Files
DeepSeek-Reasonix/internal/assembly/boot/boot_test.go
T
YHH 23f0a011c4 fix(config): workspace configuration only narrows the user's settings (#11208)
A workspace's reasonix.toml and .reasonix/settings.json are read as
untrusted input.

- [sandbox]: bash may only move to enforce and network only to false;
  forbid_read and denied_domains add to the user's; allow_write entries
  and workspace_root apply only when they resolve inside the workspace.
  ${VAR} in sandbox and proxy paths expands from the process environment
  only.
- [permissions] mode, allow and allow_dynamic_bash, [network] proxy
  settings and [desktop] default_tool_approval_mode are read from the
  user config only; deny and ask add to the user's. An "always allow"
  given in a workspace is stored under the Reasonix home
  (project-grants.json) rather than in the checkout.
- Programs a workspace declares (hooks, language servers, providers,
  tools.search.rg_path, tools.shell.path, [browser], tools.system_one
  endpoints and the local Laya runtime) run only once approved for that
  folder. An approval records the declaration and a digest of the files
  it names, is kept under the Reasonix home, and is checked again before
  each start; a change refuses the start with ErrProjectProgramChanged.
  `reasonix trust` lists and approves them, and saving hooks from the
  hooks page approves the block written. Model settings that named only
  a held-back provider fall back to the user's choice.
- Single program paths may not point into the workspace, a temporary
  directory, an allow_write root or a toolchain cache.
- Sandbox write roots and host write directories keep the identity they
  had when first granted; a root whose identity changed is dropped with
  sandbox.write_root_redirected or sandbox.write_root_changed. The
  host-writable directory list lives in internal/base/hostwrite.
- Every ignored value is reported as a load warning with a typed reason
  (Config.IgnoredProjectSettings); held-back programs raise the
  project_programs_awaiting_approval notice.
2026-09-28 18:21:34 -07:00

4431 lines
141 KiB
Go

package boot
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"reasonix/internal/state/sessionstore"
"reflect"
"runtime"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"reasonix/internal/base/netclient"
"reasonix/internal/base/secrets"
"reasonix/internal/contract/config"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
"reasonix/internal/ext/plugin"
"reasonix/internal/ext/pluginpkg"
"reasonix/internal/ext/skill"
"reasonix/internal/runtime/agent"
"reasonix/internal/runtime/agent/testutil"
"reasonix/internal/runtime/delegation"
"reasonix/internal/safety/sandbox"
"reasonix/internal/session/control"
"reasonix/internal/state/memory"
"reasonix/internal/tools/builtin"
// Blank import registers the provider kind the same way cmd/reasonix's main
// does; importing builtin above registers the built-in tools.
_ "reasonix/internal/model/anthropic"
_ "reasonix/internal/model/openai"
)
func newProviderForTest(e *config.ProviderEntry) (provider.Provider, error) {
return NewProviderWithProxy(e, netclient.ProxySpec{Mode: netclient.ModeAuto})
}
func TestAgentKeepPolicyFromConfig(t *testing.T) {
if got := agentKeepPolicy(nil); got != agent.KeepErrors|agent.KeepUserMarked {
t.Fatalf("nil keep policy = %v, want KeepErrors|KeepUserMarked", got)
}
if got := agentKeepPolicy([]string{}); got != 0 {
t.Fatalf("empty keep policy = %v, want 0", got)
}
if got := agentKeepPolicy([]string{"errors", "user_marked"}); got != agent.KeepErrors|agent.KeepUserMarked {
t.Fatalf("combined keep policy = %v, want errors|user_marked", got)
}
}
// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the
// cache-first wiring: a project REASONIX.md is discovered at boot and folded
// into the session's system message (the cached prefix), and the `remember`
// tool is registered. It builds a real Controller from a throwaway project dir.
func TestBuildOwesProjectInstructionsToTheTurnNotThePrefix(t *testing.T) {
dir := robustTempDir(t)
t.Chdir(dir)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE SYSTEM PROMPT"
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`)
approveWorkspace(t, dir)
writeFile(t, dir, "REASONIX.md", "Project rule: always run go vet before committing.")
ctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
// The system message is the cached prefix: the base prompt is in it and the
// project's own rules are not.
sys := systemMessage(ctrl.History())
if !strings.Contains(sys, "BASE SYSTEM PROMPT") {
t.Fatalf("base prompt missing from system message:\n%s", sys)
}
if strings.Contains(sys, "always run go vet before committing") {
t.Fatalf("project REASONIX.md reached the cached prefix:\n%s", sys)
}
// Discovery still happened, and the first real turn carries what it found.
if mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {
t.Fatal("controller memory set is empty after discovering REASONIX.md")
}
turn := ctrl.Compose("what should I do?")
if !strings.Contains(turn, "<project-instructions>") ||
!strings.Contains(turn, "always run go vet before committing") {
t.Fatalf("the first turn does not carry the discovered instructions:\n%s", turn)
}
// Composing does not settle the debt: bytes a blocked turn never delivered
// are not delivered. Delivery is measured through real turns in
// context_projection_test.go.
if again := ctrl.Compose("and now?"); !strings.Contains(again, "always run go vet before committing") {
t.Fatalf("a composed-but-undelivered turn cleared the instruction debt:\n%s", again)
}
}
func TestBuildRunsCleanupPendingReconciler(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`)
approveWorkspace(t, dir)
sessionDir := filepath.Join(robustTempDir(t), "sessions")
called := false
ctrl, err := Build(context.Background(), Options{
SessionDir: sessionDir,
CleanupPendingReconciler: func(got string) error {
called = true
if filepath.Clean(got) != filepath.Clean(sessionDir) {
t.Fatalf("reconciler dir = %q, want %q", got, sessionDir)
}
return nil
},
})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
if !called {
t.Fatal("cleanup-pending reconciler was not called")
}
}
func TestBuildRunsCleanupPendingDespiteSafeModeEnv(t *testing.T) {
// v1.20+: REASONIX_SAFE_MODE no longer skips cleanup reconciliation.
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
t.Setenv("REASONIX_SAFE_MODE", "1")
called := false
ctrl, err := Build(context.Background(), Options{
SessionDir: filepath.Join(robustTempDir(t), "sessions"),
CleanupPendingReconciler: func(string) error {
called = true
return nil
},
})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
if !called {
t.Fatal("cleanup-pending reconciler must still run when REASONIX_SAFE_MODE is set")
}
}
func TestBuildRegistersUsableHistoryAndMemoryRetrievalTools(t *testing.T) {
isolateConfigHome(t)
historyIndexReady := bootTestHistoryIndexReady(t)
dir := robustTempDir(t)
t.Chdir(dir)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "boot-retrieval-tool-test"
model = "x"
`)
approveWorkspace(t, dir)
sessionDir := filepath.Join(robustTempDir(t), "sessions")
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
t.Fatal(err)
}
past := sessionstore.NewSession("")
past.Add(provider.Message{Role: provider.RoleUser, Content: "Should the history layer use vector embeddings?"})
past.Add(provider.Message{Role: provider.RoleAssistant, Content: "Decision: port lightweight BM25 history retrieval without a vector database."})
if err := past.Save(filepath.Join(sessionDir, "past.jsonl")); err != nil {
t.Fatalf("save past session: %v", err)
}
store := memory.StoreFor(config.MemoryUserDir(), dir)
if _, err := store.Save(memory.Memory{
Name: "synthesis-cache-policy",
Description: "Stable conclusions should be reused from memory",
Type: memory.TypeFeedback,
Body: "Use a synthesis cache document when expensive retrieval produced a stable conclusion.",
}); err != nil {
t.Fatalf("save memory: %v", err)
}
registerBootRetrievalToolTestProvider()
// Optional retrieval tools are reached through the stable use_capability
// proxy without appearing on the provider-visible surface.
prov := testutil.NewMock("boot-retrieval-tool-test",
testutil.Turn{ToolCalls: []provider.ToolCall{
{ID: "history-1", Name: "use_capability", Arguments: `{"action":"call","capability_id":"tool:history","arguments":{"operation":"search","query":"BM25 vector database","scope":"project","limit":5}}`},
{ID: "memory-1", Name: "use_capability", Arguments: `{"action":"call","capability_id":"tool:memory","arguments":{"operation":"search","query":"synthesis cache stable conclusion","limit":5}}`},
}},
testutil.Turn{Text: "done"},
)
setBootRetrievalToolTestProvider(t, prov)
ctrl, err := Build(context.Background(), Options{Sink: event.Discard, SessionDir: sessionDir})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
waitForBootTestHistoryIndex(t, historyIndexReady)
sys := systemMessage(ctrl.History())
for _, forbidden := range []string{
"Decision: port lightweight BM25 history retrieval without a vector database.",
"Use a synthesis cache document when expensive retrieval produced a stable conclusion.",
} {
if strings.Contains(sys, forbidden) {
t.Fatalf("retrieval content should stay behind on-demand tools, not enter the cache-stable system prompt:\n%s", sys)
}
}
// Full registry still has history/memory for use_capability dispatch.
registered := map[string]bool{}
for _, e := range ctrl.AllToolContractEntries() {
registered[e.Name] = true
}
for _, want := range []string{"history", "memory", "remember", "forget", "use_capability"} {
if !registered[want] {
t.Fatalf("capability registry missing %q", want)
}
}
if err := ctrl.Run(context.Background(), "recover past context"); err != nil {
t.Fatalf("Run: %v", err)
}
reqs := prov.Requests()
if len(reqs) == 0 {
t.Fatal("provider received no requests")
}
// Provider-visible surface stays lean: use_capability only.
if !requestHasTool(reqs[0], "use_capability") {
t.Fatalf("first request missing use_capability; tools=%v", toolSchemaNames(reqs[0].Tools))
}
for _, hidden := range []string{"history", "memory", "remember", "forget"} {
if requestHasTool(reqs[0], hidden) {
t.Fatalf("first request must not expose %q top-level; tools=%v", hidden, toolSchemaNames(reqs[0].Tools))
}
}
toolResults := map[string]string{}
for _, msg := range ctrl.History() {
if msg.Role == provider.RoleTool {
toolResults[msg.Name] += "\n" + msg.Content
}
}
// use_capability returns the underlying tool output in its own result text.
combined := toolResults["use_capability"] + toolResults["history"] + toolResults["memory"]
if !strings.Contains(combined, "port lightweight BM25 history retrieval") {
t.Fatalf("history tool result did not include saved session decision:\n%s", combined)
}
if !strings.Contains(combined, "synthesis-cache-policy") ||
!strings.Contains(combined, "stable conclusion") {
t.Fatalf("memory tool result did not include saved memory:\n%s", combined)
}
}
const bootRetrievalToolTestProviderKind = "boot-retrieval-tool-test"
var (
bootRetrievalToolTestProviderOnce sync.Once
bootRetrievalToolTestProviderCurrent *testutil.MockProvider
bootRetrievalToolTestProviderMu sync.Mutex
)
func registerBootRetrievalToolTestProvider() {
bootRetrievalToolTestProviderOnce.Do(func() {
provider.Register(bootRetrievalToolTestProviderKind, func(provider.Config) (provider.Provider, error) {
bootRetrievalToolTestProviderMu.Lock()
defer bootRetrievalToolTestProviderMu.Unlock()
if bootRetrievalToolTestProviderCurrent == nil {
return nil, errors.New("boot retrieval tool test provider is not installed")
}
return bootRetrievalToolTestProviderCurrent, nil
})
})
}
func setBootRetrievalToolTestProvider(t *testing.T, p *testutil.MockProvider) {
t.Helper()
bootRetrievalToolTestProviderMu.Lock()
bootRetrievalToolTestProviderCurrent = p
bootRetrievalToolTestProviderMu.Unlock()
t.Cleanup(func() {
bootRetrievalToolTestProviderMu.Lock()
if bootRetrievalToolTestProviderCurrent == p {
bootRetrievalToolTestProviderCurrent = nil
}
bootRetrievalToolTestProviderMu.Unlock()
})
}
const bootTokenProfileTestProviderKind = "boot-token-profile-test"
var (
bootTokenProfileTestProviderOnce sync.Once
bootTokenProfileTestProviderCurrent *testutil.MockProvider
bootTokenProfileTestProviderMu sync.Mutex
)
func registerBootTokenProfileTestProvider() {
bootTokenProfileTestProviderOnce.Do(func() {
provider.Register(bootTokenProfileTestProviderKind, func(provider.Config) (provider.Provider, error) {
bootTokenProfileTestProviderMu.Lock()
defer bootTokenProfileTestProviderMu.Unlock()
if bootTokenProfileTestProviderCurrent == nil {
return nil, errors.New("boot token profile test provider is not installed")
}
return bootTokenProfileTestProviderCurrent, nil
})
})
}
func setBootTokenProfileTestProvider(t *testing.T, p *testutil.MockProvider) {
t.Helper()
bootTokenProfileTestProviderMu.Lock()
bootTokenProfileTestProviderCurrent = p
bootTokenProfileTestProviderMu.Unlock()
t.Cleanup(func() {
bootTokenProfileTestProviderMu.Lock()
if bootTokenProfileTestProviderCurrent == p {
bootTokenProfileTestProviderCurrent = nil
}
bootTokenProfileTestProviderMu.Unlock()
})
}
func requestHasTool(req provider.Request, name string) bool {
for _, schema := range req.Tools {
if schema.Name == name {
return true
}
}
return false
}
func requestMessageContains(messages []provider.Message, role provider.Role, needle string) bool {
for _, message := range messages {
if message.Role == role && strings.Contains(message.Content, needle) {
return true
}
}
return false
}
func requestToolSchemaContains(req provider.Request, name, want string) bool {
for _, schema := range req.Tools {
if schema.Name == name {
return strings.Contains(string(schema.Parameters), want)
}
}
return false
}
func requestHasToolPrefix(req provider.Request, prefix string) bool {
for _, schema := range req.Tools {
if strings.HasPrefix(schema.Name, prefix) {
return true
}
}
return false
}
func toolSchemaNames(tools []provider.ToolSchema) []string {
names := make([]string, 0, len(tools))
for _, schema := range tools {
names = append(names, schema.Name)
}
return names
}
// agentRequests keeps only the executor's own sampling calls. Under the
// delivery role setting the capability router samples first, and it is offered
// no tool surface — so carrying one identifies the agent without the caller
// having to know how many other components sampled.
func agentRequests(reqs []provider.Request) []provider.Request {
out := make([]provider.Request, 0, len(reqs))
for _, req := range reqs {
if len(req.Tools) > 0 {
out = append(out, req)
}
}
return out
}
func firstTokenProfileRequest(t *testing.T, tokenMode string) provider.Request {
t.Helper()
registerBootTokenProfileTestProvider()
prov := testutil.NewMock("token-profile", testutil.Turn{Text: "[]"}, testutil.Turn{Text: "done"})
setBootTokenProfileTestProvider(t, prov)
opts := Options{Sink: event.Discard}
if tokenMode != "" {
opts.TokenMode = tokenMode
}
ctrl, err := Build(context.Background(), opts)
if err != nil {
t.Fatalf("Build(%q): %v", tokenMode, err)
}
defer ctrl.Close()
if err := ctrl.Run(context.Background(), "capture request prefix"); err != nil {
t.Fatalf("Run(%q): %v", tokenMode, err)
}
reqs := agentRequests(prov.Requests())
if len(reqs) != 1 {
t.Fatalf("agent requests(%q) = %d, want 1", tokenMode, len(reqs))
}
return reqs[0]
}
func captureTokenProfileSurface(t *testing.T, tokenMode string) (provider.Request, []tool.ContractEntry) {
t.Helper()
registerBootTokenProfileTestProvider()
prov := testutil.NewMock("token-profile", testutil.Turn{Text: "done"})
setBootTokenProfileTestProvider(t, prov)
opts := Options{Sink: event.Discard}
if tokenMode != "" {
opts.TokenMode = tokenMode
}
ctrl, err := Build(context.Background(), opts)
if err != nil {
t.Fatalf("Build(%q): %v", tokenMode, err)
}
defer ctrl.Close()
if err := ctrl.Run(context.Background(), "capture contract"); err != nil {
t.Fatalf("Run(%q): %v", tokenMode, err)
}
reqs := prov.Requests()
if len(reqs) != 1 {
t.Fatalf("requests(%q) = %d, want 1", tokenMode, len(reqs))
}
return reqs[0], ctrl.ToolContractEntries()
}
func TestBuildSubagentSkillFailedContinuationPersistsTranscript(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
registerBootSubagentTestProvider()
prov := &bootSubagentTestProvider{}
setBootSubagentTestProvider(t, prov)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "boot-subagent-test"
model = "x"
`)
approveWorkspace(t, dir)
ctrl, err := Build(context.Background(), Options{Sink: event.Discard})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
sessionPath := sessionstore.NewSessionPath(ctrl.SessionDir(), ctrl.Label())
ctrl.SetSessionPath(sessionPath)
if err := ctrl.Run(context.Background(), "first review"); err != nil {
t.Fatalf("first Run: %v", err)
}
ref := subagentRefFromHistory(t, ctrl.History())
prov.setContinueRef(ref)
if err := ctrl.Run(context.Background(), "continue review"); err != nil {
t.Fatalf("second Run: %v", err)
}
store := delegation.NewSubagentStore(filepath.Join(config.SessionDir(), "subagents"))
meta, err := store.LoadMeta(ref)
if err != nil {
t.Fatalf("LoadMeta: %v", err)
}
if meta.Status != sessionstore.SubagentFailed {
t.Fatalf("status = %q, want failed", meta.Status)
}
if meta.ParentSession != sessionstore.BranchID(sessionPath) {
t.Fatalf("parent session = %q, want %q", meta.ParentSession, sessionstore.BranchID(sessionPath))
}
sess, err := sessionstore.LoadSession(filepath.Join(config.SessionDir(), "subagents", ref+".jsonl"))
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
msgs := sess.Snapshot()
modelMessages := provider.ModelMessages(msgs)
if len(msgs) < 5 || len(modelMessages) < 4 || !strings.Contains(modelMessages[1].Content, "first skill task") || modelMessages[2].Content != "first skill answer" || !strings.Contains(modelMessages[3].Content, "second skill task") || !msgs[len(msgs)-1].LocalOnly {
t.Fatalf("failed skill transcript = %+v, want tasks plus provider-excluded failure recovery", msgs)
}
}
func TestBuildSubagentStoreHonorsSessionDirOverride(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
registerBootSubagentTestProvider()
prov := &bootSubagentTestProvider{}
setBootSubagentTestProvider(t, prov)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "boot-subagent-test"
model = "x"
`)
approveWorkspace(t, dir)
sessionDir := filepath.Join(robustTempDir(t), "desktop-workspace-sessions")
ctrl, err := Build(context.Background(), Options{Sink: event.Discard, SessionDir: sessionDir})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
sessionPath := sessionstore.NewSessionPath(ctrl.SessionDir(), ctrl.Label())
ctrl.SetSessionPath(sessionPath)
if err := ctrl.Run(context.Background(), "first review"); err != nil {
t.Fatalf("Run: %v", err)
}
ref := subagentRefFromHistory(t, ctrl.History())
overrideStore := delegation.NewSubagentStore(filepath.Join(sessionDir, "subagents"))
meta, err := overrideStore.LoadMeta(ref)
if err != nil {
t.Fatalf("LoadMeta from override dir: %v", err)
}
if meta.ParentSession != sessionstore.BranchID(sessionPath) {
t.Fatalf("parent session = %q, want %q", meta.ParentSession, sessionstore.BranchID(sessionPath))
}
if _, err := os.Stat(filepath.Join(config.SessionDir(), "subagents", ref+".meta.json")); !os.IsNotExist(err) {
t.Fatalf("subagent metadata should not be written to global session dir, stat err = %v", err)
}
}
func TestBuildSubagentSkillUsesLiveReasoningLanguage(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
registerBootSubagentTestProvider()
prov := &bootSubagentTestProvider{}
setBootSubagentTestProvider(t, prov)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
reasoning_language = "zh"
[[providers]]
name = "test-model"
kind = "boot-subagent-test"
model = "x"
`)
approveWorkspace(t, dir)
ctrl, err := Build(context.Background(), Options{Sink: event.Discard})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
ctrl.SetReasoningLanguage("auto")
if err := ctrl.Run(context.Background(), "first review"); err != nil {
t.Fatalf("Run: %v", err)
}
reqs := prov.requestsSnapshot()
if len(reqs) < 2 {
t.Fatalf("provider requests = %d, want parent request plus skill subagent request", len(reqs))
}
if got := bootLastUser(reqs[1]); strings.Contains(got, "<reasoning-language>") {
t.Fatalf("skill subagent kept stale boot-time reasoning language after live auto update: %q", got)
}
if got := bootLastUser(reqs[1]); !strings.Contains(got, `<subagent-context event="SubagentStart">`) || !strings.Contains(got, "first skill task") {
t.Fatalf("skill subagent user prompt = %q, want SubagentStart context plus first skill task", got)
}
}
func TestBuildDeepSeekTextParentCanUseImageReturningMCPAndVisionSubagent(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
registerBootSubagentTestProvider()
prov := &bootSubagentTestProvider{combinedVision: true}
setBootSubagentTestProvider(t, prov)
if _, err := config.SetCredential("BOOT_DEEPSEEK_TEST_KEY", "test-key"); err != nil {
t.Fatalf("store test DeepSeek credential: %v", err)
}
writeFile(t, dir, "reasonix.toml", `
default_model = "parent"
[agent]
system_prompt = "BASE"
subagent_model = "vision-model"
[[providers]]
name = "parent"
kind = "boot-subagent-test"
base_url = "https://api.deepseek.com/anthropic"
model = "x"
api_key_env = "BOOT_DEEPSEEK_TEST_KEY"
[[providers]]
name = "vision-model"
kind = "boot-subagent-test"
base_url = "https://vision.example.invalid"
model = "x"
vision = true
`)
approveWorkspace(t, dir)
png, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==")
if err != nil {
t.Fatalf("decode test png: %v", err)
}
if err := os.MkdirAll(filepath.Join(dir, ".reasonix", "attachments"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".reasonix", "attachments", "shot.png"), png, 0o644); err != nil {
t.Fatal(err)
}
mcpImage := base64.StdEncoding.EncodeToString(png)
var mcpCalls atomic.Int32
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
ID *int `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if request.ID == nil {
w.WriteHeader(http.StatusAccepted)
return
}
var result any
switch request.Method {
case "initialize":
result = map[string]any{
"protocolVersion": "2024-11-05",
"serverInfo": map[string]any{"name": "vision-reader", "version": "1"},
"capabilities": map[string]any{"tools": map[string]any{}},
}
case "tools/list":
result = map[string]any{"tools": []map[string]any{{
"name": "inspect",
"description": "Inspect an image file by path.",
"inputSchema": map[string]any{
"type": "object",
"properties": map[string]any{"path": map[string]any{"type": "string"}},
"required": []string{"path"},
},
"annotations": map[string]any{"readOnlyHint": true},
}}}
case "tools/call":
mcpCalls.Add(1)
result = map[string]any{"content": []map[string]any{
{"type": "text", "text": "vision-mcp-ok"},
{"type": "image", "mimeType": "image/png", "data": mcpImage},
}}
default:
http.Error(w, "unsupported method", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": *request.ID, "result": result})
}))
defer mcpServer.Close()
ctrl, err := Build(context.Background(), Options{
Sink: event.Discard,
ExtraPlugins: []plugin.Spec{{
Name: "vision-reader",
Type: "http",
URL: mcpServer.URL,
Authorized: true,
}},
})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
if ctrl.ImageInputEnabled() {
loaded, loadErr := config.LoadForRoot(dir)
resolved, _ := loaded.ResolveModel(ctrl.ModelRef())
t.Fatalf("official DeepSeek parent unexpectedly enables direct images: ref=%q entry=%+v load_err=%v", ctrl.ModelRef(), resolved, loadErr)
}
if err := ctrl.Run(context.Background(), "review @.reasonix/attachments/shot.png"); err != nil {
t.Fatalf("Run: %v", err)
}
reqs := prov.requestsSnapshot()
if len(reqs) != 4 {
t.Fatalf("provider requests = %d, want parent MCP call, parent subagent call, vision child, and parent final", len(reqs))
}
if got := mcpCalls.Load(); got != 1 {
t.Fatalf("vision MCP calls = %d, want 1", got)
}
// MCP tools stay behind use_capability; review is registered for dispatch.
if !requestHasTool(reqs[0], "use_capability") {
t.Fatalf("parent tools = %v, want use_capability for MCP/review dispatch", toolSchemaNames(reqs[0].Tools))
}
registered := map[string]bool{}
for _, e := range ctrl.AllToolContractEntries() {
registered[e.Name] = true
}
if !registered["review"] && !registered["run_skill"] {
t.Fatalf("capability registry missing review/run_skill: %v", registered)
}
if got := bootLastUser(reqs[0]); !strings.Contains(got, "@.reasonix/attachments/shot.png") {
t.Fatalf("text-only parent lost the attachment reference needed by MCP: %q", got)
}
// The direct attachment remains candidate-only for the text parent. An MCP
// image is intentionally retained on its local tool-result message; the real
// DeepSeek adapter tests assert that this exact role is omitted on the wire.
for _, requestIndex := range []int{0, 1, 3} {
for _, msg := range reqs[requestIndex].Messages {
if msg.Role == provider.RoleUser && len(msg.Images) != 0 {
t.Fatalf("text-only parent request %d embedded %d direct attachment(s): %+v", requestIndex, len(msg.Images), reqs[requestIndex].Messages)
}
}
}
if !requestMessageContains(reqs[1].Messages, provider.RoleTool, "vision-mcp-ok") {
t.Fatalf("parent did not receive the vision MCP text result: %+v", reqs[1].Messages)
}
var parentMCPImageCount int
for _, msg := range reqs[1].Messages {
if msg.Role == provider.RoleTool && msg.Name == "mcp__vision-reader__inspect" {
parentMCPImageCount += len(msg.Images)
}
}
if parentMCPImageCount != 1 {
t.Fatalf("parent local MCP result images = %d, want one image for provider-boundary filtering", parentMCPImageCount)
}
var childImageCount int
for _, msg := range reqs[2].Messages {
if msg.Role == provider.RoleUser {
childImageCount = len(msg.Images)
}
}
if childImageCount != 1 {
t.Fatalf("vision child user images = %d, want one attachment; request = %+v", childImageCount, reqs[2].Messages)
}
}
func TestBuildUsesConfiguredLanguageForResponsePreference(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
registerBootSubagentTestProvider()
prov := &bootSubagentTestProvider{}
setBootSubagentTestProvider(t, prov)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
language = "en"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "boot-subagent-test"
model = "x"
`)
approveWorkspace(t, dir)
ctrl, err := Build(context.Background(), Options{Sink: event.Discard})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
if err := ctrl.Run(context.Background(), "first review"); err != nil {
t.Fatalf("Run: %v", err)
}
reqs := prov.requestsSnapshot()
if len(reqs) == 0 {
t.Fatal("provider requests = 0, want at least one")
}
if got := bootLastUser(reqs[0]); !strings.Contains(got, "<response-language>") || !strings.Contains(got, "use English") {
t.Fatalf("first user turn = %q, want English response preference", got)
}
}
// TestBuildReviewSubagentSkillEnforcesReadOnlyBash pins the review builtin's
// read-only contract at the tool boundary: its sub-agent gets the plan-mode
// safe bash wrapper, not the writer-capable foreground bash.
func TestBuildReviewSubagentSkillEnforcesReadOnlyBash(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
registerBootSubagentTestProvider()
prov := &bootSubagentTestProvider{reviewReport: true}
setBootSubagentTestProvider(t, prov)
writeFile(t, dir, "target.go", "package p\n")
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "boot-subagent-test"
model = "x"
`)
approveWorkspace(t, dir)
ctrl, err := Build(context.Background(), Options{Sink: event.Discard})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
ctrl.SetSessionPath(sessionstore.NewSessionPath(ctrl.SessionDir(), ctrl.Label()))
if err := ctrl.Run(context.Background(), "first review"); err != nil {
t.Fatalf("Run: %v", err)
}
reqs := prov.requestsSnapshot()
if len(reqs) < 2 {
t.Fatalf("provider requests = %d, want parent request plus skill subagent request", len(reqs))
}
parentReq, subReq := reqs[0], reqs[1]
// Core shell tools stay top-level; task is dispatched via use_capability.
for _, want := range []string{"bash", "wait", "bash_output", "kill_shell", "use_capability"} {
if !requestHasTool(parentReq, want) {
t.Fatalf("parent request missing %q; tools=%v", want, toolSchemaNames(parentReq.Tools))
}
}
registered := map[string]bool{}
for _, e := range ctrl.AllToolContractEntries() {
registered[e.Name] = true
}
if !registered["task"] && !registered["review"] {
t.Fatalf("capability registry missing task/review for skill subagent dispatch")
}
if !requestToolSchemaContains(parentReq, "bash", "run_in_background") {
t.Fatalf("parent bash schema should include run_in_background")
}
for _, hidden := range []string{"task", "run_skill", "read_only_skill", "read_skill", "install_skill", "install_source", "explore", "research", "review", "security_review", "wait", "bash_output", "kill_shell"} {
if requestHasTool(subReq, hidden) {
t.Fatalf("skill subagent request should hide %q; tools=%v", hidden, toolSchemaNames(subReq.Tools))
}
}
if !requestHasTool(subReq, "bash") {
t.Fatalf("skill subagent request should keep bash; tools=%v", toolSchemaNames(subReq.Tools))
}
if requestToolSchemaContains(subReq, "bash", "run_in_background") {
t.Fatalf("skill subagent bash schema should not include run_in_background")
}
if !requestToolDescriptionContains(subReq, "bash", "Only permission-classified read-only commands are allowed") {
t.Fatalf("review subagent bash must advertise its permission-layer read-only policy; got %q", requestToolDescription(subReq, "bash"))
}
}
func requestToolDescription(req provider.Request, name string) string {
for _, schema := range req.Tools {
if schema.Name == name {
return schema.Description
}
}
return ""
}
func requestToolDescriptionContains(req provider.Request, name, want string) bool {
return strings.Contains(requestToolDescription(req, name), want)
}
// TestBuildRunSkillSubagentRegistryHonorsReadOnlyFlag proves the registry split
// for user-defined subagent skills: a plain skill keeps writer tools and the
// foreground-only bash, while a `read-only: true` skill is stripped to research
// tools plus the permission-classified read-only bash wrapper.
func TestBuildRunSkillSubagentRegistryHonorsReadOnlyFlag(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
registerBootTokenProfileTestProvider()
prov := testutil.NewMock("run-skill-readonly",
testutil.Turn{ToolCalls: []provider.ToolCall{
{ID: "w-1", Name: "run_skill", Arguments: `{"name":"wskill","arguments":"write things"}`},
}},
testutil.Turn{Text: "writer sub done"},
testutil.Turn{ToolCalls: []provider.ToolCall{
{ID: "ro-1", Name: "run_skill", Arguments: `{"name":"roskill","arguments":"inspect things"}`},
}},
testutil.Turn{Text: "read-only sub done"},
testutil.Turn{Text: "done"},
)
setBootTokenProfileTestProvider(t, prov)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "boot-token-profile-test"
model = "x"
`)
approveWorkspace(t, dir)
writeFile(t, dir, ".reasonix/skills/wskill.md",
"---\ndescription: writer skill\nrunAs: subagent\nallowed-tools: bash, read_file, write_file\n---\nwriter body")
writeFile(t, dir, ".reasonix/skills/roskill.md",
"---\ndescription: read-only skill\nrunAs: subagent\nallowed-tools: bash, read_file, write_file\nread-only: true\n---\nread-only body")
ctrl, err := Build(context.Background(), Options{Sink: event.Discard})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
if err := ctrl.Run(context.Background(), "run both skills"); err != nil {
t.Fatalf("Run: %v", err)
}
reqs := prov.Requests()
if len(reqs) != 5 {
t.Fatalf("provider requests = %d, want 5 (parent, writer sub, parent, read-only sub, parent)", len(reqs))
}
writerReq, roReq := reqs[1], reqs[3]
if !requestHasTool(writerReq, "write_file") {
t.Fatalf("writer skill subagent should keep write_file; tools=%v", toolSchemaNames(writerReq.Tools))
}
if !requestToolDescriptionContains(writerReq, "bash", "Background execution is unavailable inside subagents") {
t.Fatalf("writer skill subagent bash should be the foreground-only wrapper; got %q", requestToolDescription(writerReq, "bash"))
}
if requestToolDescriptionContains(writerReq, "bash", "Only permission-classified read-only commands are allowed") {
t.Fatalf("writer skill subagent bash must not be the read-only wrapper; got %q", requestToolDescription(writerReq, "bash"))
}
if requestHasTool(roReq, "write_file") {
t.Fatalf("read-only skill subagent must strip write_file; tools=%v", toolSchemaNames(roReq.Tools))
}
if !requestHasTool(roReq, "read_file") {
t.Fatalf("read-only skill subagent should keep read_file; tools=%v", toolSchemaNames(roReq.Tools))
}
if !requestToolDescriptionContains(roReq, "bash", "Only permission-classified read-only commands are allowed") {
t.Fatalf("read-only skill subagent bash must be the permission-layer wrapper; got %q", requestToolDescription(roReq, "bash"))
}
}
const bootSubagentTestProviderKind = "boot-subagent-test"
var (
bootSubagentTestProviderOnce sync.Once
bootSubagentTestProviderCurrent *bootSubagentTestProvider
bootSubagentTestProviderMu sync.Mutex
)
func registerBootSubagentTestProvider() {
bootSubagentTestProviderOnce.Do(func() {
provider.Register(bootSubagentTestProviderKind, func(provider.Config) (provider.Provider, error) {
bootSubagentTestProviderMu.Lock()
defer bootSubagentTestProviderMu.Unlock()
if bootSubagentTestProviderCurrent == nil {
return nil, errors.New("boot subagent test provider is not installed")
}
return bootSubagentTestProviderCurrent, nil
})
})
}
func setBootSubagentTestProvider(t *testing.T, p *bootSubagentTestProvider) {
t.Helper()
bootSubagentTestProviderMu.Lock()
bootSubagentTestProviderCurrent = p
bootSubagentTestProviderMu.Unlock()
t.Cleanup(func() {
bootSubagentTestProviderMu.Lock()
if bootSubagentTestProviderCurrent == p {
bootSubagentTestProviderCurrent = nil
}
bootSubagentTestProviderMu.Unlock()
})
}
type bootSubagentTestProvider struct {
mu sync.Mutex
calls int
continueRef string
requests []provider.Request
combinedVision bool
reviewReport bool
}
func (p *bootSubagentTestProvider) Name() string { return "boot-subagent-test" }
func (p *bootSubagentTestProvider) setContinueRef(ref string) {
p.mu.Lock()
defer p.mu.Unlock()
p.continueRef = ref
}
func (p *bootSubagentTestProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
p.mu.Lock()
call := p.calls
p.calls++
ref := p.continueRef
p.requests = append(p.requests, req)
combinedVision := p.combinedVision
p.mu.Unlock()
var chunks []provider.Chunk
if combinedVision {
switch call {
case 0:
chunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{
ID: "vision-mcp-1", Name: "mcp__vision-reader__inspect",
Arguments: `{"path":".reasonix/attachments/shot.png"}`,
}}}
case 1:
chunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{
ID: "vision-explore-1", Name: "explore", Arguments: `{"task":"inspect the attached image"}`,
}}}
case 2:
chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "vision child answer"}, {Type: provider.ChunkDone}}
case 3:
chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "parent done"}, {Type: provider.ChunkDone}}
default:
chunks = []provider.Chunk{{Type: provider.ChunkError, Err: fmt.Errorf("unexpected combined vision provider call %d", call)}}
}
ch := make(chan provider.Chunk, len(chunks))
for _, chunk := range chunks {
ch <- chunk
}
close(ch)
return ch, nil
}
if p.reviewReport {
// The review contract end to end at the default role setting: the child
// reads a file, submits the typed report the host verifies against that
// read, and only then may finish.
switch call {
case 0:
chunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "review-1", Name: "review", Arguments: `{"task":"review the change"}`}}}
case 1:
chunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "read-1", Name: "read_file", Arguments: `{"path":"target.go"}`}}}
case 2:
chunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "report-1", Name: "review_report", Arguments: `{"kind":"review","verdict":"pass","reviewed_paths":["target.go"],"findings":[]}`}}}
case 3:
chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "review child answer"}, {Type: provider.ChunkDone}}
case 4:
chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "parent done"}, {Type: provider.ChunkDone}}
default:
chunks = []provider.Chunk{{Type: provider.ChunkError, Err: fmt.Errorf("unexpected review provider call %d", call)}}
}
ch := make(chan provider.Chunk, len(chunks))
for _, chunk := range chunks {
ch <- chunk
}
close(ch)
return ch, nil
}
switch call {
case 0:
chunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "explore-1", Name: "explore", Arguments: `{"task":"first skill task"}`}}}
case 1:
chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "first skill answer"}, {Type: provider.ChunkDone}}
case 2:
chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "parent first done"}, {Type: provider.ChunkDone}}
case 3:
args, _ := json.Marshal(map[string]string{"task": "second skill task", "continue_from": ref})
chunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "explore-2", Name: "explore", Arguments: string(args)}}}
case 4:
chunks = []provider.Chunk{{Type: provider.ChunkError, Err: errors.New("subagent skill failed")}}
case 5:
chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "parent second done"}, {Type: provider.ChunkDone}}
default:
chunks = []provider.Chunk{{Type: provider.ChunkError, Err: fmt.Errorf("unexpected provider call %d", call)}}
}
ch := make(chan provider.Chunk, len(chunks))
for _, chunk := range chunks {
ch <- chunk
}
close(ch)
return ch, nil
}
func (p *bootSubagentTestProvider) requestsSnapshot() []provider.Request {
p.mu.Lock()
defer p.mu.Unlock()
out := make([]provider.Request, len(p.requests))
copy(out, p.requests)
return out
}
func bootLastUser(req provider.Request) string {
for _, v := range slices.Backward(req.Messages) {
if v.Role == provider.RoleUser {
return v.Content
}
}
return ""
}
func subagentRefFromHistory(t *testing.T, msgs []provider.Message) string {
t.Helper()
for _, msg := range msgs {
if msg.Role != provider.RoleTool {
continue
}
for line := range strings.SplitSeq(msg.Content, "\n") {
if after, ok := strings.CutPrefix(line, "Subagent reference: "); ok {
return strings.TrimSpace(after)
}
}
}
t.Fatalf("no subagent reference in history: %+v", msgs)
return ""
}
// TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath reproduces headless
// `reasonix run`: a controller built via Build with NO SetSessionPath (exactly
// what internal/frontend/cli.runAgent does) must still be able to run a `task` sub-agent.
// Before the ephemeral fallback this failed with "parent session is required".
func TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
registerHeadlessTaskTestProvider()
prov := &headlessTaskTestProvider{}
setHeadlessTaskTestProvider(t, prov)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "boot-headless-test"
model = "x"
`)
approveWorkspace(t, dir)
ctrl, err := Build(context.Background(), Options{Sink: event.Discard})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
// Deliberately NOT calling SetSessionPath — this is the headless run path.
if err := ctrl.Run(context.Background(), "use a task subagent"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := ctrl.SessionPath(); got != "" {
t.Fatalf("headless run should keep an empty session path, got %q", got)
}
var toolContent strings.Builder
for _, msg := range ctrl.History() {
if msg.Role == provider.RoleTool {
toolContent.WriteString("\n" + msg.Content)
}
}
if strings.Contains(toolContent.String(), "parent session is required") {
t.Fatalf("task subagent failed in headless run mode: %s", toolContent.String())
}
if !strings.Contains(toolContent.String(), "subagent answer") {
t.Fatalf("task tool result = %q, want sub-agent answer", toolContent.String())
}
if strings.Contains(toolContent.String(), "Subagent reference") {
t.Fatalf("ephemeral headless run should not persist a transcript reference: %s", toolContent.String())
}
}
const headlessTaskTestProviderKind = "boot-headless-test"
var (
headlessTaskTestProviderOnce sync.Once
headlessTaskTestProviderCurrent *headlessTaskTestProvider
headlessTaskTestProviderMu sync.Mutex
)
func registerHeadlessTaskTestProvider() {
headlessTaskTestProviderOnce.Do(func() {
provider.Register(headlessTaskTestProviderKind, func(provider.Config) (provider.Provider, error) {
headlessTaskTestProviderMu.Lock()
defer headlessTaskTestProviderMu.Unlock()
if headlessTaskTestProviderCurrent == nil {
return nil, errors.New("headless task test provider is not installed")
}
return headlessTaskTestProviderCurrent, nil
})
})
}
func setHeadlessTaskTestProvider(t *testing.T, p *headlessTaskTestProvider) {
t.Helper()
headlessTaskTestProviderMu.Lock()
headlessTaskTestProviderCurrent = p
headlessTaskTestProviderMu.Unlock()
t.Cleanup(func() {
headlessTaskTestProviderMu.Lock()
if headlessTaskTestProviderCurrent == p {
headlessTaskTestProviderCurrent = nil
}
headlessTaskTestProviderMu.Unlock()
})
}
type headlessTaskTestProvider struct {
mu sync.Mutex
calls int
}
func (p *headlessTaskTestProvider) Name() string { return "boot-headless-test" }
func (p *headlessTaskTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
p.mu.Lock()
call := p.calls
p.calls++
p.mu.Unlock()
var chunks []provider.Chunk
switch call {
case 0:
chunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "task-1", Name: "task", Arguments: `{"prompt":"find callers"}`}}}
case 1:
chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "subagent answer"}, {Type: provider.ChunkDone}}
default:
chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "parent done"}, {Type: provider.ChunkDone}}
}
ch := make(chan provider.Chunk, len(chunks))
for _, chunk := range chunks {
ch <- chunk
}
close(ch)
return ch, nil
}
// TestBuildHeadlessApprovalModePropagatesToTaskSubagentGate pins boot.Build's
// actual wiring for the fix: a `task` sub-agent spawned from a headless run
// must honor the same --permission-mode contract as the parent executor
// instead of the mode-unaware default gate that boot used to build
// unconditionally. Ask and Auto must fail closed on write_file's
// explicit ask rule even inside the sub-agent; only yolo may bypass it.
func TestBuildHeadlessApprovalModePropagatesToTaskSubagentGate(t *testing.T) {
runTaskWriteOnce := func(t *testing.T, mode string) bool {
t.Helper()
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
registerHeadlessTaskWriteTestProvider()
prov := &headlessTaskWriteTestProvider{}
setHeadlessTaskWriteTestProvider(t, prov)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[permissions]
mode = "ask"
ask = ["write_file"]
[[providers]]
name = "test-model"
kind = "boot-headless-write-test"
model = "x"
`)
approveWorkspace(t, dir)
ctrl, err := Build(context.Background(), Options{Sink: event.Discard, HeadlessApprovalMode: mode})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
// The fake provider writes without verifying, so the role setting's
// readiness gate legitimately reports a gap. What this test asserts is
// the approval contract — whether sub.txt exists — not turn readiness.
if err := ctrl.Run(context.Background(), "use a task subagent to write a file"); err != nil &&
!strings.Contains(err.Error(), "readiness") {
t.Fatalf("Run: %v", err)
}
_, statErr := os.Stat(filepath.Join(dir, "sub.txt"))
return statErr == nil
}
if written := runTaskWriteOnce(t, "ask"); written {
t.Fatalf("ask: task sub-agent wrote sub.txt despite having no approval UI")
}
if written := runTaskWriteOnce(t, "auto"); written {
t.Fatalf("auto: task sub-agent wrote sub.txt despite the explicit ask rule on write_file")
}
if written := runTaskWriteOnce(t, "yolo"); !written {
t.Fatal("yolo: task sub-agent did not write sub.txt, want the ask rule bypassed")
}
}
// TestBuildIgnoresRetiredAutoRecoveryKillSwitch freezes the contract that the
// short-lived global and project keys no longer disable built-in Auto Guard.
func TestBuildIgnoresRetiredAutoRecoveryKillSwitch(t *testing.T) {
isolateConfigHome(t)
userCfg := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(userCfg), 0o755); err != nil {
t.Fatalf("mkdir user config: %v", err)
}
if err := os.WriteFile(userCfg, []byte(`
default_model = "test-model"
[agent]
auto_recovery_checkpoint = "on"
system_prompt = "GLOBAL"
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`), 0o644); err != nil {
t.Fatalf("write user config: %v", err)
}
dir := robustTempDir(t)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
auto_recovery_checkpoint = "off"
system_prompt = "PROJECT"
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`)
approveWorkspace(t, dir)
ctrl, err := Build(context.Background(), Options{WorkspaceRoot: dir, Sink: event.Discard})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
// Retired keys do not block construction or fresh-session rotation.
fresh := filepath.Join(dir, "fresh-session.jsonl")
ctrl.SetFreshSessionPath(fresh)
if got := ctrl.SessionPath(); got != fresh {
t.Fatalf("fresh session path = %q, want %q", got, fresh)
}
}
func TestRecoveryHeadlessModeUsesExplicitFrontendCapability(t *testing.T) {
if recoveryHeadlessMode(Options{}) {
t.Fatal("interactive frontend without HeadlessApprovalMode must remain answerable")
}
if recoveryHeadlessMode(Options{ApprovalTimeout: time.Minute}) {
t.Fatal("a bounded bot approval timeout must not make recovery headless")
}
if !recoveryHeadlessMode(Options{HeadlessApprovalMode: control.ToolApprovalAuto}) {
t.Fatal("reasonix run Auto mode must fail closed instead of waiting for a card")
}
if !recoveryHeadlessMode(Options{HeadlessApprovalMode: control.ToolApprovalAsk}) {
t.Fatal("all explicit headless permission modes must use the non-waiting recovery path")
}
}
// TestBuildInteractiveApprovalModeSwitchPropagatesToTaskSubagentGate pins the
// interactive counterpart of TestBuildHeadlessApprovalModePropagatesToTaskSubagentGate:
// boot.Build with no HeadlessApprovalMode — the interactive REPL's boot path,
// which always starts a session at the default Ask posture and switches modes
// later at runtime via Shift+Tab (Controller.SetToolApprovalMode) — followed
// by a runtime switch to auto must also reach the task sub-agent's gate.
// Before this fix, the sub-agent gate was captured once at boot with the
// mode-unaware default and had no rebuild hook, so a
// later SetToolApprovalMode(auto) call updated only the parent executor.
func TestBuildInteractiveApprovalModeSwitchPropagatesToTaskSubagentGate(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
registerHeadlessTaskWriteTestProvider()
prov := &headlessTaskWriteTestProvider{}
setHeadlessTaskWriteTestProvider(t, prov)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[permissions]
mode = "ask"
ask = ["write_file"]
[[providers]]
name = "test-model"
kind = "boot-headless-write-test"
model = "x"
`)
approveWorkspace(t, dir)
ctrl, err := Build(context.Background(), Options{Sink: event.Discard})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
ctrl.SetToolApprovalMode("auto")
if err := ctrl.Run(context.Background(), "use a task subagent to write a file"); err != nil {
t.Fatalf("Run: %v", err)
}
if _, statErr := os.Stat(filepath.Join(dir, "sub.txt")); statErr == nil {
t.Fatal("auto (interactive mode switch): task sub-agent wrote sub.txt despite the explicit ask rule on write_file")
}
}
const headlessTaskWriteTestProviderKind = "boot-headless-write-test"
var (
headlessTaskWriteTestProviderOnce sync.Once
headlessTaskWriteTestProviderCurrent *headlessTaskWriteTestProvider
headlessTaskWriteTestProviderMu sync.Mutex
)
func registerHeadlessTaskWriteTestProvider() {
headlessTaskWriteTestProviderOnce.Do(func() {
provider.Register(headlessTaskWriteTestProviderKind, func(provider.Config) (provider.Provider, error) {
headlessTaskWriteTestProviderMu.Lock()
defer headlessTaskWriteTestProviderMu.Unlock()
if headlessTaskWriteTestProviderCurrent == nil {
return nil, errors.New("headless task write test provider is not installed")
}
return headlessTaskWriteTestProviderCurrent, nil
})
})
}
func setHeadlessTaskWriteTestProvider(t *testing.T, p *headlessTaskWriteTestProvider) {
t.Helper()
headlessTaskWriteTestProviderMu.Lock()
headlessTaskWriteTestProviderCurrent = p
headlessTaskWriteTestProviderMu.Unlock()
t.Cleanup(func() {
headlessTaskWriteTestProviderMu.Lock()
if headlessTaskWriteTestProviderCurrent == p {
headlessTaskWriteTestProviderCurrent = nil
}
headlessTaskWriteTestProviderMu.Unlock()
})
}
// headlessTaskWriteTestProvider scripts a parent turn that spawns a `task`
// sub-agent, which itself calls write_file before answering — reproducing the
// exact call shape TaskTool.runSubSession drives so the boot-level gate wiring
// is exercised end to end, not just the gate object in isolation.
type headlessTaskWriteTestProvider struct {
mu sync.Mutex
calls int
}
func (p *headlessTaskWriteTestProvider) Name() string { return "boot-headless-write-test" }
func (p *headlessTaskWriteTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
p.mu.Lock()
call := p.calls
p.calls++
p.mu.Unlock()
var chunks []provider.Chunk
switch call {
case 0:
chunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "task-1", Name: "task", Arguments: `{"prompt":"write a file"}`}}}
case 1:
chunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "write-1", Name: "write_file", Arguments: `{"path":"sub.txt","content":"hi"}`}}}
case 2:
chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "subagent answer"}, {Type: provider.ChunkDone}}
default:
chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "parent done"}, {Type: provider.ChunkDone}}
}
ch := make(chan provider.Chunk, len(chunks))
for _, chunk := range chunks {
ch <- chunk
}
close(ch)
return ch, nil
}
func TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {
var gotReq map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Fatalf("decode request: %v", err)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n"))
}))
defer srv.Close()
p, err := newProviderForTest(&config.ProviderEntry{
Name: "custom",
Kind: "openai",
BaseURL: srv.URL,
Model: "m",
SupportedEfforts: []string{"low", "medium", "high"},
DefaultEffort: "MEDIUM",
})
if err != nil {
t.Fatalf("NewProvider: %v", err)
}
ch, err := p.Stream(context.Background(), provider.Request{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
})
if err != nil {
t.Fatalf("Stream: %v", err)
}
for chunk := range ch {
if chunk.Type == provider.ChunkError {
t.Fatalf("stream error: %v", chunk.Err)
}
}
if got := gotReq["reasoning_effort"]; got != "medium" {
t.Fatalf("reasoning_effort = %#v, want medium from default_effort", got)
}
}
func TestNewProviderPreservesExplicitlySupportedKimiK3Efforts(t *testing.T) {
var gotReq map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Fatalf("decode request: %v", err)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n"))
}))
defer srv.Close()
p, err := newProviderForTest(&config.ProviderEntry{
Name: "opencode-go",
Kind: "openai",
BaseURL: srv.URL,
Model: "kimi-k3",
ReasoningProtocol: config.ReasoningProtocolOpenAI,
SupportedEfforts: []string{"high", "max"},
DefaultEffort: "max",
})
if err != nil {
t.Fatalf("NewProvider: %v", err)
}
ch, err := p.Stream(context.Background(), provider.Request{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
})
if err != nil {
t.Fatalf("Stream: %v", err)
}
for chunk := range ch {
if chunk.Type == provider.ChunkError {
t.Fatalf("stream error: %v", chunk.Err)
}
}
if got := gotReq["reasoning_effort"]; got != "max" {
t.Fatalf("reasoning_effort = %#v, want explicitly supported max", got)
}
}
func TestNewProviderAppliesOfficialKimiK3RequestContract(t *testing.T) {
var gotReq map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Fatalf("decode request: %v", err)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n"))
}))
defer srv.Close()
p, err := newProviderForTest(&config.ProviderEntry{
Name: "kimi-cn",
Kind: "openai",
BaseURL: "https://api.moonshot.cn/v1",
ChatURL: srv.URL,
Model: "kimi-k3",
ReasoningProtocol: config.ReasoningProtocolOpenAI,
SupportedEfforts: []string{"low", "high", "max"},
DefaultEffort: "max",
})
if err != nil {
t.Fatalf("NewProvider: %v", err)
}
ch, err := p.Stream(context.Background(), provider.Request{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
Temperature: provider.TemperaturePtr(0),
MaxTokens: 2000,
})
if err != nil {
t.Fatalf("Stream: %v", err)
}
for chunk := range ch {
if chunk.Type == provider.ChunkError {
t.Fatalf("stream error: %v", chunk.Err)
}
}
if gotReq["reasoning_effort"] != "max" || gotReq["max_completion_tokens"] != float64(2000) {
t.Fatalf("official Kimi K3 request = %+v, want max effort and max_completion_tokens", gotReq)
}
for _, field := range []string{"temperature", "max_tokens"} {
if _, ok := gotReq[field]; ok {
t.Fatalf("official Kimi K3 request must omit %q: %+v", field, gotReq)
}
}
}
func TestNewProviderPropagatesConfiguredMaxOutputTokens(t *testing.T) {
var gotReq map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Fatalf("decode request: %v", err)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n"))
}))
defer srv.Close()
p, err := newProviderForTest(&config.ProviderEntry{
Name: "openai", Kind: "openai", BaseURL: "https://api.openai.com/v1",
ChatURL: "https://legacy.invalid/chat/completions/", RequestURL: srv.URL, Model: "o3", MaxOutputTokens: 4096,
})
if err != nil {
t.Fatalf("NewProvider: %v", err)
}
ch, err := p.Stream(context.Background(), provider.Request{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
})
if err != nil {
t.Fatalf("Stream: %v", err)
}
for chunk := range ch {
if chunk.Type == provider.ChunkError {
t.Fatalf("stream error: %v", chunk.Err)
}
}
if gotReq["max_completion_tokens"] != float64(4096) {
t.Fatalf("max_completion_tokens = %#v, want 4096: %+v", gotReq["max_completion_tokens"], gotReq)
}
if _, exists := gotReq["max_tokens"]; exists {
t.Fatalf("official OpenAI request must omit max_tokens: %+v", gotReq)
}
}
func TestNewProviderAppliesModelReasoningProtocol(t *testing.T) {
var gotReq map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Fatalf("decode request: %v", err)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n"))
}))
defer srv.Close()
p, err := newProviderForTest(&config.ProviderEntry{
Name: "deepseek-proxy",
Kind: "openai",
BaseURL: srv.URL,
Model: "deepseek-v4-flash",
})
if err != nil {
t.Fatalf("NewProvider: %v", err)
}
ch, err := p.Stream(context.Background(), provider.Request{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
})
if err != nil {
t.Fatalf("Stream: %v", err)
}
for chunk := range ch {
if chunk.Type == provider.ChunkError {
t.Fatalf("stream error: %v", chunk.Err)
}
}
if got := gotReq["reasoning_effort"]; got != "high" {
t.Fatalf("reasoning_effort = %#v, want high from DeepSeek model capability", got)
}
thinking, ok := gotReq["thinking"].(map[string]any)
if !ok || thinking["type"] != "enabled" {
t.Fatalf("thinking = %#v, want enabled", gotReq["thinking"])
}
}
func TestNewProviderBuildsDeepSeekAnthropicPreset(t *testing.T) {
preset, ok := config.CuratedProviderPreset("deepseek-anthropic")
if !ok || len(preset.Entries) != 1 {
t.Fatalf("DeepSeek Anthropic preset = %+v", preset)
}
var cfg config.Config
if err := cfg.UpsertProvider(preset.Entries[0]); err != nil {
t.Fatalf("UpsertProvider: %v", err)
}
entry, ok := cfg.ResolveModel("deepseek-anthropic/deepseek-flash")
if !ok {
t.Fatal("ResolveModel failed")
}
p, err := newProviderForTest(entry)
if err != nil {
t.Fatalf("NewProvider: %v", err)
}
if p.Name() != "deepseek-anthropic" || !provider.RequiresToolCallReasoning(p) || provider.RequiresReasoningRoundTrip(p) {
t.Fatalf("assembled DeepSeek Anthropic provider = %T/%q policies=%v/%v", p, p.Name(), provider.RequiresToolCallReasoning(p), provider.RequiresReasoningRoundTrip(p))
}
}
func TestNewProviderRejectsExplicitOfficialDeepSeekVisionModel(t *testing.T) {
var gotReq map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Fatalf("decode request: %v", err)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n"))
}))
defer srv.Close()
p, err := newProviderForTest(&config.ProviderEntry{
Name: "deepseek",
Kind: "openai",
BaseURL: "https://api.deepseek.com",
ChatURL: srv.URL,
Model: "deepseek-v5-vision",
VisionModels: []string{"deepseek-v5-vision"},
})
if err != nil {
t.Fatalf("NewProvider: %v", err)
}
ch, err := p.Stream(context.Background(), provider.Request{
Messages: []provider.Message{{
Role: provider.RoleUser, Content: "describe",
Images: []string{"data:image/png;base64,AAAA"},
}},
})
if err != nil {
t.Fatalf("Stream: %v", err)
}
for chunk := range ch {
if chunk.Type == provider.ChunkError {
t.Fatalf("stream error: %v", chunk.Err)
}
}
messages, ok := gotReq["messages"].([]any)
if !ok || len(messages) != 1 {
t.Fatalf("messages = %#v, want one message", gotReq["messages"])
}
message, ok := messages[0].(map[string]any)
if !ok {
t.Fatalf("message = %#v, want object", messages[0])
}
if got, ok := message["content"].(string); !ok || got != "describe" {
t.Fatalf("content = %#v, want plain text despite stale explicit vision metadata", message["content"])
}
encoded, err := json.Marshal(gotReq)
if err != nil {
t.Fatalf("marshal captured request: %v", err)
}
if bytes.Contains(encoded, []byte("image_url")) || bytes.Contains(encoded, []byte("base64,AAAA")) {
t.Fatalf("official DeepSeek request leaked image payload: %s", encoded)
}
}
func TestBuildHonorsSessionDirOverride(t *testing.T) {
dir := robustTempDir(t)
home := robustTempDir(t)
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("AppData", filepath.Join(home, "AppData"))
t.Chdir(dir)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`)
approveWorkspace(t, dir)
sessionDir := filepath.Join(robustTempDir(t), "desktop-workspace-sessions")
ctrl, err := Build(context.Background(), Options{SessionDir: sessionDir})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
if got := ctrl.SessionDir(); got != sessionDir {
t.Fatalf("SessionDir() = %q, want override %q", got, sessionDir)
}
}
// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill
// is discovered at boot, surfaced via Controller.Skills(), and its name folds
// into the cache-stable system prompt's "# Skills" index alongside a built-in.
func TestBuildDiscoversSkills(t *testing.T) {
dir := robustTempDir(t)
home := robustTempDir(t)
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Chdir(dir)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`)
approveWorkspace(t, dir)
writeFile(t, dir, ".reasonix/skills/projskill.md", "---\ndescription: a project skill\n---\nplaybook")
ctrl, err := Build(context.Background(), Options{})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
var hasProj, hasBuiltin bool
for _, s := range ctrl.Skills() {
switch s.Name {
case "projskill":
hasProj = true
case "explore":
hasBuiltin = true
}
}
if !hasProj || !hasBuiltin {
t.Fatalf("Skills() should include the project skill and a built-in; got %v", ctrl.Skills())
}
// The catalog is per-project, so it rides the turn, not the prefix every
// project on the machine shares.
if sys := systemMessage(ctrl.History()); strings.Contains(sys, "# Skills") {
t.Fatalf("the skills index reached the cached prefix:\n%s", sys)
}
turn := ctrl.Compose("hello")
if !strings.Contains(turn, "<available-skills>") {
t.Fatalf("skills listing missing from the turn:\n%s", turn)
}
if !strings.Contains(turn, "projskill") || !strings.Contains(turn, "explore") {
t.Fatalf("skill names missing from the listing:\n%s", turn)
}
}
func TestBuildDiscoversSkillsDespiteSafeModeEnv(t *testing.T) {
// v1.20+: skill discovery is not gated by REASONIX_SAFE_MODE.
dir := robustTempDir(t)
home := robustTempDir(t)
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("REASONIX_SAFE_MODE", "1")
t.Chdir(dir)
writeFile(t, dir, ".reasonix/skills/project-skill.md", "---\ndescription: project skill\n---\nplaybook")
writeFile(t, home, ".reasonix/skills/global-skill.md", "---\ndescription: global skill\n---\nplaybook")
ctrl, err := Build(context.Background(), Options{SessionDir: filepath.Join(robustTempDir(t), "sessions")})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
if skills := ctrl.AllSkills(); len(skills) == 0 {
t.Fatal("skills must still be discovered when REASONIX_SAFE_MODE is set")
}
}
func TestBuildKeepsPluginSkillModelNameBareAndSlashNameQualified(t *testing.T) {
dir := robustTempDir(t)
home := robustTempDir(t)
reasonixHome := filepath.Join(home, ".reasonix")
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("REASONIX_HOME", reasonixHome)
t.Chdir(dir)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`)
approveWorkspace(t, dir)
pluginRoot := filepath.Join(reasonixHome, "plugins", "superpowers")
writeFile(t, pluginRoot, pluginpkg.CodexManifest, `{"name":"superpowers","skills":"skills"}`)
writeFile(t, pluginRoot, "skills/plan/SKILL.md", "---\ndescription: Plugin plan\n---\nPlugin body")
if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
Name: "superpowers", Root: "plugins/superpowers", ManifestKind: "codex", Enabled: true,
}); err != nil {
t.Fatal(err)
}
ctrl, err := Build(context.Background(), Options{})
if err != nil {
t.Fatal(err)
}
defer ctrl.Close()
var modelPlan bool
for _, sk := range ctrl.Skills() {
if sk.Name == "plan" {
modelPlan = true
}
}
if !modelPlan {
t.Fatalf("model skill plan missing: %+v", ctrl.Skills())
}
var qualified bool
for _, sk := range ctrl.SlashSkills() {
if sk.SlashName() == "superpowers:plan" {
qualified = true
}
}
if !qualified {
t.Fatalf("qualified slash skill missing: %+v", ctrl.SlashSkills())
}
if sent, ok := ctrl.RunSkill("/superpowers:plan now"); !ok || !strings.Contains(sent, "Plugin body") {
t.Fatalf("qualified RunSkill = %q, %v", sent, ok)
}
turn := ctrl.Compose("hello")
if !strings.Contains(turn, "- plan") || strings.Contains(turn, "superpowers:plan") {
t.Fatalf("model skill listing changed identifiers:\n%s", turn)
}
var slashDescription string
for _, entry := range ctrl.AllToolContractEntries() {
if entry.Name == "slash_command" {
slashDescription = entry.Description
}
}
if slashDescription == "" {
// slash_command may be host-only / not registered when skills use
// use_capability; still require the qualified slash skill surface.
if !qualified {
t.Fatal("slash_command tool missing and qualified slash skill missing")
}
return
}
// The names left the schema; the qualified one is asserted on the turn above.
if strings.Contains(slashDescription, "plan") {
t.Fatalf("a configured name reached the slash_command schema: %q", slashDescription)
}
}
func TestBuildTokenFullMatchesDefaultRequestPrefix(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "boot-token-profile-test"
model = "x"
`)
approveWorkspace(t, dir)
writeFile(t, dir, ".reasonix/skills/projskill.md", "---\ndescription: a project skill\n---\nplaybook")
defaultReq := firstTokenProfileRequest(t, "")
fullReq := firstTokenProfileRequest(t, TokenModeFull)
if got, want := systemMessage(defaultReq.Messages), systemMessage(fullReq.Messages); got != want {
t.Fatalf("explicit full mode changed the system prompt\n--- default ---\n%s\n--- full ---\n%s", got, want)
}
if strings.Contains(systemMessage(fullReq.Messages), tokenEconomyPrompt) {
t.Fatalf("full mode system prompt should not include token economy prompt:\n%s", systemMessage(fullReq.Messages))
}
if !strings.Contains(userMessages(fullReq), "<available-skills>") || !strings.Contains(userMessages(fullReq), "projskill") {
t.Fatalf("full mode should preserve the skills listing on the turn:\n%s", userMessages(fullReq))
}
if got, want := toolSchemaNames(fullReq.Tools), toolSchemaNames(defaultReq.Tools); !reflect.DeepEqual(got, want) {
t.Fatalf("explicit full mode changed tool schema order\nfull=%v\ndefault=%v", got, want)
}
if !reflect.DeepEqual(fullReq.Tools, defaultReq.Tools) {
t.Fatalf("explicit full mode changed provider-visible tool schemas; names=%v", toolSchemaNames(fullReq.Tools))
}
if requestHasTool(fullReq, "connect_tool_source") {
t.Fatalf("full mode should not expose economy connector; tools=%v", toolSchemaNames(fullReq.Tools))
}
}
func TestBuildTokenBalancedAliasMatchesDefaultRequestPrefix(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "boot-token-profile-test"
model = "x"
`)
approveWorkspace(t, dir)
defaultReq := firstTokenProfileRequest(t, "")
balancedReq := firstTokenProfileRequest(t, "balanced")
if !reflect.DeepEqual(balancedReq.Messages, defaultReq.Messages) {
t.Fatal("balanced alias changed provider-visible messages")
}
if !reflect.DeepEqual(balancedReq.Tools, defaultReq.Tools) {
t.Fatal("balanced alias changed provider-visible tool schemas")
}
}
func TestNormalizeTokenModeSupportsRuntimeProfilesAndLegacyAliases(t *testing.T) {
// NormalizeTokenMode remains the dual-write legacy mapping.
for input, want := range map[string]string{
"": TokenModeFull,
"full": TokenModeFull,
"balanced": TokenModeFull,
"economy": TokenModeFull,
"eco": TokenModeFull,
"light": TokenModeFull,
"delivery": TokenModeDelivery,
"quality": TokenModeDelivery,
"unexpected": TokenModeFull,
} {
if got := NormalizeTokenMode(input); got != want {
t.Errorf("NormalizeTokenMode(%q) = %q, want %q", input, got, want)
}
}
for input, want := range map[string]string{
"": AgentPresetBalanced,
"full": AgentPresetBalanced,
"balanced": AgentPresetBalanced,
"economy": AgentPresetBalanced,
"light": AgentPresetBalanced,
"delivery": AgentPresetDelivery,
} {
if got := NormalizeAgentPreset(input); got != want {
t.Errorf("NormalizeAgentPreset(%q) = %q, want %q", input, got, want)
}
}
}
func TestBuildTokenDeliverySharesUnifiedSurfaceAndExecutionPolicy(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "boot-token-profile-test"
model = "x"
`)
approveWorkspace(t, dir)
fullReq := firstTokenProfileRequest(t, TokenModeFull)
deliveryReq := firstTokenProfileRequest(t, TokenModeDelivery)
fullSystem := systemMessage(fullReq.Messages)
deliverySystem := systemMessage(deliveryReq.Messages)
if fullSystem != deliverySystem {
t.Fatal("delivery must share the balanced system prompt (no mode-specific injection)")
}
if strings.Contains(deliverySystem, tokenDeliveryPrompt) || strings.Contains(deliverySystem, tokenEconomyPrompt) {
t.Fatalf("role settings must not inject mode-specific system prompts:\n%s", deliverySystem)
}
if !requestHasTool(deliveryReq, "use_capability") || !requestHasTool(fullReq, "use_capability") {
t.Fatal("every role setting must expose use_capability")
}
if !reflect.DeepEqual(toolSchemaNames(fullReq.Tools), toolSchemaNames(deliveryReq.Tools)) {
t.Fatalf("delivery tools diverged from balanced\nfull=%v\ndelivery=%v", toolSchemaNames(fullReq.Tools), toolSchemaNames(deliveryReq.Tools))
}
if requestHasTool(deliveryReq, "connect_tool_source") {
t.Fatal("delivery profile should not expose the economy connector")
}
// Per-turn execution-policy block freezes the role setting without
// rewriting the system prompt.
if !requestMessageContains(deliveryReq.Messages, provider.RoleUser, `<execution-policy preset="delivery"`) {
t.Fatal("delivery turn must include execution-policy block")
}
if !requestMessageContains(fullReq.Messages, provider.RoleUser, `<execution-policy preset="balanced"`) {
t.Fatal("balanced turn must include execution-policy block")
}
if requestMessageContains(deliveryReq.Messages, provider.RoleUser, "<delivery-runtime>") {
t.Fatal("delivery-runtime marker is retired; use execution-policy")
}
}
func TestBuildBalancedDualModelAddsStableProxyToExecutor(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
registerBootTokenProfileTestProvider()
prov := testutil.NewMock("balanced-dual-proxy")
setBootTokenProfileTestProvider(t, prov)
writeConfig := func(planner bool) {
plannerLine := ""
plannerProvider := ""
if planner {
plannerLine = `planner_model = "planner"`
plannerProvider = `
[[providers]]
name = "planner"
kind = "boot-token-profile-test"
model = "planner-model"`
}
writeFile(t, dir, "reasonix.toml", fmt.Sprintf(`
default_model = "executor"
[agent]
system_prompt = "BASE"
%s
[[providers]]
name = "executor"
kind = "boot-token-profile-test"
model = "executor-model"%s
`, plannerLine, plannerProvider))
approveWorkspace(t, dir)
}
writeConfig(false)
single, err := Build(context.Background(), Options{Sink: event.Discard})
if err != nil {
t.Fatal(err)
}
singleEntries := single.ToolContractEntries()
single.Close()
// Every role setting exposes use_capability on the unified surface.
if !slices.Contains(contractEntryNames(singleEntries), "use_capability") {
t.Fatal("single-model Balanced must expose use_capability")
}
writeConfig(true)
dual, err := Build(context.Background(), Options{Sink: event.Discard})
if err != nil {
t.Fatal(err)
}
defer dual.Close()
dualEntries := dual.ToolContractEntries()
dualNames := contractEntryNames(dualEntries)
if !slices.Contains(dualNames, "use_capability") {
t.Fatalf("dual-model Balanced executor missing stable capability proxy: %v", dualNames)
}
// Provider-visible surface stays identical with or without dual-model.
if !reflect.DeepEqual(contractEntryNames(dualEntries), contractEntryNames(singleEntries)) {
t.Fatalf("dual-model provider surface diverged from single-model\nsingle=%v\ndual=%v", contractEntryNames(singleEntries), dualNames)
}
}
func TestBuildInjectsEnvironmentBlockByDefaultAndEconomy(t *testing.T) {
for _, tokenMode := range []string{"", TokenModeEconomy} {
t.Run(firstNonEmpty(tokenMode, "default"), func(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "boot-token-profile-test"
model = "x"
`)
approveWorkspace(t, dir)
req, _ := captureTokenProfileSurface(t, tokenMode)
sys := systemMessage(req.Messages)
if !strings.Contains(sys, "## Environment") {
t.Fatalf("environment block missing in tokenMode=%q:\n%s", tokenMode, sys)
}
if !strings.Contains(sys, "- OS:") || !strings.Contains(sys, "Detected tools:") {
t.Fatalf("environment block missing stable fields in tokenMode=%q:\n%s", tokenMode, sys)
}
})
}
}
// The environment block names the interpreter the bash tool runs, which is not
// the user's login shell; an unattributed "Shell:" line reads as the latter.
func TestBuildEnvironmentBlockAttributesShellToBashTool(t *testing.T) {
isolateConfigHome(t)
t.Setenv("SHELL", "/usr/bin/fish")
dir := robustTempDir(t)
t.Chdir(dir)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "boot-token-profile-test"
model = "x"
`)
approveWorkspace(t, dir)
req, _ := captureTokenProfileSurface(t, "")
sys := systemMessage(req.Messages)
if !strings.Contains(sys, "\n- Shell used by the bash tool: ") {
t.Fatalf("environment block does not attribute the shell to the bash tool:\n%s", sys)
}
if strings.Contains(sys, "\n- Shell: ") || strings.Contains(sys, "fish") {
t.Fatalf("environment block states a shell the bash tool does not run:\n%s", sys)
}
}
func TestBuildSkipsEnvironmentBlockWhenDisabled(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[environment]
enabled = false
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "boot-token-profile-test"
model = "x"
`)
approveWorkspace(t, dir)
req, _ := captureTokenProfileSurface(t, "")
if sys := systemMessage(req.Messages); strings.Contains(sys, "## Environment") {
t.Fatalf("environment block should be disabled:\n%s", sys)
}
}
func TestBuildDoesNotExecuteWorkspaceEnvironmentOverride(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
toolPath := filepath.Join(dir, "go")
ranPath := filepath.Join(dir, "ran")
body := "#!/bin/sh\ntouch " + shellQuoteForTest(ranPath) + "\nprintf 'bad\\n'\n"
if runtime.GOOS == "windows" {
toolPath += ".bat"
body = "@echo bad>\"" + ranPath + "\"\r\n@echo bad\r\n"
}
if err := os.WriteFile(toolPath, []byte(body), 0o755); err != nil {
t.Fatalf("write fake tool: %v", err)
}
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[environment.tools]
go = "./go"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "boot-token-profile-test"
model = "x"
`)
approveWorkspace(t, dir)
req, _ := captureTokenProfileSurface(t, "")
if _, err := os.Stat(ranPath); !os.IsNotExist(err) {
t.Fatalf("workspace environment override was executed; stat err=%v", err)
}
if sys := systemMessage(req.Messages); !strings.Contains(sys, "- go: not trusted") {
t.Fatalf("environment block should mark workspace override untrusted:\n%s", sys)
}
}
func TestToolContractDocCoversDefaultBootSurfaces(t *testing.T) {
pkgDir, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "boot-token-profile-test"
model = "x"
`)
approveWorkspace(t, dir)
fullReq, _ := captureTokenProfileSurface(t, TokenModeFull)
economyReq, _ := captureTokenProfileSurface(t, TokenModeEconomy)
doc, err := os.ReadFile(filepath.Join(pkgDir, "..", "..", "..", "docs", "TOOL_CONTRACT.md"))
if err != nil {
t.Fatalf("read tool contract doc: %v", err)
}
text := string(doc)
for _, heading := range []string{"## Default Full Boot Surface", "## Unified Boot Surface (all execution settings)"} {
if !strings.Contains(text, heading) {
t.Fatalf("tool contract doc missing %q", heading)
}
}
var missing []string
for _, name := range append(toolSchemaNames(fullReq.Tools), toolSchemaNames(economyReq.Tools)...) {
if !strings.Contains(text, "`"+name+"`") {
missing = append(missing, name)
}
}
if len(missing) > 0 {
t.Fatalf("tool contract doc missing boot-surface tools: %v", missing)
}
}
func contractEntryNames(entries []tool.ContractEntry) []string {
names := make([]string, 0, len(entries))
for _, e := range entries {
names = append(names, e.Name)
}
return names
}
func TestBuildTokenEconomyStartsWithLeanToolSurface(t *testing.T) {
// Light (legacy economy) shares the unified provider-visible surface with
// Balanced/Delivery: core tools + host-control + use_capability.
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
registerBootTokenProfileTestProvider()
prov := testutil.NewMock("token-economy", testutil.Turn{Text: "done"})
setBootTokenProfileTestProvider(t, prov)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "boot-token-profile-test"
model = "x"
[[plugins]]
name = "mockmcp"
command = "reasonix-missing-mockmcp"
`)
approveWorkspace(t, dir)
writeFile(t, dir, ".reasonix/skills/projskill.md", "---\ndescription: a project skill\n---\nplaybook")
ctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
if err := ctrl.Run(context.Background(), "use the lean surface"); err != nil {
t.Fatalf("Run: %v", err)
}
reqs := prov.Requests()
if len(reqs) != 1 {
t.Fatalf("requests = %d, want 1", len(reqs))
}
req := reqs[0]
assertSegmentedSurface(t, "light first request", toolSchemaNames(req.Tools))
for _, want := range []string{"compress", "use_capability", "read_file", "edit_file", "write_file", "bash", "ask"} {
if !requestHasTool(req, want) {
t.Fatalf("light first request missing tool %q; tools=%v", want, toolSchemaNames(req.Tools))
}
}
for _, forbidden := range []string{
"connect_tool_source", "web_fetch", "task", "read_only_task", "read_only_skill", "run_skill", "read_skill", "install_skill", "install_source",
"explore", "research", "review", "security_review",
"lsp_definition", "lsp_references", "lsp_hover", "lsp_diagnostics",
"code_index", "glob", "grep", "ls", "move_file", "multi_edit",
"docs", "history", "list_sessions", "read_session", "memory", "remember", "forget", "slash_command",
} {
if requestHasTool(req, forbidden) {
t.Fatalf("light first request should hide %q; tools=%v", forbidden, toolSchemaNames(req.Tools))
}
}
if requestHasToolPrefix(req, "mcp__mockmcp") {
t.Fatalf("light first request should not expose MCP placeholders; tools=%v", toolSchemaNames(req.Tools))
}
sys := systemMessage(req.Messages)
if strings.Contains(sys, tokenEconomyPrompt) || strings.Contains(sys, tokenDeliveryPrompt) {
t.Fatalf("role settings must not inject mode-specific system prompts:\n%s", sys)
}
}
func TestUseCapabilityDispatchesOptionalToolsWithoutSchemaGrowth(t *testing.T) {
// Replaces the retired connect_tool_source on-demand source matrix: optional
// tools stay off the provider-visible surface and dispatch through use_capability.
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
writeFile(t, dir, "a.go", "package a\n// needle_token_ucap\n")
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "boot-token-profile-test"
model = "x"
`)
approveWorkspace(t, dir)
registerBootTokenProfileTestProvider()
cases := []struct {
name string
id string
args map[string]any
want string
}{
{
name: "grep",
id: "tool:grep",
args: map[string]any{"pattern": "needle_token_ucap", "path": "."},
want: "needle_token_ucap",
},
{
name: "ls",
id: "tool:ls",
args: map[string]any{"path": "."},
want: "a.go",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
raw, _ := json.Marshal(map[string]any{
"action": "call",
"capability_id": tc.id,
"arguments": tc.args,
})
prov := testutil.NewMock("ucap-"+tc.name,
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c1", Name: "use_capability", Arguments: string(raw)}}},
testutil.Turn{Text: "done"},
)
setBootTokenProfileTestProvider(t, prov)
ctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
if err := ctrl.Run(context.Background(), "use optional tool"); err != nil {
t.Fatalf("Run: %v", err)
}
for _, req := range prov.Requests() {
if requestHasTool(req, "connect_tool_source") {
t.Fatalf("connect_tool_source must not appear: %v", toolSchemaNames(req.Tools))
}
if requestHasTool(req, tc.name) {
t.Fatalf("%s must stay off provider surface: %v", tc.name, toolSchemaNames(req.Tools))
}
if !requestHasTool(req, "use_capability") {
t.Fatalf("use_capability missing: %v", toolSchemaNames(req.Tools))
}
}
var toolOut strings.Builder
for _, msg := range ctrl.History() {
if msg.Role == provider.RoleTool {
toolOut.WriteString(msg.Content)
}
}
if !strings.Contains(toolOut.String(), tc.want) {
t.Fatalf("use_capability(%s) output missing %q:\n%s", tc.id, tc.want, toolOut.String())
}
})
}
}
func TestUseCapabilitySurfaceStableAcrossRoleSettings(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "boot-token-profile-test"
model = "x"
`)
approveWorkspace(t, dir)
registerBootTokenProfileTestProvider()
var base []string
for _, mode := range []string{TokenModeEconomy, TokenModeFull, TokenModeDelivery, "light", "balanced"} {
prov := testutil.NewMock("stable-"+mode, testutil.Turn{Text: "[]"}, testutil.Turn{Text: "done"})
setBootTokenProfileTestProvider(t, prov)
ctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: mode, AgentPreset: mode})
if err != nil {
t.Fatalf("Build(%q): %v", mode, err)
}
if err := ctrl.Run(context.Background(), "hi"); err != nil {
ctrl.Close()
t.Fatalf("Run(%q): %v", mode, err)
}
names := toolSchemaNames(agentRequests(prov.Requests())[0].Tools)
if requestHasTool(agentRequests(prov.Requests())[0], "connect_tool_source") {
ctrl.Close()
t.Fatalf("%q still exposes connect_tool_source", mode)
}
if !requestHasTool(agentRequests(prov.Requests())[0], "use_capability") {
ctrl.Close()
t.Fatalf("%q missing use_capability: %v", mode, names)
}
// Hidden tools remain dispatchable through the host registry.
reg := map[string]bool{}
for _, e := range ctrl.AllToolContractEntries() {
reg[e.Name] = true
}
for _, hidden := range []string{"grep", "glob", "ls", "web_fetch"} {
if !reg[hidden] {
ctrl.Close()
t.Fatalf("%q registry missing %q for use_capability dispatch", mode, hidden)
}
}
if base == nil {
base = names
} else if !reflect.DeepEqual(base, names) {
ctrl.Close()
t.Fatalf("provider surface diverged for %q\nbase=%v\ngot=%v", mode, base, names)
}
ctrl.Close()
}
}
func TestUseCapabilityWorksInPlanMode(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
writeFile(t, dir, "a.go", "package a\n// plan_needle\n")
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "boot-token-profile-test"
model = "x"
`)
approveWorkspace(t, dir)
registerBootTokenProfileTestProvider()
raw, _ := json.Marshal(map[string]any{
"action": "call",
"capability_id": "tool:grep",
"arguments": map[string]any{"pattern": "plan_needle", "path": "."},
})
prov := testutil.NewMock("ucap-plan",
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "g1", Name: "use_capability", Arguments: string(raw)}}},
testutil.Turn{Text: "done"},
)
setBootTokenProfileTestProvider(t, prov)
ctrl, err := Build(context.Background(), Options{Sink: event.Discard})
if err != nil {
t.Fatal(err)
}
defer ctrl.Close()
ctrl.SetPlanMode(true)
if err := ctrl.Run(context.Background(), "search in plan"); err != nil {
t.Fatalf("Run: %v", err)
}
var toolOut strings.Builder
for _, msg := range ctrl.History() {
if msg.Role == provider.RoleTool {
toolOut.WriteString(msg.Content)
}
}
if strings.Contains(toolOut.String(), "blocked:") && strings.Contains(toolOut.String(), "use_capability") {
t.Fatalf("use_capability should not be blocked in plan mode:\n%s", toolOut.String())
}
if !strings.Contains(toolOut.String(), "plan_needle") {
t.Fatalf("plan-mode use_capability/grep missing result:\n%s", toolOut.String())
}
}
func TestBuildLegacyPlanModeReadOnlyCommandsDoesNotEmitGateWarning(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
registerBootTokenProfileTestProvider()
prov := testutil.NewMock("plan-mode-read-only-commands", testutil.Turn{Text: "done"})
setBootTokenProfileTestProvider(t, prov)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
plan_mode_read_only_commands = ["bash", "gh issue view"]
[[providers]]
name = "test-model"
kind = "boot-token-profile-test"
model = "x"
`)
approveWorkspace(t, dir)
var notices []event.Event
sink := event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
notices = append(notices, e)
}
})
ctrl, err := Build(context.Background(), Options{Sink: sink})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
for _, notice := range notices {
if strings.Contains(notice.Text, "plan-mode command") || strings.Contains(notice.Detail, "plan_mode_read_only_commands") {
t.Fatalf("legacy Plan command setting emitted obsolete gate warning: %+v", notice)
}
}
}
func TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {
reg := tool.NewRegistry()
var stderr bytes.Buffer
addBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{}, nil, nil, builtin.SessionDataGuard{}, builtin.ManagedConfigPaths{}, nil, nil, nil, nil)
for _, name := range []string{
"todo_write",
"complete_step",
"bash_output",
"kill_shell",
"wait",
"move_file",
"notebook_edit",
} {
if _, ok := reg.Get(name); !ok {
t.Fatalf("workspace builtins missing %q; got %v", name, reg.Names())
}
}
}
// The decision backend is an ordinary source the decision role names, so the
// tool binds from the same place every other model comes from.
func TestAddBuiltinsBindsTheDecisionRolesBackend(t *testing.T) {
reg := tool.NewRegistry()
t.Setenv("DECIDER_KEY", "secret")
cfg := &config.Config{Providers: []config.ProviderEntry{{
Name: "decider", Kind: "typesafe", BaseURL: "https://decide.example",
Models: []string{"system-one"}, APIKeyEnv: "DECIDER_KEY",
}}}
cfg.Agent.DecisionModel = "decider/system-one"
// Load does this for a real config; a hand-built one has to ask.
cfg.Providers[0].ResolveAPIKeyForRoot(".")
addSystemOne(reg, []string{"system_one"}, cfg, http.DefaultClient)
registered, ok := reg.Get("system_one")
if !ok {
t.Fatal("the decision role's backend was not registered")
}
visible, ok := registered.(tool.ContextualTool)
if !ok || !visible.ProviderVisible(context.Background()) {
t.Fatal("configured system_one is not provider-visible")
}
}
// A chat model in the decision role is a model that cannot answer a question
// set. Binding it would turn a misconfiguration into a failing turn.
func TestAddBuiltinsRefusesAChatModelAsTheDecisionBackend(t *testing.T) {
reg := tool.NewRegistry()
t.Setenv("CHAT_KEY", "secret")
cfg := &config.Config{Providers: []config.ProviderEntry{{
Name: "chatty", Kind: "openai", BaseURL: "https://chat.example",
Models: []string{"gpt-x"}, APIKeyEnv: "CHAT_KEY",
}}}
cfg.Agent.DecisionModel = "chatty/gpt-x"
cfg.Providers[0].ResolveAPIKeyForRoot(".")
addSystemOne(reg, []string{"system_one"}, cfg, http.DefaultClient)
if _, ok := reg.Get("system_one"); ok {
t.Fatal("a chat model was bound as the decision backend")
}
}
func TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {
dir := robustTempDir(t)
home := robustTempDir(t)
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Chdir(dir)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[skills]
disabled_skills = ["projskill", "review"]
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`)
approveWorkspace(t, dir)
writeFile(t, dir, ".reasonix/skills/projskill.md", "---\ndescription: a project skill\n---\nplaybook")
ctrl, err := Build(context.Background(), Options{})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
for _, s := range ctrl.Skills() {
if s.Name == "projskill" || s.Name == "review" {
t.Fatalf("disabled skill %q should not be executable: %v", s.Name, ctrl.Skills())
}
}
var allHasProj bool
for _, s := range ctrl.AllSkills() {
if s.Name == "projskill" {
allHasProj = true
}
}
if !allHasProj {
t.Fatalf("AllSkills should include disabled skills for management: %v", ctrl.AllSkills())
}
sys := systemMessage(ctrl.History())
if strings.Contains(sys, "projskill") || strings.Contains(sys, "- review ") {
t.Fatalf("disabled skill names should be omitted from system prompt:\n%s", sys)
}
}
func TestBuildOmitsExcludedSkillRootsFromPromptAndRuntimeList(t *testing.T) {
dir := robustTempDir(t)
home := robustTempDir(t)
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Chdir(dir)
excluded := filepath.Join(home, ".agents", "skills")
writeFile(t, home, ".reasonix/skills/keep.md", "---\ndescription: keep\n---\nplaybook")
writeFile(t, home, ".agents/skills/noisy.md", "---\ndescription: noisy\n---\nplaybook")
writeFile(t, dir, "reasonix.toml", fmt.Sprintf(`
default_model = "test-model"
[agent]
system_prompt = "BASE"
[skills]
excluded_paths = [%q]
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`, excluded))
approveWorkspace(t, dir)
ctrl, err := Build(context.Background(), Options{})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
for _, s := range ctrl.Skills() {
if s.Name == "noisy" {
t.Fatalf("excluded skill should not be executable: %v", ctrl.Skills())
}
}
sys := systemMessage(ctrl.History())
if strings.Contains(sys, "noisy") {
t.Fatalf("excluded skill name should be omitted from system prompt:\n%s", sys)
}
if !strings.Contains(sys, "keep") {
t.Fatalf("non-excluded skill should remain in system prompt:\n%s", sys)
}
}
// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no
// memory files, the system prompt is exactly the configured base — the cache
// prefix is untouched by the memory feature.
func TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {
dir := robustTempDir(t)
home := robustTempDir(t)
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("AppData", filepath.Join(home, "AppData"))
t.Chdir(dir)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "JUST THE BASE"
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`)
approveWorkspace(t, dir)
ctrl, err := Build(context.Background(), Options{})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
sys := systemMessage(ctrl.History())
// The built-in skills always append a "# Skills" index to the prefix; this
// test is about memory, so strip that and assert the remaining base is exactly
// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A
// user-global REASONIX.md in the real config dir could append; the test
// environment has none, so the base stands alone.)
base := sys
if before, _, ok := strings.Cut(sys, "\n\n# Skills"); ok {
base = before
}
// The language policy, user-decision policy, and current-workspace line are
// always appended at boot; strip them so this assertion is purely about
// whether project/ancestor memory leaked into the base.
base = stripEnvironmentBlock(base)
base = stripCurrentWorkspaceLine(base)
base = stripLanguagePolicy(base)
if base != "JUST THE BASE" {
t.Fatalf("expected untouched base prompt, got:\n%s", sys)
}
}
func TestBuildLanguagePolicyIsAppended(t *testing.T) {
dir := robustTempDir(t)
t.Chdir(dir)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`)
approveWorkspace(t, dir)
ctrl, err := Build(context.Background(), Options{})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
sys := systemMessage(ctrl.History())
if !strings.Contains(sys, config.LanguagePolicy) {
t.Fatalf("language policy missing from system prompt:\n%s", sys)
}
}
func TestBuildAppendsUserDecisionPolicyToCustomSystemPrompt(t *testing.T) {
dir := robustTempDir(t)
t.Chdir(dir)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`)
approveWorkspace(t, dir)
ctrl, err := Build(context.Background(), Options{})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
sys := systemMessage(ctrl.History())
for _, want := range []string{
"User-owned choices",
"call the ask tool",
"Do not ask in prose",
} {
if !strings.Contains(sys, want) {
t.Fatalf("user decision policy missing %q from custom system prompt:\n%s", want, sys)
}
}
}
func systemMessage(msgs []provider.Message) string {
for _, m := range msgs {
if m.Role == provider.RoleSystem {
return m.Content
}
}
return ""
}
func stripLanguagePolicy(s string) string {
s = strings.TrimSpace(s)
// Reverse order: they are appended in corePolicies order, so the last one
// added is the first suffix to come off.
for _, policy := range slices.Backward(corePolicies) {
s = strings.TrimSpace(strings.TrimSuffix(s, policy))
}
return s
}
func stripEnvironmentBlock(s string) string {
if before, _, ok := strings.Cut(s, "\n\n## Environment"); ok {
return before
}
return s
}
func stripCurrentWorkspaceLine(s string) string {
if i := strings.LastIndex(s, "\n\nCurrent workspace: "); i >= 0 {
return s[:i]
}
return s
}
func writeFile(t *testing.T, dir, name, body string) {
t.Helper()
if err := writeFileRaw(dir, name, body); err != nil {
t.Fatal(err)
}
}
func shellQuoteForTest(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
// rememberHome isolates the Reasonix home the project allow record lives in.
func rememberHome(t *testing.T) string {
t.Helper()
home := robustTempDir(t)
t.Setenv("REASONIX_HOME", home)
return home
}
func projectAllowRules(t *testing.T, workspace string) []string {
t.Helper()
grant, err := config.NewProjectGrantStore(config.Roots{}.Home()).Grant(workspace)
if err != nil {
t.Fatal(err)
}
return grant.Allow
}
// A workspace's "always" is the user's decision, so it lands under their home
// and never in a reasonix.toml a checkout ships.
func TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {
rememberHome(t)
cwd := robustTempDir(t)
workspace := robustTempDir(t)
t.Chdir(cwd)
writeFile(t, workspace, "reasonix.toml", "[permissions]\nallow = [\"Bash(workspace*)\"]\n")
approveWorkspace(t, workspace)
const rule = "Bash(go test ./...)"
res := rememberPermissionRule(config.Roots{}, workspace, rule)
if !res.Saved || res.Err != nil {
t.Fatalf("remember result = %+v, want saved", res)
}
if !hasPermissionRule(projectAllowRules(t, workspace), rule) {
t.Fatalf("remembered rule missing from the workspace record: %v", projectAllowRules(t, workspace))
}
if hasPermissionRule(projectAllowRules(t, cwd), rule) {
t.Fatal("remembered rule was filed under the process cwd")
}
if got := config.LoadForEdit(filepath.Join(workspace, "reasonix.toml")); hasPermissionRule(got.Permissions.Allow, rule) {
t.Fatalf("remembered rule was written into the checkout: %v", got.Permissions.Allow)
}
cfg, err := config.LoadForRootReadOnly(workspace)
if err != nil {
t.Fatal(err)
}
if !hasPermissionRule(cfg.Permissions.Allow, rule) || hasPermissionRule(cfg.Permissions.Allow, "Bash(workspace*)") {
t.Fatalf("effective allow = %v, want the remembered rule and not the checkout's", cfg.Permissions.Allow)
}
}
func TestRememberPermissionRulePreservesPermissionPolicyAndComments(t *testing.T) {
home := rememberHome(t)
writeFile(t, home, "config.toml", `
[permissions]
# Keep this rationale with the policy.
mode = "deny"
allow = ["Bash(existing)"] # Keep this allow rationale.
ask = ["Edit(*.env)"]
deny = ["Bash(rm:*)"]
future_policy = "keep"
[desktop]
legacy_preference = "keep"
`)
const rule = "Edit(src/app.go)"
result := rememberPermissionRule(config.Roots{}, "", rule)
if result.Err != nil || !result.Saved {
t.Fatalf("remember result = %+v, want saved without error", result)
}
path := filepath.Join(home, "config.toml")
got := config.LoadForEdit(path)
if got.Permissions.Mode != "deny" {
t.Errorf("permissions.mode = %q, want deny", got.Permissions.Mode)
}
if !reflect.DeepEqual(got.Permissions.Ask, []string{"Edit(*.env)"}) {
t.Errorf("permissions.ask = %v, want existing ask policy", got.Permissions.Ask)
}
if !reflect.DeepEqual(got.Permissions.Deny, []string{"Bash(rm:*)"}) {
t.Errorf("permissions.deny = %v, want existing deny policy", got.Permissions.Deny)
}
if !hasPermissionRule(got.Permissions.Allow, "Bash(existing)") || !hasPermissionRule(got.Permissions.Allow, rule) {
t.Errorf("permissions.allow = %v, want existing and remembered rules", got.Permissions.Allow)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
body := string(raw)
for _, want := range []string{
"# Keep this rationale with the policy.",
"# Keep this allow rationale.",
`future_policy = "keep"`,
} {
if !strings.Contains(body, want) {
t.Errorf("permissions content %q was not preserved:\n%s", want, body)
}
}
if !strings.Contains(body, "[desktop]\nlegacy_preference = \"keep\"") {
t.Errorf("unrelated section was not preserved:\n%s", body)
}
}
func TestRememberPermissionRuleIgnoresTOMLExampleInMultilineSystemPrompt(t *testing.T) {
home := rememberHome(t)
writeFile(t, home, "config.toml", `[agent]
system_prompt = """
Example only:
[permissions]
allow = ["Bash(example)"]
"""
[permissions]
mode = "ask"
allow = ["Bash(existing)"]
deny = ["Bash(rm:*)"]
`)
const rule = "Edit(src/app.go)"
result := rememberPermissionRule(config.Roots{}, "", rule)
if result.Err != nil || !result.Saved {
t.Fatalf("remember result = %+v, want saved without error", result)
}
got, err := config.LoadForEditReadOnlyStrict(filepath.Join(home, "config.toml"))
if err != nil {
t.Fatalf("updated config does not parse: %v", err)
}
if !reflect.DeepEqual(got.Permissions.Allow, []string{"Bash(existing)", rule}) {
t.Fatalf("permissions.allow = %v", got.Permissions.Allow)
}
if !strings.Contains(got.Agent.SystemPrompt, "[permissions]\nallow = [\"Bash(example)\"]") {
t.Fatalf("system prompt example changed: %q", got.Agent.SystemPrompt)
}
}
func TestRememberPermissionRuleRejectsMalformedConfigWithoutWriting(t *testing.T) {
home := rememberHome(t)
path := filepath.Join(home, "config.toml")
original := []byte("[permissions]\nmode = \"deny\"\nallow = [\n")
if err := os.WriteFile(path, original, 0o644); err != nil {
t.Fatal(err)
}
result := rememberPermissionRule(config.Roots{}, "", "Edit(src/app.go)")
if result.Err == nil || result.Saved {
t.Fatalf("remember result = %+v, want parse error without save", result)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, original) {
t.Fatalf("malformed config changed:\ngot:\n%s\nwant:\n%s", got, original)
}
}
func TestRememberPermissionRuleRejectsAnUnreadableRecordWithoutWriting(t *testing.T) {
home := rememberHome(t)
workspace := robustTempDir(t)
path := filepath.Join(home, "project-grants.json")
original := []byte("{")
if err := os.WriteFile(path, original, 0o600); err != nil {
t.Fatal(err)
}
result := rememberPermissionRule(config.Roots{}, workspace, "Edit(src/app.go)")
if !errors.Is(result.Err, config.ErrProjectGrantsUnavailable) || result.Saved {
t.Fatalf("remember result = %+v, want ErrProjectGrantsUnavailable without save", result)
}
if got, _ := os.ReadFile(path); !bytes.Equal(got, original) {
t.Fatalf("unreadable record changed: %s", got)
}
}
func TestRememberPermissionRuleSerializesConcurrentWriters(t *testing.T) {
rememberHome(t)
workspace := robustTempDir(t)
const writers = 32
start := make(chan struct{})
results := make(chan control.RememberResult, writers)
var wg sync.WaitGroup
for i := range writers {
wg.Add(1)
go func(n int) {
defer wg.Done()
<-start
results <- rememberPermissionRule(config.Roots{}, workspace, fmt.Sprintf("Edit(file-%02d)", n))
}(i)
}
close(start)
wg.Wait()
close(results)
for result := range results {
if result.Err != nil || !result.Saved {
t.Errorf("remember result = %+v, want saved without error", result)
}
}
got := projectAllowRules(t, workspace)
for i := range writers {
rule := fmt.Sprintf("Edit(file-%02d)", i)
if !hasPermissionRule(got, rule) {
t.Errorf("remembered rules missing %q: %v", rule, got)
}
}
}
func TestRememberPermissionRuleSerializesCrossProcessWriters(t *testing.T) {
home := rememberHome(t)
workspace := robustTempDir(t)
readyDir := robustTempDir(t)
startPath := filepath.Join(readyDir, "start")
const workers = 4
const rulesPerWorker = 8
commands := make([]*exec.Cmd, 0, workers)
outputs := make([]bytes.Buffer, workers)
for worker := range workers {
cmd := exec.Command(os.Args[0], "-test.run=^TestRememberPermissionRuleProcessHelper$")
cmd.Stdout = &outputs[worker]
cmd.Stderr = &outputs[worker]
cmd.Env = append(os.Environ(),
"REASONIX_PERMISSION_HOME="+home,
"REASONIX_PERMISSION_HELPER=1",
"REASONIX_PERMISSION_WORKSPACE="+workspace,
"REASONIX_PERMISSION_READY_DIR="+readyDir,
"REASONIX_PERMISSION_START="+startPath,
fmt.Sprintf("REASONIX_PERMISSION_WORKER=%d", worker),
fmt.Sprintf("REASONIX_PERMISSION_RULES=%d", rulesPerWorker),
)
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
commands = append(commands, cmd)
}
t.Cleanup(func() {
for _, cmd := range commands {
if cmd.ProcessState == nil {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}
}
})
deadline := time.Now().Add(5 * time.Second)
for worker := 0; worker < workers; {
if _, err := os.Stat(filepath.Join(readyDir, fmt.Sprintf("ready-%d", worker))); err == nil {
worker++
continue
}
if time.Now().After(deadline) {
t.Fatal("permission helper processes did not become ready")
}
time.Sleep(10 * time.Millisecond)
}
if err := os.WriteFile(startPath, []byte("start"), 0o644); err != nil {
t.Fatal(err)
}
for i, cmd := range commands {
if err := cmd.Wait(); err != nil {
t.Fatalf("permission helper failed: %v\n%s", err, outputs[i].String())
}
}
got := projectAllowRules(t, workspace)
for worker := range workers {
for n := range rulesPerWorker {
rule := fmt.Sprintf("Edit(process-%d-file-%02d)", worker, n)
if !hasPermissionRule(got, rule) {
t.Errorf("remembered rules missing %q: %v", rule, got)
}
}
}
}
func TestRememberPermissionRuleProcessHelper(t *testing.T) {
if os.Getenv("REASONIX_PERMISSION_HELPER") != "1" {
return
}
workspace := os.Getenv("REASONIX_PERMISSION_WORKSPACE")
readyDir := os.Getenv("REASONIX_PERMISSION_READY_DIR")
startPath := os.Getenv("REASONIX_PERMISSION_START")
t.Setenv("REASONIX_CACHE_HOME", readyDir)
t.Setenv("REASONIX_HOME", os.Getenv("REASONIX_PERMISSION_HOME"))
worker, err := strconv.Atoi(os.Getenv("REASONIX_PERMISSION_WORKER"))
if err != nil {
t.Fatal(err)
}
rules, err := strconv.Atoi(os.Getenv("REASONIX_PERMISSION_RULES"))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(readyDir, fmt.Sprintf("ready-%d", worker)), []byte("ready"), 0o644); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(5 * time.Second)
for {
if _, err := os.Stat(startPath); err == nil {
break
}
if time.Now().After(deadline) {
t.Fatal("timed out waiting for permission helper start")
}
time.Sleep(10 * time.Millisecond)
}
for n := range rules {
rule := fmt.Sprintf("Edit(process-%d-file-%02d)", worker, n)
result := rememberPermissionRule(config.Roots{}, workspace, rule)
if result.Err != nil || !result.Saved {
t.Fatalf("remember result = %+v, want saved without error", result)
}
}
}
func TestRememberPermissionRuleKeepsWorkspaceRulesOutOfUserConfig(t *testing.T) {
home := rememberHome(t)
workspace := robustTempDir(t)
writeFile(t, home, "config.toml", "[permissions]\nallow = [\"Bash(user)\"]\n")
const rule = "Edit(src/app.go)"
res := rememberPermissionRule(config.Roots{}, workspace, rule)
if !res.Saved || res.Path != filepath.Join(home, "project-grants.json") {
t.Fatalf("remember result = %+v, want saved to the project allow record", res)
}
if userCfg := config.LoadForEdit(filepath.Join(home, "config.toml")); hasPermissionRule(userCfg.Permissions.Allow, rule) {
t.Fatalf("workspace rule was written to user config: %v", userCfg.Permissions.Allow)
}
if _, err := os.Stat(filepath.Join(workspace, "reasonix.toml")); !os.IsNotExist(err) {
t.Fatalf("remembering created a reasonix.toml in the checkout, err=%v", err)
}
}
func TestRememberPermissionRuleEmptyRootUsesUserConfig(t *testing.T) {
home := rememberHome(t)
cwd := robustTempDir(t)
t.Chdir(cwd)
writeFile(t, cwd, "reasonix.toml", "[permissions]\nallow = [\"Bash(cwd*)\"]\n")
approveWorkspace(t, cwd)
writeFile(t, home, "config.toml", "[permissions]\nallow = [\"Bash(user*)\"]\n")
const rule = "Bash(go env)"
res := rememberPermissionRule(config.Roots{}, "", rule)
userConfig := filepath.Join(home, "config.toml")
if !res.Saved || res.Path != userConfig {
t.Fatalf("remember result = %+v, want saved to the user config", res)
}
if userCfg := config.LoadForEdit(userConfig); !hasPermissionRule(userCfg.Permissions.Allow, rule) {
t.Fatalf("empty root should remember into the user config: %v", userCfg.Permissions.Allow)
}
if cwdCfg := config.LoadForEdit(filepath.Join(cwd, "reasonix.toml")); hasPermissionRule(cwdCfg.Permissions.Allow, rule) {
t.Fatalf("empty root wrote into the cwd project file: %v", cwdCfg.Permissions.Allow)
}
}
func seedProjectGrant(t *testing.T, workspace string, rules ...string) {
t.Helper()
if err := config.NewProjectGrantStore(config.Roots{}.Home()).Update(workspace, func(g config.ProjectGrant) (config.ProjectGrant, error) {
g.Allow = rules
return g, nil
}); err != nil {
t.Fatal(err)
}
}
func TestRememberPermissionRuleSkipsRuleCoveredByExistingAllow(t *testing.T) {
rememberHome(t)
workspace := robustTempDir(t)
seedProjectGrant(t, workspace, "Bash(go test:*)")
res := rememberPermissionRule(config.Roots{}, workspace, "Bash(go test ./...)")
if res.Saved || res.CoveredBy != "Bash(go test:*)" {
t.Fatalf("remember result = %+v, want already covered", res)
}
if got := projectAllowRules(t, workspace); len(got) != 1 || got[0] != "Bash(go test:*)" {
t.Fatalf("allow rules = %v, want only existing prefix", got)
}
}
func TestRememberDynamicBashLiteralIsNotCoveredByBroadRule(t *testing.T) {
rememberHome(t)
workspace := robustTempDir(t)
seedProjectGrant(t, workspace, "Bash(git*)")
const literal = "Bash=git status $(touch /tmp/reasonix-dynamic-approval)"
res := rememberPermissionRule(config.Roots{}, workspace, literal)
if !res.Saved || res.CoveredBy != "" || res.Err != nil {
t.Fatalf("remember dynamic literal = %+v, want newly saved rule", res)
}
if got := projectAllowRules(t, workspace); !hasPermissionRule(got, "Bash(git*)") || !hasPermissionRule(got, literal) {
t.Fatalf("allow rules = %v, want broad rule and dynamic literal", got)
}
res = rememberPermissionRule(config.Roots{}, workspace, literal)
if res.Saved || res.CoveredBy != literal || res.Err != nil {
t.Fatalf("remember duplicate dynamic literal = %+v, want exact deduplication", res)
}
count := 0
for _, rule := range projectAllowRules(t, workspace) {
if rule == literal {
count++
}
}
if count != 1 {
t.Fatalf("dynamic literal count = %d, want 1", count)
}
}
func TestRememberPermissionRulePrunesNarrowRulesWhenSavingBroaderRule(t *testing.T) {
rememberHome(t)
workspace := robustTempDir(t)
seedProjectGrant(t, workspace, "Bash(go test ./...)", "Bash(go build ./...)")
res := rememberPermissionRule(config.Roots{}, workspace, "Bash(go test:*)")
if !res.Saved || res.CoveredBy != "" {
t.Fatalf("remember result = %+v, want saved broader rule", res)
}
got := projectAllowRules(t, workspace)
if hasPermissionRule(got, "Bash(go test ./...)") {
t.Fatalf("narrow go test rule should be pruned: %v", got)
}
if !hasPermissionRule(got, "Bash(go build ./...)") || !hasPermissionRule(got, "Bash(go test:*)") {
t.Fatalf("allow rules = %v, want unrelated exact plus prefix", got)
}
}
func hasPermissionRule(rules []string, want string) bool {
return slices.Contains(rules, want)
}
// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x
// ~/.reasonix/config.json with no v1+ config present must be imported during
// Build — config written, key pinned into the env, and the user told via a notice.
func TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {
home := robustTempDir(t)
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home) // os.UserHomeDir on Windows
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) // os.UserConfigDir on Linux
t.Setenv("AppData", filepath.Join(home, "AppData")) // os.UserConfigDir on Windows
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
t.Setenv("DEEPSEEK_API_KEY", "") // track for cleanup; migration os.Setenv's it live
proj := robustTempDir(t)
t.Chdir(proj)
// Project config merges over the migrated user config without dropping the
// migrated plugins.
writeFile(t, proj, "reasonix.toml", "")
approveWorkspace(t, proj)
writeFile(t, filepath.Join(home, ".reasonix"), "config.json",
`{"apiKey":"sk-e2e","lang":"zh","mcpServers":{"fs":{"command":"npx","args":["-y","server-fs"]}}}`)
writeFile(t, filepath.Join(home, ".reasonix", "sessions"), "chat-1.events.jsonl",
`{"type":"user.message","id":1,"ts":"t","turn":0,"text":"hello from v0.x"}`+"\n"+
`{"type":"model.final","id":2,"ts":"t","turn":0,"content":"hi","toolCalls":[],"usage":{},"costUsd":0}`+"\n")
var notices []string
sink := event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
notices = append(notices, e.Text)
}
})
ctrl, err := Build(context.Background(), Options{Sink: sink})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
migrated := false
for _, n := range notices {
if strings.Contains(n, "migrated your previous configuration") {
migrated = true
}
}
if !migrated {
t.Fatalf("no migration notice emitted; got %v", notices)
}
dest := config.UserConfigPath()
data, err := os.ReadFile(dest)
if err != nil {
t.Fatalf("v2 config not written to %s: %v", dest, err)
}
if !strings.Contains(string(data), `name = "fs"`) || !strings.Contains(string(data), `language = "zh"`) {
t.Errorf("migrated config missing plugin/lang:\n%s", data)
}
if got := os.Getenv("DEEPSEEK_API_KEY"); got != "sk-e2e" {
t.Errorf("DEEPSEEK_API_KEY not pinned into env after migration: %q", got)
}
if data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), "DEEPSEEK_API_KEY=sk-e2e") {
t.Errorf("credentials store missing migrated key: %q (err %v)", data, err)
}
if _, err := os.Stat(filepath.Join(home, ".env")); !os.IsNotExist(err) {
t.Errorf("migration must not write the user's ~/.env, stat err=%v", err)
}
sessionImported := false
for _, n := range notices {
if strings.Contains(n, "imported") && strings.Contains(n, "past session") {
sessionImported = true
}
}
if !sessionImported {
t.Errorf("no session-import notice emitted; got %v", notices)
}
migratedSession := filepath.Join(config.SessionDir(), "chat-1.jsonl")
if _, err := os.Stat(migratedSession); err != nil {
t.Errorf("legacy session not imported to %s: %v", migratedSession, err)
}
}
func TestBuildMigratesLegacyDeepSeekProtocolWithOneNotice(t *testing.T) {
home := isolateConfigHome(t)
t.Setenv("REASONIX_HOME", filepath.Join(home, "reasonix-home"))
userPath := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(userPath), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(userPath, []byte(`default_model = "deepseek-flash/deepseek-flash"
[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
`), 0o600); err != nil {
t.Fatal(err)
}
var notices []event.Event
sink := event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
notices = append(notices, e)
}
})
build := func() {
t.Helper()
ctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: robustTempDir(t)})
if err != nil {
t.Fatalf("Build: %v", err)
}
ctrl.Close()
}
build()
migrationNotices := 0
for _, notice := range notices {
if notice.Text != "DeepSeek official access was upgraded to Anthropic Messages." {
continue
}
migrationNotices++
if notice.Level != event.LevelInfo || !strings.Contains(notice.Detail, "prefix-cache") {
t.Fatalf("migration notice = %+v", notice)
}
}
if migrationNotices != 1 {
t.Fatalf("migration notices = %d, want 1; got %+v", migrationNotices, notices)
}
raw, err := os.ReadFile(userPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `kind = "anthropic"`) ||
!strings.Contains(string(raw), `base_url = "https://api.deepseek.com/anthropic"`) {
t.Fatalf("legacy DeepSeek protocol remained on disk:\n%s", raw)
}
notices = nil
build()
for _, notice := range notices {
if strings.Contains(notice.Text, "DeepSeek official access was upgraded") {
t.Fatalf("second boot repeated migration notice: %+v", notice)
}
}
}
func TestBuildMigratesDeprecatedAgentStepLimitsWithOneNotice(t *testing.T) {
home := isolateConfigHome(t)
t.Setenv("REASONIX_HOME", filepath.Join(home, "reasonix-home"))
project := robustTempDir(t)
configPath := filepath.Join(project, "reasonix.toml")
writeFile(t, project, "reasonix.toml", `
default_model = "test-model"
[agent]
max_steps = 3
planner_max_steps = 4
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`)
approveWorkspace(t, project)
var notices []event.Event
sink := event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
notices = append(notices, e)
}
})
build := func() {
t.Helper()
ctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: project})
if err != nil {
t.Fatalf("Build: %v", err)
}
ctrl.Close()
}
build()
migrationNotices := 0
for _, notice := range notices {
if notice.Text == "Deprecated agent step limits were removed." {
migrationNotices++
if notice.Level != event.LevelInfo || !strings.Contains(notice.Detail, "--max-steps") {
t.Fatalf("migration notice = %+v", notice)
}
}
}
if migrationNotices != 1 {
t.Fatalf("migration notices = %d, want 1; got %+v", migrationNotices, notices)
}
raw, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "planner_max_steps") || strings.Contains(string(raw), "\nmax_steps = 3") {
t.Fatalf("deprecated agent step limits remain after boot:\n%s", raw)
}
notices = nil
build()
for _, notice := range notices {
if strings.Contains(notice.Text, "Deprecated agent step") {
t.Fatalf("second boot repeated migration notice: %+v", notice)
}
}
}
func TestBuildMigratesDeprecatedRedactToolOutputWithOneNotice(t *testing.T) {
home := isolateConfigHome(t)
t.Setenv("REASONIX_HOME", filepath.Join(home, "reasonix-home"))
project := robustTempDir(t)
configPath := filepath.Join(project, "reasonix.toml")
writeFile(t, project, "reasonix.toml", `
default_model = "test-model"
[secrets]
redact_tool_output = true
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`)
approveWorkspace(t, project)
var notices []event.Event
sink := event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
notices = append(notices, e)
}
})
build := func() {
t.Helper()
ctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: project})
if err != nil {
t.Fatalf("Build: %v", err)
}
ctrl.Close()
}
build()
migrationNotices := 0
for _, notice := range notices {
if notice.Text == "Deprecated redact_tool_output setting was removed." {
migrationNotices++
if notice.Level != event.LevelInfo || !strings.Contains(notice.Detail, "doctor redact-sessions") {
t.Fatalf("migration notice = %+v", notice)
}
}
}
if migrationNotices != 1 {
t.Fatalf("migration notices = %d, want 1; got %+v", migrationNotices, notices)
}
raw, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "redact_tool_output") {
t.Fatalf("deprecated redact_tool_output remains after boot:\n%s", raw)
}
notices = nil
build()
for _, notice := range notices {
if strings.Contains(notice.Text, "redact_tool_output") {
t.Fatalf("second boot repeated migration notice: %+v", notice)
}
}
}
func TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {
home := robustTempDir(t)
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config"))
t.Setenv("AppData", filepath.Join(home, "AppData"))
proj := robustTempDir(t)
writeFile(t, proj, "reasonix.toml", "")
approveWorkspace(t, proj)
legacyConfig := config.LegacyUserConfigPath()
if legacyConfig == "" {
t.Skip("legacy OS config path matches primary path on this platform")
}
legacyDir := filepath.Join(filepath.Dir(legacyConfig), "sessions")
writeFile(t, legacyDir, "custom-root.events.jsonl",
`{"type":"user.message","id":1,"ts":"t","turn":0,"text":"hello from redirected config root"}`+"\n"+
`{"type":"model.final","id":2,"ts":"t","turn":0,"content":"hi from redirected root","toolCalls":[],"usage":{},"costUsd":0}`+"\n")
var notices []string
sink := event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
notices = append(notices, e.Text)
}
})
// Pass the project root via WorkspaceRoot instead of t.Chdir: changing the
// process cwd into a t.TempDir makes Windows refuse to remove that dir during
// test cleanup (the cwd counts as "in use"), which is the only thing this test
// failed on. WorkspaceRoot loads the same config without touching the cwd.
ctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: proj})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
sessionPath := filepath.Join(config.SessionDir(), "custom-root.jsonl")
data, err := os.ReadFile(sessionPath)
if err != nil {
t.Fatalf("legacy config-root session not imported to %s: %v", sessionPath, err)
}
if !strings.Contains(string(data), "hello from redirected config root") {
t.Fatalf("migrated session missing legacy content:\n%s", data)
}
if _, err := os.Stat(filepath.Join(config.SessionDir(), ".legacy-imported.v0-events-config")); err != nil {
t.Fatalf("config-root legacy import marker missing: %v", err)
}
sessionImported := false
for _, n := range notices {
if strings.Contains(n, "imported") && strings.Contains(n, "past session") && strings.Contains(n, legacyDir) {
sessionImported = true
}
}
if !sessionImported {
t.Errorf("no config-root session-import notice emitted; got %v", notices)
}
}
func TestBuildSkipsLegacySessionMigrationWhenIsolated(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("legacy XDG paths are Unix-only")
}
home := robustTempDir(t)
xdg := filepath.Join(home, "xdg-config")
reasonixHome := filepath.Join(home, "rx-home")
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", xdg)
t.Setenv("REASONIX_HOME", reasonixHome)
proj := robustTempDir(t)
writeFile(t, proj, "reasonix.toml", "[codegraph]\nenabled = false\n")
approveWorkspace(t, proj)
legacyRoot := filepath.Join(xdg, "reasonix")
writeFile(t, filepath.Join(legacyRoot, "sessions"), "xdg-flat.events.jsonl",
`{"type":"user.message","id":1,"ts":"t","turn":0,"text":"hello from xdg"}`+"\n"+
`{"type":"model.final","id":2,"ts":"t","turn":0,"content":"hi from xdg","toolCalls":[],"usage":{},"costUsd":0}`+"\n")
slug := config.WorkspaceSlug(proj)
legacyProjectDir := filepath.Join(legacyRoot, "projects", slug, "sessions")
session := sessionstore.NewSession("")
session.Add(provider.Message{Role: provider.RoleUser, Content: "hello from old project session"})
if err := session.Save(filepath.Join(legacyProjectDir, "project-chat.jsonl")); err != nil {
t.Fatalf("save legacy project session: %v", err)
}
ctrl, err := Build(context.Background(), Options{WorkspaceRoot: proj})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
if _, err := os.Stat(filepath.Join(config.SessionDir(), "xdg-flat.jsonl")); !os.IsNotExist(err) {
t.Fatal("legacy XDG flat session was imported but must not be when REASONIX_HOME is set")
}
projectPath := filepath.Join(config.MemoryUserDir(), "projects", slug, "sessions", "project-chat.jsonl")
if _, err := os.Stat(projectPath); !os.IsNotExist(err) {
t.Fatal("legacy project session was imported but must not be when REASONIX_HOME is set")
}
}
func TestSkillMCPBindingsUseOnlyValidOwnedCache(t *testing.T) {
specs := []plugin.Spec{
{Name: "figma", Package: "design-plugin", StripRawPrefix: "figma_"},
{Name: "other", Package: "other-plugin"},
}
cached := map[string][]plugin.CachedTool{
"figma": {{Name: "figma_get_design_context"}},
"other": {{Name: "search"}},
}
got := skillMCPBindings(skill.Skill{Plugin: "design-plugin"}, nil, specs, cached, map[string]bool{"figma": true, "other": true})
if len(got) != 1 || got[0].VisibleName != "get_design_context" || got[0].CallableName != plugin.ModelToolName("figma", "get_design_context") || got[0].CapabilityID != "mcp-tool:figma/figma_get_design_context" {
t.Fatalf("cached skill bindings = %+v", got)
}
if stale := skillMCPBindings(skill.Skill{Plugin: "design-plugin"}, nil, specs, cached, map[string]bool{"figma": false}); len(stale) != 0 {
t.Fatalf("stale cache supplied skill bindings: %+v", stale)
}
reg := tool.NewRegistry()
host := plugin.NewHost()
t.Cleanup(host.Close)
liveTools := plugin.LazyToolset(specs[0], &plugin.CachedSchema{Tools: []plugin.CachedTool{{Name: "figma_current_tool"}}}, host, reg, context.Background(), false)
for _, live := range liveTools {
reg.Add(live)
}
oldCache := map[string][]plugin.CachedTool{"figma": {{Name: "figma_removed_tool"}}}
got = skillMCPBindings(skill.Skill{Plugin: "design-plugin"}, reg, specs, oldCache, map[string]bool{"figma": true})
if len(got) != 1 || got[0].RawName != "figma_current_tool" {
t.Fatalf("live registry did not supersede stale boot cache: %+v", got)
}
}
// approveProjectServer answers for a repository-declared MCP server the way a
// user would. A repo's own reasonix.toml and .mcp.json both arrive with a
// clone, so neither starts until someone says it may; a test about something
// else has to say so, or it becomes a test of that gate instead.
func approveProjectServer(t *testing.T, root, name string) {
t.Helper()
entry := config.PluginEntry{Name: name, Source: config.MCPSourceProjectConfig}
if err := config.DefaultActivationStore().SetServerEnabled(entry, root, config.ActivationProject, true); err != nil {
t.Fatalf("approve %s: %v", name, err)
}
}
func TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
[[plugins]]
name = "legacy-eager"
command = "reasonix-missing-legacy-eager-mcp"
tier = "eager"
`)
approveWorkspace(t, dir)
approveProjectServer(t, dir, "legacy-eager")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
ctrl, err := Build(ctx, Options{})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
failures := waitForMCPFailure(t, ctrl.Host(), "legacy-eager", 2*time.Second)
if len(failures) != 1 || failures[0].Name != "legacy-eager" {
t.Fatalf("failures = %+v, want background startup failure for migrated legacy eager plugin", failures)
}
raw, err := os.ReadFile(filepath.Join(dir, "reasonix.toml"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "\ntier") {
t.Fatalf("legacy eager tier should be removed during load:\n%s", raw)
}
}
func TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
[[plugins]]
name = "legacy-lazy"
command = "reasonix-missing-legacy-lazy-mcp"
tier = "lazy"
`)
approveWorkspace(t, dir)
approveProjectServer(t, dir, "legacy-lazy")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
ctrl, err := Build(ctx, Options{})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
failures := waitForMCPFailure(t, ctrl.Host(), "legacy-lazy", 2*time.Second)
if len(failures) != 1 || failures[0].Name != "legacy-lazy" {
t.Fatalf("failures = %+v, want background startup failure for migrated legacy lazy plugin", failures)
}
raw, err := os.ReadFile(filepath.Join(dir, "reasonix.toml"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "\ntier") {
t.Fatalf("legacy lazy tier should be removed during load:\n%s", raw)
}
}
func TestBuildDefaultsToNearestGitRoot(t *testing.T) {
isolateConfigHome(t)
root := robustTempDir(t)
if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatal(err)
}
subdir := filepath.Join(root, "cmd", "tool")
if err := os.MkdirAll(subdir, 0o755); err != nil {
t.Fatal(err)
}
writeFile(t, root, "reasonix.toml", `
default_model = "root-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "root-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`)
approveWorkspace(t, root)
t.Chdir(subdir)
ctrl, err := Build(context.Background(), Options{Model: "root-model"})
if err != nil {
t.Fatalf("Build should load config from nearest git root: %v", err)
}
defer ctrl.Close()
}
func TestNormalizeAdditionalDirs(t *testing.T) {
root := robustTempDir(t)
extra := filepath.Join(root, "extra")
if err := os.Mkdir(extra, 0o755); err != nil {
t.Fatal(err)
}
link := filepath.Join(root, "extra-link")
if err := os.Symlink(extra, link); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
got, err := normalizeAdditionalDirs(root, []string{"extra", link, "", " extra "})
if err != nil {
t.Fatalf("normalizeAdditionalDirs: %v", err)
}
real, err := filepath.EvalSymlinks(extra)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, []string{real}) {
t.Fatalf("normalized dirs = %v, want [%s]", got, real)
}
}
func TestAppendUniquePathsDeduplicatesSymlinkEquivalentRoots(t *testing.T) {
real := robustTempDir(t)
link := filepath.Join(robustTempDir(t), "root-link")
if err := os.Symlink(real, link); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
got := appendUniquePaths([]string{link}, real)
if !reflect.DeepEqual(got, []string{link}) {
t.Fatalf("roots = %v, want only original symlink root", got)
}
}
func TestRuntimeForbidReadRootsAddsOnlyGlobalCredentialFile(t *testing.T) {
home := isolateConfigHome(t)
t.Setenv("REASONIX_HOME", filepath.Join(home, "reasonix-home"))
configured := filepath.Join(robustTempDir(t), "configured-secret")
projectEnv := filepath.Join(robustTempDir(t), ".env")
for _, path := range []string{configured, projectEnv} {
if err := os.WriteFile(path, []byte("secret"), 0o600); err != nil {
t.Fatal(err)
}
}
cfg := config.Default()
cfg.Sandbox.ForbidRead = []string{configured}
withoutCredentials := RuntimeForbidReadRoots(cfg, ".")
hostSecrets := append(secrets.HostSecretPaths(), cfg.Roots().RemoteStateDir())
if !reflect.DeepEqual(withoutCredentials, appendUniquePaths([]string{configured}, hostSecrets...)) {
t.Fatalf("roots without global credentials = %v", withoutCredentials)
}
credentialPath := config.UserCredentialsPath()
if err := os.MkdirAll(filepath.Dir(credentialPath), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(credentialPath, []byte("PROVIDER_KEY=secret"), 0o600); err != nil {
t.Fatal(err)
}
got := RuntimeForbidReadRoots(cfg, ".")
if !pathListContains(got, credentialPath) || !pathListContains(got, configured) {
t.Fatalf("runtime forbid roots = %v", got)
}
if pathListContains(got, projectEnv) {
t.Fatalf("project .env was unexpectedly added to runtime forbid roots: %v", got)
}
}
func TestRuntimeForbidReadRootsFiltersUnconfiguredStoredCredential(t *testing.T) {
home := isolateConfigHome(t)
t.Setenv("REASONIX_HOME", filepath.Join(home, "reasonix-home"))
const staleKey = "REASONIX_TEST_UNCONFIGURED_STORED_CREDENTIAL"
t.Setenv(staleKey, "opaque-stale-value")
credentialPath := config.UserCredentialsPath()
if err := os.MkdirAll(filepath.Dir(credentialPath), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(credentialPath, []byte(staleKey+"=opaque-stale-value\n"), 0o600); err != nil {
t.Fatal(err)
}
_ = RuntimeForbidReadRoots(config.Default(), ".")
joined := strings.Join(secrets.ProcessEnv(), "\n")
if strings.Contains(joined, staleKey+"=") || strings.Contains(joined, "opaque-stale-value") {
t.Fatalf("unconfigured stored credential survived in subprocess env")
}
}
func pathListContains(paths []string, want string) bool {
want = pathComparisonKey(want)
for _, path := range paths {
if pathComparisonKey(path) == want {
return true
}
}
return false
}
func TestNormalizeAdditionalDirsRejectsInvalidPaths(t *testing.T) {
root := robustTempDir(t)
file := filepath.Join(root, "file.txt")
if err := os.WriteFile(file, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
for _, path := range []string{"missing", file} {
t.Run(filepath.Base(path), func(t *testing.T) {
if _, err := normalizeAdditionalDirs(root, []string{path}); err == nil {
t.Fatalf("normalizeAdditionalDirs(%q) unexpectedly succeeded", path)
}
})
}
}
func TestBuildAdditionalDirsAllowWriterAndPreserveToolSchemas(t *testing.T) {
isolateConfigHome(t)
root := robustTempDir(t)
extra := robustTempDir(t)
t.Chdir(root)
writeFile(t, root, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "boot-token-profile-test"
model = "x"
`)
approveWorkspace(t, root)
registerBootTokenProfileTestProvider()
captureSchemas := func(opts Options) []byte {
t.Helper()
prov := testutil.NewMock("additional-dir-schema", testutil.Turn{Text: "done"})
setBootTokenProfileTestProvider(t, prov)
opts.Sink = event.Discard
ctrl, err := Build(context.Background(), opts)
if err != nil {
t.Fatalf("Build: %v", err)
}
if err := ctrl.Run(context.Background(), "capture schemas"); err != nil {
ctrl.Close()
t.Fatalf("Run: %v", err)
}
ctrl.Close()
reqs := prov.Requests()
if len(reqs) != 1 {
t.Fatalf("requests = %d, want 1", len(reqs))
}
encoded, err := json.Marshal(reqs[0].Tools)
if err != nil {
t.Fatal(err)
}
return encoded
}
baseline := captureSchemas(Options{})
withOverrides := captureSchemas(Options{
AdditionalDirs: []string{extra},
PermissionAllow: []string{"Bash(git *)", "Edit"},
})
if !bytes.Equal(baseline, withOverrides) {
t.Fatalf("session access overrides changed provider-visible tool schemas\nbaseline=%s\nwith=%s", baseline, withOverrides)
}
target := filepath.Join(extra, "written.txt")
prov := testutil.NewMock("additional-dir-write",
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "write-1", Name: "write_file", Arguments: fmt.Sprintf(`{"path":%q,"content":"ok"}`, target)}}},
testutil.Turn{Text: "done"},
)
setBootTokenProfileTestProvider(t, prov)
ctrl, err := Build(context.Background(), Options{Sink: event.Discard, AdditionalDirs: []string{extra}})
if err != nil {
t.Fatalf("Build writer: %v", err)
}
defer ctrl.Close()
// The mock writes without verifying, so the readiness gate legitimately
// reports a gap; this test asserts where the write landed.
if err := ctrl.Run(context.Background(), "write into the additional directory"); err != nil &&
!strings.Contains(err.Error(), "readiness") {
t.Fatalf("Run writer: %v", err)
}
if got, err := os.ReadFile(target); err != nil || string(got) != "ok" {
t.Fatalf("additional-dir file = %q, err=%v", got, err)
}
}
func TestBuildAdditionalDirsReachSandboxedBashWriteRoots(t *testing.T) {
if runtime.GOOS == "windows" || !sandbox.Available() {
t.Skip("requires a Unix sandbox backend")
}
isolateConfigHome(t)
root := robustTempDir(t)
extra := robustTempDir(t)
t.Chdir(root)
writeFile(t, root, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[sandbox]
bash = "enforce"
[[providers]]
name = "test-model"
kind = "boot-token-profile-test"
model = "x"
`)
approveWorkspace(t, root)
registerBootTokenProfileTestProvider()
target := filepath.Join(extra, "sandboxed.txt")
command := "printf ok > " + strconv.Quote(target)
prov := testutil.NewMock("additional-dir-bash",
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "bash-1", Name: "bash", Arguments: fmt.Sprintf(`{"command":%q}`, command)}}},
testutil.Turn{Text: "done"},
)
setBootTokenProfileTestProvider(t, prov)
ctrl, err := Build(context.Background(), Options{
Sink: event.Discard,
AdditionalDirs: []string{extra},
HeadlessApprovalMode: control.ToolApprovalYolo,
})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
if err := ctrl.Run(context.Background(), "write from sandboxed bash"); err != nil {
t.Fatalf("Run: %v", err)
}
if got, err := os.ReadFile(target); err != nil || string(got) != "ok" {
t.Fatalf("sandboxed file = %q, err=%v", got, err)
}
}
func waitForMCPFailure(t *testing.T, h *plugin.Host, name string, timeout time.Duration) []plugin.Failure {
t.Helper()
deadline := time.Now().Add(timeout)
for {
failures := h.Failures()
for _, f := range failures {
if f.Name == name {
return failures
}
}
if time.Now().After(deadline) {
return failures
}
time.Sleep(10 * time.Millisecond)
}
}
// TestBuildExtraPluginProbeKeepsSessionProcessAlive pins the lifecycle split
// used by host-supplied ACP/session MCP servers. The five-second readiness
// context is cancelled before Build returns; a successful stdio child must
// still live on the session context and accept its first real tool call.
func TestBuildExtraPluginProbeKeepsSessionProcessAlive(t *testing.T) {
isolateConfigHome(t)
workspace := robustTempDir(t)
t.Chdir(workspace)
sessionCtx := t.Context()
ctrl, err := Build(sessionCtx, Options{
SessionDir: filepath.Join(robustTempDir(t), "sessions"),
Sink: event.Discard,
ExtraPlugins: []plugin.Spec{{
Name: "acp-extra",
Command: os.Args[0],
Args: []string{"-test.run=TestHelperProcess", "--"},
Env: map[string]string{"GO_WANT_HELPER_PROCESS": "1"},
}},
})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
tools, err := ctrl.Host().ToolsFor(sessionCtx, "acp-extra")
if err != nil {
t.Fatalf("ToolsFor: %v", err)
}
var echo tool.Tool
for _, candidate := range tools {
if candidate.Name() == "mcp__acp-extra__echo" {
echo = candidate
break
}
}
if echo == nil {
t.Fatalf("extra plugin echo tool missing from %d tools", len(tools))
}
callCtx, cancelCall := context.WithTimeout(sessionCtx, 5*time.Second)
defer cancelCall()
out, err := echo.Execute(callCtx, json.RawMessage(`{"msg":"after-probe"}`))
if err != nil {
t.Fatalf("Execute after readiness context cancellation: %v", err)
}
if out != "echo: after-probe" {
t.Fatalf("Execute result = %q, want %q", out, "echo: after-probe")
}
}
// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot
// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio
// server in internal/ext/plugin/plugin_test.go so the boot package can drive an
// end-to-end handshake without depending on the plugin package's test helper
// (Go's testing framework only re-invokes the binary of the test package
// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a
// normal `go test ./internal/assembly/boot/...` does not trip it.
func TestHelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return
}
defer os.Exit(0)
in := bufio.NewReader(os.Stdin)
for {
line, err := in.ReadBytes('\n')
if err != nil {
return
}
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
var req struct {
ID *int `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
if err := json.Unmarshal(line, &req); err != nil {
continue
}
if req.ID == nil {
continue // notification: no response
}
var result any
switch req.Method {
case "initialize":
result = map[string]any{
"protocolVersion": "2024-11-05",
"serverInfo": map[string]any{"name": "mock", "version": "0"},
"capabilities": map[string]any{},
}
case "tools/list":
echo := map[string]any{
"name": "echo",
"description": "Echo back the message.",
"inputSchema": map[string]any{
"type": "object",
"properties": map[string]any{"msg": map[string]any{"type": "string"}},
"required": []string{"msg"},
},
}
if os.Getenv("GO_WANT_HELPER_READ_ONLY") == "1" {
echo["annotations"] = map[string]any{"readOnlyHint": true}
}
result = map[string]any{"tools": []map[string]any{echo}}
case "tools/call":
var p struct {
Arguments struct {
Msg string `json:"msg"`
} `json:"arguments"`
}
_ = json.Unmarshal(req.Params, &p)
result = map[string]any{"content": []map[string]any{
{"type": "text", "text": "echo: " + p.Arguments.Msg},
}}
}
resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID, "result": result}
b, _ := json.Marshal(resp)
os.Stdout.Write(append(b, '\n'))
}
}
// TestBuildKeepsSourceConnectorAndSkillToolsDespiteSafeModeEnv pins that
// v1.20+ no longer strips tools when REASONIX_SAFE_MODE is set.
func TestBuildKeepsSourceConnectorAndSkillToolsDespiteSafeModeEnv(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
t.Setenv("REASONIX_SAFE_MODE", "1")
ctrl, err := Build(context.Background(), Options{
SessionDir: filepath.Join(robustTempDir(t), "sessions"),
TokenMode: TokenModeFull,
Sink: event.Discard,
})
if err != nil {
t.Fatalf("Build: %v", err)
}
names := map[string]bool{}
for _, e := range ctrl.ToolContractEntries() {
names[e.Name] = true
}
ctrl.Close()
// Provider-visible surface is the unified core; optional tools remain
// registered for use_capability dispatch even under safe mode.
if !names["use_capability"] {
t.Fatal("expected use_capability when REASONIX_SAFE_MODE is set")
}
for _, want := range []string{"bash", "read_file", "write_file"} {
if !names[want] {
t.Fatalf("expected core tool %s when REASONIX_SAFE_MODE is set", want)
}
}
}