package control

import (
	"context"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"testing"
	"time"

	"reasonix/internal/agent"

	"reasonix/internal/command"
	"reasonix/internal/event"
	"reasonix/internal/hook"
	"reasonix/internal/memory"
	"reasonix/internal/provider"
	"reasonix/internal/skill"
	"reasonix/internal/tool"
)

type fakeTurnRunner struct {
	inputs []string
}

func (f *fakeTurnRunner) Run(ctx context.Context, input string) error {
	f.inputs = append(f.inputs, input)
	return nil
}

type fakeLanguageRunner struct {
	fakeTurnRunner
	responseLang string
	lang         string
}

func (f *fakeLanguageRunner) SetResponseLanguage(lang string) {
	f.responseLang = lang
}

func (f *fakeLanguageRunner) SetReasoningLanguage(lang string) {
	f.lang = lang
}

func TestCustomCommandLookup(t *testing.T) {
	c := New(Options{Commands: []command.Command{{Name: "review"}, {Name: "git:commit"}}})

	if _, ok := c.CustomCommand("/review the diff"); !ok {
		t.Error("review should be found")
	}
	if _, ok := c.CustomCommand("/git:commit"); !ok {
		t.Error("git:commit should be found")
	}
	if _, ok := c.CustomCommand("/missing"); ok {
		t.Error("missing should not be found")
	}
}

func TestSkillsReflectStoreChangesAfterControllerBuild(t *testing.T) {
	project := t.TempDir()
	home := t.TempDir()
	store := skill.New(skill.Options{HomeDir: home, ProjectRoot: project, DisableBuiltins: true})
	c := New(Options{SkillStore: store, Skills: store.List()})

	if _, ok := c.RunSkill("/hot now"); ok {
		t.Fatal("skill should not exist before it is written")
	}
	writeControlSkill(t, project, ".reasonix/skills/hot/SKILL.md", "---\nname: hot\ndescription: Hot install\n---\nHot body")

	if skills := c.Skills(); len(skills) != 1 || skills[0].Name != "hot" {
		t.Fatalf("Skills() = %+v, want newly installed hot skill", skills)
	}
	sent, ok := c.RunSkill("/hot now")
	if !ok {
		t.Fatal("RunSkill should find newly installed skill")
	}
	if !strings.Contains(sent, "Hot body") || !strings.Contains(sent, "Arguments: now") {
		t.Fatalf("rendered skill = %q", sent)
	}
}

func TestSubmitSlashSubagentRunsIsolatedAndPersistsDistilledAnswer(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "session.jsonl")
	sess := agent.NewSession("parent system")
	exec := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
	events := make(chan event.Event, 16)
	mainRunner := &fakeTurnRunner{}
	var calls int
	var gotSkill skill.Skill
	var gotTask, gotParent, gotCallID string
	var gotPlanMode bool
	var gotHostInitiated bool
	runner := func(ctx context.Context, sk skill.Skill, task string, opts skill.SubagentRunOptions) (string, error) {
		calls++
		gotSkill = sk
		gotTask = task
		gotParent = agent.ParentSession(ctx)
		gotCallID, _, _, _ = agent.CallContext(ctx)
		gotPlanMode = agent.PlanModeFromContext(ctx)
		gotHostInitiated = opts.HostInitiated
		agent.NestedSink(ctx, event.Discard).Emit(event.Event{
			Kind: event.ToolDispatch,
			Tool: event.Tool{ID: "child-read", Name: "read_file", ReadOnly: true},
		})
		return "isolated answer", nil
	}
	c := New(Options{
		Runner:      mainRunner,
		Executor:    exec,
		Sink:        event.FuncSink(func(e event.Event) { events <- e }),
		SessionDir:  dir,
		SessionPath: path,
		Skills: []skill.Skill{{
			Name: "helper", Description: "isolated helper", Body: "secret child system prompt",
			RunAs: skill.RunSubagent, Invocation: "manual", Scope: skill.ScopeGlobal,
		}},
		SkillRunner: runner,
		ReadOnlySkillRunner: func(context.Context, skill.Skill, string, skill.SubagentRunOptions) (string, error) {
			t.Fatal("normal-mode slash invocation must not use the read-only runner")
			return "", nil
		},
		SkillProfile: func(skill.Skill) *event.Profile { return &event.Profile{Model: "test/model", Effort: "high"} },
	})
	defer c.Close()

	c.SubmitDisplay("/helper inspect auth", "/helper inspect auth")
	gotEvents := waitForTurnEvents(t, events)
	if calls != 1 || gotSkill.Name != "helper" || !strings.Contains(gotTask, "inspect auth") {
		t.Fatalf("isolated runner calls=%d skill=%q task=%q", calls, gotSkill.Name, gotTask)
	}
	if len(mainRunner.inputs) != 0 {
		t.Fatalf("main runner must not receive a runAs=subagent slash turn: %q", mainRunner.inputs)
	}
	if gotParent != agent.BranchID(path) || !strings.HasPrefix(gotCallID, "slash-skill-") || gotPlanMode || !gotHostInitiated {
		t.Fatalf("runner context parent=%q call=%q plan=%v hostInitiated=%v", gotParent, gotCallID, gotPlanMode, gotHostInitiated)
	}
	msgs := c.History()
	if len(msgs) != 3 || msgs[1].Role != provider.RoleUser || msgs[2].Role != provider.RoleAssistant {
		t.Fatalf("parent history = %+v, want system/user/assistant", msgs)
	}
	if !strings.Contains(msgs[1].Content, "inspect auth") || strings.Contains(msgs[1].Content, gotSkill.Body) {
		t.Fatalf("parent user message should contain the task but not child system prompt: %q", msgs[1].Content)
	}
	if msgs[2].Content != "isolated answer" {
		t.Fatalf("parent distilled answer = %q", msgs[2].Content)
	}
	var sawStart, sawParent, sawNested, sawText bool
	for _, e := range gotEvents {
		switch {
		case e.Kind == event.TurnStarted:
			sawStart = true
		case e.Kind == event.ToolDispatch && e.Tool.Name == "run_skill":
			sawParent = e.Tool.Profile != nil && e.Tool.Profile.Model == "test/model"
		case e.Kind == event.ToolDispatch && e.Tool.Name == "read_file":
			sawNested = e.Tool.ParentID == gotCallID && strings.HasPrefix(e.Tool.ID, gotCallID+"/")
		case e.Kind == event.Text && e.Text == "isolated answer":
			sawText = true
		}
	}
	if !sawStart || !sawParent || !sawNested || !sawText {
		t.Fatalf("slash subagent events missing: start=%v parent=%v nested=%v text=%v events=%+v", sawStart, sawParent, sawNested, sawText, gotEvents)
	}

	// The isolated turn must release the ordinary foreground admission gate so
	// the same session can keep chatting immediately afterwards.
	waitIdle(t, c)
	c.SubmitUserTurn("next turn", "next turn")
	waitForTurnEvents(t, events)
	if len(mainRunner.inputs) != 1 || !strings.Contains(mainRunner.inputs[0], "next turn") {
		t.Fatalf("normal chat did not resume after slash subagent: %q", mainRunner.inputs)
	}
}

func TestSubmitInvocationDisplayExecutesStructuredEntitiesInVisualOrder(t *testing.T) {
	sess := agent.NewSession("parent system")
	exec := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
	events := make(chan event.Event, 24)
	mainRunner := &fakeTurnRunner{}
	var names, tasks []string
	c := New(Options{
		Runner:   mainRunner,
		Executor: exec,
		Sink:     event.FuncSink(func(e event.Event) { events <- e }),
		Skills: []skill.Skill{
			{Name: "format", Body: "FORMAT_RULE", RunAs: skill.RunInline, Scope: skill.ScopeGlobal},
			{Name: "first", Body: "FIRST_SYSTEM", RunAs: skill.RunSubagent, Scope: skill.ScopeGlobal},
			{Name: "second", Body: "SECOND_SYSTEM", RunAs: skill.RunSubagent, Scope: skill.ScopeGlobal},
		},
		SkillRunner: func(_ context.Context, sk skill.Skill, task string, _ skill.SubagentRunOptions) (string, error) {
			names = append(names, sk.Name)
			tasks = append(tasks, task)
			return sk.Name + " answer", nil
		},
	})
	defer c.Close()

	input := "历史会话：prior\n\n当前用户问题：\ninspect auth"
	c.SubmitInvocationDisplay("inspect auth", input, []InvocationRequest{
		{Name: "second", Kind: "subagent", Offset: 20},
		{Name: "format", Kind: "skill", Offset: 0},
		{Name: "first", Kind: "subagent", Offset: 10},
	})
	waitForTurnEvents(t, events)
	waitIdle(t, c)

	if strings.Join(names, ",") != "first,second" {
		t.Fatalf("subagent execution order = %v, want visual order first,second", names)
	}
	if len(tasks) != 2 || !strings.Contains(tasks[0], "FORMAT_RULE") || !strings.Contains(tasks[0], "历史会话：prior") || tasks[0] != tasks[1] {
		t.Fatalf("structured tasks = %#v", tasks)
	}
	if len(mainRunner.inputs) != 0 {
		t.Fatalf("main runner received structured subagent turn: %q", mainRunner.inputs)
	}
	msgs := c.History()
	if len(msgs) != 4 || msgs[1].Role != provider.RoleUser || msgs[2].Content != "first answer" || msgs[3].Content != "second answer" {
		t.Fatalf("parent history = %+v", msgs)
	}
}

func TestSubmitInvocationDisplayPreparesPluginSubagentBindings(t *testing.T) {
	home := t.TempDir()
	pluginRoot := t.TempDir()
	writeControlSkill(t, pluginRoot, "helper/SKILL.md", "---\ndescription: Plugin helper\nrunAs: subagent\n---\nCall search.")
	store := skill.New(skill.Options{
		HomeDir: home, CustomPaths: []string{pluginRoot},
		PluginPaths: map[string][]string{pluginRoot: {"search-plugin"}}, DisableBuiltins: true,
	})
	store.ConfigureToolBindings(func(skill.Skill) []tool.MCPBinding {
		return []tool.MCPBinding{{
			Package: "search-plugin", Server: "search", RawName: "search",
			VisibleName: "search", CallableName: "mcp__search__search", CapabilityID: "mcp-tool:search/search",
		}}
	})

	sess := agent.NewSession("parent system")
	exec := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
	events := make(chan event.Event, 12)
	var got skill.Skill
	c := New(Options{
		Executor: exec, SkillStore: store, Skills: store.List(),
		Sink: event.FuncSink(func(e event.Event) { events <- e }),
		SkillRunner: func(_ context.Context, sk skill.Skill, _ string, _ skill.SubagentRunOptions) (string, error) {
			got = sk
			return "done", nil
		},
	})
	defer c.Close()

	c.SubmitInvocationDisplay("inspect", "inspect", []InvocationRequest{{Name: "search-plugin:helper", Kind: "subagent"}})
	waitForTurnEvents(t, events)
	waitIdle(t, c)
	if !strings.Contains(got.Body, "## Runtime MCP tool bindings") || !strings.Contains(got.Body, "`mcp__search__search`") {
		t.Fatalf("structured plugin subagent was not prepared: %q", got.Body)
	}
}

func TestRunSubagentProfilePreparesPluginBindings(t *testing.T) {
	home := t.TempDir()
	pluginRoot := t.TempDir()
	writeControlSkill(t, pluginRoot, "helper/SKILL.md", "---\ndescription: Plugin helper\nrunAs: subagent\n---\nCall search.")
	store := skill.New(skill.Options{
		HomeDir: home, CustomPaths: []string{pluginRoot},
		PluginPaths: map[string][]string{pluginRoot: {"search-plugin"}}, DisableBuiltins: true,
	})
	store.ConfigureToolBindings(func(skill.Skill) []tool.MCPBinding {
		return []tool.MCPBinding{{
			Package: "search-plugin", Server: "search", RawName: "search",
			VisibleName: "search", CallableName: "mcp__search__search", CapabilityID: "mcp-tool:search/search",
		}}
	})

	var got skill.Skill
	c := New(Options{
		SkillStore: store, Skills: store.List(),
		SkillRunner: func(_ context.Context, sk skill.Skill, _ string, _ skill.SubagentRunOptions) (string, error) {
			got = sk
			return "done", nil
		},
	})
	defer c.Close()

	answer, err := c.RunSubagentProfile(context.Background(), "search-plugin:helper", "inspect", false)
	if err != nil || answer != "done" {
		t.Fatalf("RunSubagentProfile() = %q, %v", answer, err)
	}
	if !strings.Contains(got.Body, "## Runtime MCP tool bindings") || !strings.Contains(got.Body, "`mcp__search__search`") {
		t.Fatalf("headless plugin subagent was not prepared: %q", got.Body)
	}
}

func TestSubmitInvocationDisplayRunsInlineSkillWithoutArguments(t *testing.T) {
	runner := &fakeTurnRunner{}
	c := New(Options{
		Runner: runner,
		Skills: []skill.Skill{{Name: "init", Body: "INITIALIZE_PROJECT", RunAs: skill.RunInline, Scope: skill.ScopeGlobal}},
	})
	c.SubmitInvocationDisplay("", "", []InvocationRequest{{Name: "init", Kind: "skill", Offset: 0}})
	waitIdle(t, c)
	if len(runner.inputs) != 1 || !strings.Contains(runner.inputs[0], "INITIALIZE_PROJECT") {
		t.Fatalf("inline-only structured input = %q", runner.inputs)
	}
}

func TestSubmitInvocationDisplayRunsInlineSkillInsideActiveGoal(t *testing.T) {
	prov := &scriptedTurns{turns: [][]provider.Chunk{
		textTurn("Notes listed.\n\n[goal:complete]"),
	}}
	ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
	events := make(chan event.Event, 8)
	c := New(Options{
		Runner:   ag,
		Executor: ag,
		Sink: event.FuncSink(func(e event.Event) {
			if e.Kind == event.TurnDone || e.Kind == event.Notice {
				events <- e
			}
		}),
		Skills: []skill.Skill{{Name: "notes", Body: "INSPECT_NOTES", RunAs: skill.RunInline, Scope: skill.ScopeGlobal}},
	})
	defer c.Close()
	c.SetGoalWithResearchMode("list the existing notes", GoalResearchOff)
	c.SubmitInvocationDisplay(
		"list the existing notes",
		"list the existing notes",
		[]InvocationRequest{{Name: "notes", Kind: "skill", Offset: 0}},
	)
	waitForTurnDone(t, events)

	if prov.call != 1 {
		t.Fatalf("active Goal structured turns = %d, want 1", prov.call)
	}
	input := firstUserMessage(ag.Session().Messages)
	for _, want := range []string{"<active-goal>\nlist the existing notes", "INSPECT_NOTES", "list the existing notes"} {
		if !strings.Contains(input, want) {
			t.Fatalf("active Goal structured input missing %q: %q", want, input)
		}
	}
}

func TestSubmitInvocationDisplayRunsSubagentSkillInsideActiveGoal(t *testing.T) {
	sess := agent.NewSession("")
	exec := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
	events := make(chan event.Event, 8)
	var gotTask string
	c := New(Options{
		Executor: exec,
		Sink: event.FuncSink(func(e event.Event) {
			if e.Kind == event.TurnDone || e.Kind == event.Notice {
				events <- e
			}
		}),
		Skills: []skill.Skill{{
			Name: "research", Body: "RESEARCH_NOTES", RunAs: skill.RunSubagent, Scope: skill.ScopeGlobal,
		}},
		SkillRunner: func(_ context.Context, _ skill.Skill, task string, _ skill.SubagentRunOptions) (string, error) {
			gotTask = task
			return "Notes listed.\n\n[goal:complete]", nil
		},
	})
	defer c.Close()
	c.SetGoalWithResearchMode("list the existing notes", GoalResearchOff)
	c.SubmitInvocationDisplay(
		"list the existing notes",
		"list the existing notes",
		[]InvocationRequest{{Name: "research", Kind: "subagent", Offset: 0}},
	)
	waitForTurnDone(t, events)

	if !strings.Contains(gotTask, "<active-goal>\nlist the existing notes") {
		t.Fatalf("active Goal subagent task missing goal: %q", gotTask)
	}
	if !strings.Contains(gotTask, "list the existing notes") {
		t.Fatalf("active Goal subagent task missing user task: %q", gotTask)
	}
}

func TestSubmitSlashSubagentUsesPermissionedRunnerInPlanMode(t *testing.T) {
	sess := agent.NewSession("parent system")
	exec := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
	events := make(chan event.Event, 12)
	var normalCalls, readOnlyCalls int
	var gotTask string
	var gotPlanMode bool
	c := New(Options{
		Executor: exec,
		Sink:     event.FuncSink(func(e event.Event) { events <- e }),
		Skills: []skill.Skill{{
			Name: "helper", Description: "isolated helper", Body: "child prompt",
			RunAs: skill.RunSubagent, Invocation: "manual", Scope: skill.ScopeGlobal,
		}},
		SkillRunner: func(ctx context.Context, _ skill.Skill, task string, _ skill.SubagentRunOptions) (string, error) {
			normalCalls++
			gotTask = task
			gotPlanMode = agent.PlanModeFromContext(ctx)
			return "permissioned answer", nil
		},
		ReadOnlySkillRunner: func(context.Context, skill.Skill, string, skill.SubagentRunOptions) (string, error) {
			readOnlyCalls++
			return "", nil
		},
	})
	c.SetPlanMode(true)
	c.Submit("/helper inspect only")
	gotEvents := waitForTurnEvents(t, events)
	if normalCalls != 1 || readOnlyCalls != 0 || !gotPlanMode || !strings.Contains(gotTask, PlanModeMarker) {
		t.Fatalf("plan-mode runners normal=%d readonly=%d plan=%v task=%q", normalCalls, readOnlyCalls, gotPlanMode, gotTask)
	}
	for _, e := range gotEvents {
		if e.Kind == event.ToolDispatch && e.Tool.Name == "run_skill" && e.Tool.ReadOnly {
			t.Fatal("Plan must not relabel a writer-capable slash skill as read-only")
		}
	}
}

func TestSubmitStructuredSubagentUsesPermissionedRunnerInPlanMode(t *testing.T) {
	sess := agent.NewSession("parent system")
	exec := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
	events := make(chan event.Event, 12)
	var normalCalls, readOnlyCalls int
	var gotTask string
	var gotPlanMode bool
	c := New(Options{
		Executor: exec,
		Sink:     event.FuncSink(func(e event.Event) { events <- e }),
		Skills: []skill.Skill{{
			Name: "helper", Description: "isolated helper", Body: "child prompt",
			RunAs: skill.RunSubagent, Invocation: "manual", Scope: skill.ScopeGlobal,
		}},
		SkillRunner: func(ctx context.Context, _ skill.Skill, task string, _ skill.SubagentRunOptions) (string, error) {
			normalCalls++
			gotTask = task
			gotPlanMode = agent.PlanModeFromContext(ctx)
			return "permissioned answer", nil
		},
		ReadOnlySkillRunner: func(context.Context, skill.Skill, string, skill.SubagentRunOptions) (string, error) {
			readOnlyCalls++
			return "", nil
		},
	})
	c.SetPlanMode(true)
	c.SubmitInvocationDisplay("inspect only", "inspect only", []InvocationRequest{{Name: "helper", Kind: "subagent"}})
	gotEvents := waitForTurnEvents(t, events)
	if normalCalls != 1 || readOnlyCalls != 0 || !gotPlanMode || !strings.Contains(gotTask, PlanModeMarker) {
		t.Fatalf("plan structured runners normal=%d readonly=%d plan=%v task=%q", normalCalls, readOnlyCalls, gotPlanMode, gotTask)
	}
	for _, e := range gotEvents {
		if e.Kind == event.ToolDispatch && e.Tool.Name == "run_skill" && e.Tool.ReadOnly {
			t.Fatal("Plan must not relabel a writer-capable structured subagent as read-only")
		}
	}
}

func TestSubmitSlashSubagentRequiresTask(t *testing.T) {
	events := make(chan event.Event, 2)
	var calls int
	c := New(Options{
		Sink: event.FuncSink(func(e event.Event) { events <- e }),
		Skills: []skill.Skill{{
			Name: "helper", RunAs: skill.RunSubagent, Invocation: "manual", Scope: skill.ScopeGlobal,
		}},
		SkillRunner: func(context.Context, skill.Skill, string, skill.SubagentRunOptions) (string, error) {
			calls++
			return "", nil
		},
	})
	c.Submit("/helper")
	select {
	case e := <-events:
		if e.Kind != event.Notice || !strings.Contains(e.Text, "usage: /helper <task>") {
			t.Fatalf("event = %+v", e)
		}
	case <-time.After(time.Second):
		t.Fatal("missing usage notice")
	}
	if calls != 0 || c.Running() || len(c.History()) != 0 {
		t.Fatalf("taskless invocation calls=%d running=%v history=%v", calls, c.Running(), c.History())
	}
}

func TestSubmitSlashSubagentWithoutRunnerFinishesWithError(t *testing.T) {
	events := make(chan event.Event, 2)
	c := New(Options{
		Sink: event.FuncSink(func(e event.Event) { events <- e }),
		Skills: []skill.Skill{{
			Name: "helper", RunAs: skill.RunSubagent, Invocation: "manual", Scope: skill.ScopeGlobal,
		}},
	})
	c.Submit("/helper inspect auth")
	gotEvents := waitForTurnEvents(t, events)
	waitIdle(t, c)
	if terminal := gotEvents[len(gotEvents)-1]; terminal.Kind != event.TurnDone || terminal.Err == nil ||
		!strings.Contains(terminal.Err.Error(), "runner is unavailable") {
		t.Fatalf("missing terminal runner error: %+v", gotEvents)
	}
}

func TestCancelSlashSubagentStopsChildAndKeepsParentSessionUsable(t *testing.T) {
	sess := agent.NewSession("parent system")
	exec := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
	events := make(chan event.Event, 12)
	started := make(chan struct{})
	c := New(Options{
		Executor: exec,
		Sink:     event.FuncSink(func(e event.Event) { events <- e }),
		Skills: []skill.Skill{{
			Name: "helper", RunAs: skill.RunSubagent, Invocation: "manual", Scope: skill.ScopeGlobal,
		}},
		SkillRunner: func(ctx context.Context, _ skill.Skill, _ string, _ skill.SubagentRunOptions) (string, error) {
			close(started)
			<-ctx.Done()
			return "", ctx.Err()
		},
	})
	c.Submit("/helper inspect auth")
	select {
	case <-started:
	case <-time.After(5 * time.Second):
		t.Fatal("slash subagent did not start")
	}
	c.Cancel()
	gotEvents := waitForTurnEvents(t, events)
	waitIdle(t, c)
	if c.Running() {
		t.Fatal("controller still running after cancelling slash subagent")
	}
	msgs := c.History()
	if len(msgs) != 2 || msgs[1].Role != provider.RoleUser {
		t.Fatalf("cancelled slash history = %+v, want system + preserved user task", msgs)
	}
	for _, e := range gotEvents {
		if e.Kind == event.Message || (e.Kind == event.Text && e.Text != "") {
			t.Fatalf("cancelled slash subagent emitted a final answer: %+v", e)
		}
	}
}

func waitForTurnEvents(t *testing.T, events <-chan event.Event) []event.Event {
	t.Helper()
	deadline := time.After(10 * time.Second)
	var got []event.Event
	for {
		select {
		case e := <-events:
			got = append(got, e)
			if e.Kind == event.TurnDone {
				return got
			}
		case <-deadline:
			t.Fatalf("timed out waiting for TurnDone; events=%+v", got)
		}
	}
}

func TestRunSkillUsesQualifiedPluginNameAndHiddenShortCompatibility(t *testing.T) {
	home := t.TempDir()
	pluginRoot := t.TempDir()
	writeControlSkill(t, pluginRoot, "plan/SKILL.md", "---\ndescription: Plugin plan\n---\nPlugin body")
	store := skill.New(skill.Options{
		HomeDir: home, CustomPaths: []string{pluginRoot},
		PluginPaths: map[string][]string{pluginRoot: {"superpowers"}}, DisableBuiltins: true,
	})
	c := New(Options{SkillStore: store, Skills: store.List()})

	if visible := c.SlashSkills(); len(visible) != 1 || visible[0].SlashName() != "superpowers:plan" {
		t.Fatalf("SlashSkills() = %+v", visible)
	}
	for _, input := range []string{"/superpowers:plan now", "/plan now"} {
		sent, ok := c.RunSkill(input)
		if !ok || !strings.Contains(sent, "Plugin body") || !strings.Contains(sent, "Arguments: now") {
			t.Fatalf("RunSkill(%q) = %q, %v", input, sent, ok)
		}
	}
}

func writeControlSkill(t *testing.T, root, rel, body string) {
	t.Helper()
	path := filepath.Join(root, rel)
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
		t.Fatal(err)
	}
}

func TestComposePlanModeMarker(t *testing.T) {
	c := New(Options{}) // no executor — SetPlanMode still tracks the flag

	if got := c.Compose("hi"); got != "hi" {
		t.Errorf("plan off: Compose = %q, want verbatim", got)
	}

	c.SetPlanMode(true)
	got := c.Compose("hi")
	if !strings.HasPrefix(got, PlanModeMarker) || !strings.HasSuffix(got, "hi") {
		t.Errorf("plan on: Compose = %q, want marker-prefixed", got)
	}
}

func TestPlanModeMarkerSeparatesWorkflowFromPermissions(t *testing.T) {
	for _, want := range []string{"planning workflow", "research", "ask", "todo_write", "Do not begin implementation", "not a permission boundary", "Permissions and Sandbox"} {
		if !strings.Contains(PlanModeMarker, want) {
			t.Fatalf("PlanModeMarker missing %q:\n%s", want, PlanModeMarker)
		}
	}
}

func TestComposeReasoningLanguagePreference(t *testing.T) {
	auto := New(Options{ReasoningLanguage: "auto"})
	if got := auto.Compose("hi"); got != "hi" {
		t.Fatalf("auto reasoning language should not alter the turn, got %q", got)
	}

	zh := New(Options{ReasoningLanguage: "zh"})
	got := zh.Compose("hi")
	if !strings.HasPrefix(got, "<reasoning-language>") || !strings.Contains(got, "简体中文") || !strings.HasSuffix(got, "hi") {
		t.Fatalf("zh reasoning language should ride the user turn, got %q", got)
	}
	if stripped := StripComposePrefixes(got); stripped != "hi" {
		t.Fatalf("StripComposePrefixes = %q, want hi", stripped)
	}

	autoZh := New(Options{ReasoningLanguage: "auto"})
	got = autoZh.Compose("解释 AuthHandler 的 panic")
	if !strings.HasPrefix(got, "<reasoning-language>") || !strings.Contains(got, "简体中文") || !strings.HasSuffix(got, "解释 AuthHandler 的 panic") {
		t.Fatalf("auto reasoning language should infer Chinese from the user prompt, got %q", got)
	}
}

func TestRunComposesResponseLanguagePreference(t *testing.T) {
	runner := &fakeTurnRunner{}
	c := New(Options{ResponseLanguage: "en", Runner: runner})

	if err := c.Run(context.Background(), "hi"); err != nil {
		t.Fatal(err)
	}
	if len(runner.inputs) != 1 {
		t.Fatalf("runner inputs = %d, want 1", len(runner.inputs))
	}
	got := runner.inputs[0]
	if !strings.HasPrefix(got, "<response-language>") || !strings.Contains(got, "use English") || !strings.HasSuffix(got, "hi") {
		t.Fatalf("headless Run should compose the response language preference, got %q", got)
	}
}

func TestRunComposesReasoningLanguagePreference(t *testing.T) {
	runner := &fakeTurnRunner{}
	c := New(Options{ReasoningLanguage: "zh", Runner: runner})

	if err := c.Run(context.Background(), "hi"); err != nil {
		t.Fatal(err)
	}
	if len(runner.inputs) != 1 {
		t.Fatalf("runner inputs = %d, want 1", len(runner.inputs))
	}
	got := runner.inputs[0]
	if !strings.HasPrefix(got, "<reasoning-language>") || !strings.Contains(got, "简体中文") || !strings.HasSuffix(got, "hi") {
		t.Fatalf("headless Run should compose the reasoning language preference, got %q", got)
	}
}

func TestRunInjectsSessionStartHookContextOnce(t *testing.T) {
	runner := &fakeTurnRunner{}
	hooks := hook.NewRunner([]hook.ResolvedHook{{
		HookConfig: hook.HookConfig{Command: "session-start"},
		Event:      hook.SessionStart,
		Scope:      hook.ScopeGlobal,
	}}, "/tmp", func(context.Context, hook.SpawnInput) hook.SpawnResult {
		return hook.SpawnResult{ExitCode: 0, Stdout: `{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Load workspace conventions."}}`}
	}, nil)
	c := New(Options{Runner: runner, Hooks: hooks})

	if err := c.Run(context.Background(), "hi"); err != nil {
		t.Fatal(err)
	}
	if len(runner.inputs) != 1 {
		t.Fatalf("runner inputs = %d, want 1", len(runner.inputs))
	}
	first := runner.inputs[0]
	if !strings.Contains(first, `<hook-context event="SessionStart">`) || !strings.Contains(first, "Load workspace conventions.") || !strings.HasSuffix(first, "hi") {
		t.Fatalf("first input missing session hook context: %q", first)
	}

	if err := c.Run(context.Background(), "again"); err != nil {
		t.Fatal(err)
	}
	if len(runner.inputs) != 2 {
		t.Fatalf("runner inputs = %d, want 2", len(runner.inputs))
	}
	if strings.Contains(runner.inputs[1], "<hook-context") {
		t.Fatalf("second input should not repeat hook context: %q", runner.inputs[1])
	}
}

func TestSyntheticComposeDoesNotDrainSessionStartHookContext(t *testing.T) {
	c := New(Options{})
	c.enqueueHookContexts([]string{"Load once."})

	if got := c.compose("synthetic", "synthetic", false); strings.Contains(got, "<hook-context") {
		t.Fatalf("synthetic compose should not inject hook context: %q", got)
	}
	got := c.Compose("real")
	if !strings.Contains(got, `<hook-context event="SessionStart">`) || !strings.Contains(got, "Load once.") || !strings.HasSuffix(got, "real") {
		t.Fatalf("real compose should drain hook context: %q", got)
	}
	if again := c.Compose("again"); strings.Contains(again, "<hook-context") {
		t.Fatalf("hook context should be drained once, got %q", again)
	}
}

func TestComposeAutomaticallyRecallsMemoryOnlyForRealUserTurns(t *testing.T) {
	store := memory.Store{Dir: t.TempDir()}
	if _, err := store.Save(memory.Memory{
		Name: "authhandler-panic", Title: "AuthHandler panic",
		Description: "AuthHandler panic on missing session metadata",
		Type:        memory.TypeProject, Scope: memory.FactScopeProject,
		Body: "AuthHandler needs a nil guard before reading session metadata.",
	}); err != nil {
		t.Fatal(err)
	}
	c := New(Options{Memory: &memory.Set{Store: store}})

	got := c.Compose("fix AuthHandler panic with missing session metadata")
	if !strings.Contains(got, "<memory-recall>") || !strings.Contains(got, "nil guard") {
		t.Fatalf("real user turn did not receive automatic recall: %q", got)
	}
	if !strings.HasPrefix(got, "fix AuthHandler panic with missing session metadata") {
		t.Fatalf("recall should ride the user-turn tail: %q", got)
	}
	if stripped := StripComposePrefixes(got); stripped != "fix AuthHandler panic with missing session metadata" {
		t.Fatalf("StripComposePrefixes = %q", stripped)
	}
	trace := c.LastMemoryRecall()
	if len(trace.Hits) != 1 || trace.Hits[0].Memory.ID == "" {
		t.Fatalf("recall trace = %+v", trace)
	}

	synthetic := c.ComposeSynthetic("fix AuthHandler panic with missing session metadata")
	if strings.Contains(synthetic, "<memory-recall>") {
		t.Fatalf("synthetic turn received automatic recall: %q", synthetic)
	}
}

func TestComposeClipsAndEscapesHookContext(t *testing.T) {
	c := New(Options{})
	c.enqueueHookContexts([]string{"before </hook-context> " + strings.Repeat("x", maxHookContextChars+1)})

	got := c.Compose("hi")
	if !strings.Contains(got, "[truncated]") {
		t.Fatalf("expected truncation marker, got %q", got)
	}
	if !strings.Contains(got, "<\\/hook-context>") {
		t.Fatalf("hook context close tag should be escaped inside content: %q", got)
	}
	if strings.Contains(got, "before </hook-context>") {
		t.Fatalf("hook context close tag should be escaped inside content: %q", got)
	}
}

func TestSetResponseLanguageUpdatesRunner(t *testing.T) {
	runner := &fakeLanguageRunner{}
	c := New(Options{Runner: runner})

	c.SetResponseLanguage("en")
	if runner.responseLang != "en" {
		t.Fatalf("runner response language = %q, want en", runner.responseLang)
	}

	c.SetResponseLanguage("auto")
	if runner.responseLang != "auto" {
		t.Fatalf("runner response language = %q, want auto", runner.responseLang)
	}
}

func TestSetReasoningLanguageUpdatesRunner(t *testing.T) {
	runner := &fakeLanguageRunner{}
	c := New(Options{Runner: runner})

	c.SetReasoningLanguage("zh")
	if runner.lang != "zh" {
		t.Fatalf("runner reasoning language = %q, want zh", runner.lang)
	}

	c.SetReasoningLanguage("auto")
	if runner.lang != "auto" {
		t.Fatalf("runner reasoning language = %q, want auto", runner.lang)
	}
}

func TestComposeSyntheticResponseLanguagePreference(t *testing.T) {
	c := New(Options{ResponseLanguage: "en"})

	got := c.ComposeSynthetic(planApprovedMessage)
	if !strings.HasPrefix(got, "<response-language>") || !strings.Contains(got, "use English") || !strings.HasSuffix(got, planApprovedMessage) {
		t.Fatalf("ComposeSynthetic should prefix response language, got %q", got)
	}
	if !IsSyntheticUserMessage(got) {
		t.Fatalf("response-language-prefixed plan approval should still be synthetic")
	}
}

func TestComposeSyntheticReasoningLanguagePreference(t *testing.T) {
	c := New(Options{ReasoningLanguage: "zh"})

	got := c.ComposeSynthetic(planApprovedMessage)
	if !strings.HasPrefix(got, "<reasoning-language>") || !strings.Contains(got, "简体中文") || !strings.HasSuffix(got, planApprovedMessage) {
		t.Fatalf("ComposeSynthetic should prefix reasoning language, got %q", got)
	}
	if !IsSyntheticUserMessage(got) {
		t.Fatalf("reasoning-language-prefixed plan approval should still be synthetic")
	}
}

func TestComposeIncludesActiveGoal(t *testing.T) {
	c := New(Options{})
	c.SetGoal("ship the approval redesign")

	got := c.Compose("next step?")
	if !strings.Contains(got, "<active-goal>\nship the approval redesign") {
		t.Fatalf("Compose should include active goal block, got %q", got)
	}
	if !strings.Contains(got, "update_goal") {
		t.Fatalf("goal block should instruct the update_goal protocol, got %q", got)
	}
	if !strings.HasSuffix(got, "next step?") {
		t.Fatalf("user text should follow goal block: %q", got)
	}

	c.ClearGoal()
	if got := c.Compose("plain"); got != "plain" {
		t.Fatalf("cleared goal should stop injection, got %q", got)
	}
}

func TestGoalAutoResearchTriggersForLongHorizonGoals(t *testing.T) {
	root := t.TempDir()
	if resolved, err := filepath.EvalSymlinks(root); err == nil {
		root = resolved
	}
	c := New(Options{WorkspaceRoot: root})
	c.SetGoal("持续排查这个线上卡顿直到根因明确，并验证修复")

	got := c.Compose("next step?")
	if !strings.Contains(got, "<active-goal>") || strings.Contains(strings.ToLower(got), "autoresearch") {
		t.Fatalf("unified research Goal prompt = %q", got)
	}
	if got := c.goals.budgetClass; got != budgetClassResearch {
		t.Fatalf("research budget class = %q, want %q", got, budgetClassResearch)
	}
	if c.GoalRuntime().TurnsLimit != 0 {
		t.Fatalf("research Goal should have no turn quota: %+v", c.GoalRuntime())
	}
}

func TestParseGoalCommandResearchFlags(t *testing.T) {
	cmd, ok := ParseGoalCommand("/goal --research fix the typo")
	if !ok || cmd.Action != GoalCommandSet || cmd.Text != "fix the typo" || cmd.ResearchMode != GoalResearchOn || !cmd.DeprecatedBudgetFlag {
		t.Fatalf("ParseGoalCommand --research = %+v ok=%v", cmd, ok)
	}

	cmd, ok = ParseGoalCommand("/goal --simple 持续排查直到根因明确")
	if !ok || cmd.Action != GoalCommandSet || cmd.Text != "持续排查直到根因明确" || cmd.ResearchMode != GoalResearchOff || !cmd.DeprecatedBudgetFlag {
		t.Fatalf("ParseGoalCommand --simple = %+v ok=%v", cmd, ok)
	}
}

func TestGoalCommandSetsReportsAndClears(t *testing.T) {
	var notices []string
	c := New(Options{Sink: event.FuncSink(func(e event.Event) {
		if e.Kind == event.Notice {
			notices = append(notices, e.Text)
		}
	})})
	c.SetPlanMode(true)

	c.Submit("/goal finish the mode redesign")
	if got := c.Goal(); got != "finish the mode redesign" {
		t.Fatalf("Goal() = %q", got)
	}
	if c.PlanMode() {
		t.Fatal("/goal should leave plan mode")
	}
	c.Submit("/goal")
	c.Submit("/goal clear")
	if got := c.Goal(); got != "" {
		t.Fatalf("goal should be cleared, got %q", got)
	}
	joined := strings.Join(notices, "\n")
	for _, want := range []string{"goal set", "goal: finish the mode redesign", "goal cleared"} {
		if !strings.Contains(joined, want) {
			t.Fatalf("notices missing %q: %v", want, notices)
		}
	}
}

func TestParseGoalCommandWithStrict(t *testing.T) {
	tests := []struct {
		input  string
		text   string
		strict bool
		ok     bool
	}{
		{"/goal --strict implement calculator", "implement calculator", true, true},
		{"/goal implement calculator", "implement calculator", false, true},
		{"/goal --strict", "", true, true},        // --strict shows status
		{"/goal --strict status", "", true, true}, // --strict shows status
	}
	for _, tt := range tests {
		cmd, ok := ParseGoalCommand(tt.input)
		if ok != tt.ok {
			t.Errorf("ParseGoalCommand(%q) ok = %v, want %v", tt.input, ok, tt.ok)
			continue
		}
		if !ok {
			continue
		}
		if cmd.Text != tt.text {
			t.Errorf("ParseGoalCommand(%q).Text = %q, want %q", tt.input, cmd.Text, tt.text)
		}
		if cmd.Strict != tt.strict {
			t.Errorf("ParseGoalCommand(%q).Strict = %v, want %v", tt.input, cmd.Strict, tt.strict)
		}
	}
}

func TestParseGoalCommandStrictOnlyConsumesLeadingFlags(t *testing.T) {
	structuredGoal := "implement parser\n\n  keep  spacing\nliteral --strict stays"
	cmd, ok := ParseGoalCommand("/goal --strict " + structuredGoal)
	if !ok {
		t.Fatal("ParseGoalCommand returned ok=false")
	}
	if !cmd.Strict {
		t.Fatal("leading --strict should enable strict mode")
	}
	if cmd.Text != structuredGoal {
		t.Fatalf("goal text was rewritten:\nwant %q\ngot  %q", structuredGoal, cmd.Text)
	}

	cmd, ok = ParseGoalCommand("/goal implement parser --strict literally")
	if !ok {
		t.Fatal("ParseGoalCommand with literal --strict returned ok=false")
	}
	if cmd.Strict {
		t.Fatal("non-leading --strict should remain part of the goal text")
	}
	if want := "implement parser --strict literally"; cmd.Text != want {
		t.Fatalf("goal text = %q, want %q", cmd.Text, want)
	}
}

func TestComposeDrainsQueuedMemory(t *testing.T) {
	c := New(Options{}) // no executor/memory — QueueMemory still queues a turn-tail note

	c.QueueMemory("Saved memory \"rmb\": user's balance is in RMB")
	got := c.Compose("hello")
	if !strings.Contains(got, "<memory-update>") || !strings.Contains(got, "user's balance is in RMB") {
		t.Fatalf("queued memory should ride the turn: %q", got)
	}
	if !strings.HasSuffix(got, "hello") {
		t.Fatalf("user text should follow the memory block: %q", got)
	}
	if got2 := c.Compose("again"); got2 != "again" {
		t.Fatalf("pendingMemory should drain after one turn, got %q", got2)
	}
}

func TestMemoryQuickAddNoteRequiresWhitespace(t *testing.T) {
	tests := []struct {
		in   string
		note string
		ok   bool
	}{
		{in: "# remember this", note: "remember this", ok: true},
		{in: "  #\tremember this  ", note: "remember this", ok: true},
		{in: "#7 needs work", ok: false},
		{in: "#issue needs work", ok: false},
		{in: "# Heading", note: "Heading", ok: true},
		{in: "#", ok: false},
		// Multi-line input is NOT a quick-add — it's a Markdown heading (# Context)
		// followed by structured content. Desktop users pasting COSTAR-style prompts
		// hit this when the first line starts with "# ".
		{in: "# Context\n\n- file.go\n", ok: false},
		{in: "# Heading\nmore text", ok: false},
		{in: "  # Context\n  - file.go  ", ok: false},
	}
	for _, tt := range tests {
		got, ok := MemoryQuickAddNote(tt.in)
		if ok != tt.ok || got != tt.note {
			t.Errorf("MemoryQuickAddNote(%q) = (%q,%v), want (%q,%v)", tt.in, got, ok, tt.note, tt.ok)
		}
	}
}

func TestRememberCommandNote(t *testing.T) {
	tests := []struct {
		in   string
		note string
		ok   bool
	}{
		{in: "/remember use tabs", note: "use tabs", ok: true},
		{in: " /remember\tuse tabs ", note: "use tabs", ok: true},
		{in: "/remember", ok: true},
		{in: "/remembering use tabs", ok: false},
	}
	for _, tt := range tests {
		got, ok := RememberCommandNote(tt.in)
		if ok != tt.ok || got != tt.note {
			t.Errorf("RememberCommandNote(%q) = (%q,%v), want (%q,%v)", tt.in, got, ok, tt.note, tt.ok)
		}
	}
}

func TestSubmitHashNumberStartsTurn(t *testing.T) {
	runner := &fakeTurnRunner{}
	events := make(chan event.Event, 4)
	c := New(Options{
		Runner: runner,
		Sink: event.FuncSink(func(e event.Event) {
			events <- e
		}),
	})

	const input = "#7 needs work"
	c.Submit(input)
	waitForTurnDone(t, events)

	if len(runner.inputs) != 1 || runner.inputs[0] != input {
		t.Fatalf("#number prompt should start a model turn, inputs=%q", runner.inputs)
	}
}

func TestSubmitSlashPathDiagnosticStartsTurnWithFileContext(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("POSIX absolute file path context is covered on POSIX runners")
	}
	dir := t.TempDir()
	file := filepath.Join(dir, "app", "src", "main", "Foo.kt")
	if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(file, []byte("fun broken() = missingSymbol\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	runner := &fakeTurnRunner{}
	events := make(chan event.Event, 4)
	c := New(Options{
		Runner: runner,
		Sink: event.FuncSink(func(e event.Event) {
			events <- e
		}),
	})

	input := file + ":12:13: error: unresolved reference: missingSymbol"
	c.Submit(input)
	waitForTurnDone(t, events)

	if len(runner.inputs) != 1 {
		t.Fatalf("slash path diagnostic should start a model turn, inputs=%q", runner.inputs)
	}
	got := runner.inputs[0]
	if !strings.Contains(got, "Referenced context:") || !strings.Contains(got, "fun broken() = missingSymbol") {
		t.Fatalf("slash path diagnostic should attach file context, got %q", got)
	}
	if !strings.Contains(got, input) {
		t.Fatalf("slash path diagnostic should preserve original error text, got %q", got)
	}
}

func TestSubmitMissingSlashPathDiagnosticStartsTurn(t *testing.T) {
	runner := &fakeTurnRunner{}
	events := make(chan event.Event, 4)
	c := New(Options{
		Runner: runner,
		Sink: event.FuncSink(func(e event.Event) {
			events <- e
		}),
	})

	input := "/missing/Foo.kt:12: error: file no longer exists"
	c.Submit(input)
	waitForTurnDone(t, events)

	if len(runner.inputs) != 1 || runner.inputs[0] != input {
		t.Fatalf("missing slash path diagnostic should start a raw model turn, inputs=%q", runner.inputs)
	}
}

func TestSubmitBlockCommentPrefixStartsTurn(t *testing.T) {
	runner := &fakeTurnRunner{}
	events := make(chan event.Event, 4)
	c := New(Options{
		Runner: runner,
		Sink: event.FuncSink(func(e event.Event) {
			events <- e
		}),
	})

	input := "/**\n * 阿明\n */"
	c.Submit(input)
	waitForTurnDone(t, events)

	if len(runner.inputs) != 1 || runner.inputs[0] != input {
		t.Fatalf("block comment prefix should start a model turn, inputs=%q", runner.inputs)
	}
}

func TestSubmitUnknownSlashCommandStillReportsNotice(t *testing.T) {
	runner := &fakeTurnRunner{}
	events := make(chan event.Event, 4)
	c := New(Options{
		Runner: runner,
		Sink: event.FuncSink(func(e event.Event) {
			events <- e
		}),
	})

	c.Submit("/definitely-not-a-command")

	// Unknown slash input is sent as a regular message (#5756); the notice
	// still fires so genuine typos stay visible.
	var noticeText string
	deadline := time.After(30 * time.Second)
	for noticeText == "" {
		select {
		case e := <-events:
			if e.Kind == event.Notice && strings.Contains(e.Text, "unknown command: /definitely-not-a-command") {
				noticeText = e.Text
			}
		case <-deadline:
			t.Fatal("timed out waiting for unknown-command notice")
		}
	}
	if !strings.Contains(noticeText, "sent as a regular message") {
		t.Fatalf("notice = %q, want the sent-as-message suffix", noticeText)
	}
	waitForTurnDone(t, events)
	if len(runner.inputs) != 1 || !strings.Contains(runner.inputs[0], "/definitely-not-a-command") {
		t.Fatalf("unknown slash command should start a model turn with the raw line, inputs=%q", runner.inputs)
	}
}

func TestSubmitDocsShowsLocalOverviewAndGroundsModelTurn(t *testing.T) {
	runner := &fakeTurnRunner{}
	events := make(chan event.Event, 16)
	c := New(Options{
		Runner: runner,
		Sink: event.FuncSink(func(e event.Event) {
			events <- e
		}),
	})

	c.Submit("/docs")
	select {
	case e := <-events:
		if e.Kind != event.Notice || !strings.Contains(e.Text, "digest=sha256:") || !strings.Contains(e.Text, "/docs") {
			t.Fatalf("bare /docs event = %+v, want local corpus overview", e)
		}
	case <-time.After(30 * time.Second):
		t.Fatal("timed out waiting for /docs overview")
	}
	if len(runner.inputs) != 0 {
		t.Fatalf("bare /docs should not start a model turn, inputs=%q", runner.inputs)
	}

	c.Submit("/docs 1.19.5 更新日志")
	waitForTurnDone(t, events)
	if len(runner.inputs) != 1 {
		t.Fatalf("/docs query model turns = %d, inputs=%q", len(runner.inputs), runner.inputs)
	}
	for _, want := range []string{"1.19.5 更新日志", "changelog/v1.19.5.zh-CN.md", "embedded_docs_search_results"} {
		if !strings.Contains(runner.inputs[0], want) {
			t.Fatalf("grounded /docs prompt missing %q:\n%s", want, runner.inputs[0])
		}
	}
}

func TestSubmitDocsPreservesExistingCustomCommand(t *testing.T) {
	runner := &fakeTurnRunner{}
	events := make(chan event.Event, 8)
	c := New(Options{
		Runner:   runner,
		Commands: []command.Command{{Name: "docs", Body: "legacy docs workflow: $ARGUMENTS"}},
		Sink: event.FuncSink(func(e event.Event) {
			events <- e
		}),
	})

	c.Submit("/docs release notes")
	waitForTurnDone(t, events)
	if len(runner.inputs) != 1 || !strings.Contains(runner.inputs[0], "legacy docs workflow: release notes") {
		t.Fatalf("existing /docs custom command was not preserved: %q", runner.inputs)
	}
	if strings.Contains(runner.inputs[0], "embedded_docs_search_results") {
		t.Fatalf("built-in /docs shadowed the existing custom command: %q", runner.inputs[0])
	}
}

func TestSubmitQualifiedReasonixDocsPreservesExistingCommandAndUsesNextFallback(t *testing.T) {
	runner := &fakeTurnRunner{}
	events := make(chan event.Event, 8)
	c := New(Options{
		Runner: runner,
		Commands: []command.Command{
			{Name: "docs", Body: "legacy docs workflow: $ARGUMENTS"},
			{Name: ReasonixDocsSlashName, Body: "must not shadow built-in docs: $ARGUMENTS"},
		},
		Sink: event.FuncSink(func(e event.Event) {
			events <- e
		}),
	})

	c.Submit("/reasonix:docs existing workflow")
	waitForTurnDone(t, events)
	if len(runner.inputs) != 1 {
		t.Fatalf("qualified custom command model turns = %d, inputs=%q", len(runner.inputs), runner.inputs)
	}
	if !strings.Contains(runner.inputs[0], "must not shadow built-in docs: existing workflow") {
		t.Fatalf("existing qualified custom command was displaced: %q", runner.inputs[0])
	}
	waitIdle(t, c)

	c.Submit("/reasonix:builtin:docs 1.19.5 update notes")
	waitForTurnDone(t, events)
	if len(runner.inputs) != 2 {
		t.Fatalf("generated docs fallback model turns = %d, inputs=%q", len(runner.inputs), runner.inputs)
	}
	for _, want := range []string{"1.19.5 update notes", "changelog/v1.19.5.md", "embedded_docs_search_results"} {
		if !strings.Contains(runner.inputs[1], want) {
			t.Fatalf("qualified docs prompt missing %q:\n%s", want, runner.inputs[1])
		}
	}
	if strings.Contains(runner.inputs[1], "must not shadow built-in docs") || strings.Contains(runner.inputs[1], "legacy docs workflow") {
		t.Fatalf("qualified built-in docs was shadowed: %q", runner.inputs[1])
	}
}

func TestSubmitUserTurnBypassesCommandDispatch(t *testing.T) {
	runner := &fakeTurnRunner{}
	events := make(chan event.Event, 4)
	c := New(Options{
		Runner: runner,
		Sink: event.FuncSink(func(e event.Event) {
			events <- e
		}),
	})

	for _, input := range []string{"!echo should stay a prompt", "/clear"} {
		c.SubmitUserTurn(input, input)
		waitForTurnDone(t, events)
		// The next SubmitUserTurn must wait out the finishing window or it is
		// silently dropped by runGuarded — see waitIdle.
		waitIdle(t, c)
	}

	if len(runner.inputs) != 2 {
		t.Fatalf("SubmitUserTurn should start model turns, inputs=%q", runner.inputs)
	}
	if runner.inputs[0] != "!echo should stay a prompt" || runner.inputs[1] != "/clear" {
		t.Fatalf("SubmitUserTurn inputs = %q", runner.inputs)
	}
}

func TestSubmitRememberCommandQuickAddsMemory(t *testing.T) {
	dir := t.TempDir()
	runner := &fakeTurnRunner{}
	c := New(Options{
		Runner: runner,
		Memory: memory.Load(memory.Options{CWD: dir}),
	})

	c.Submit("/remember use tabs")

	if len(runner.inputs) != 0 {
		t.Fatalf("/remember should not start a model turn, inputs=%q", runner.inputs)
	}
	body, err := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(string(body), "- use tabs") {
		t.Fatalf("memory file missing note:\n%s", body)
	}
}

// waitIdle blocks until the controller's turn-admission gate reopens.
// TurnDone is emitted INSIDE the finishing window (finishGuardedTurn sets
// running=false, finishing=true, emits, then clears finishing), and runGuarded
// silently no-ops while finishing is set — so "received TurnDone" does NOT
// mean "may submit the next turn". A submit raced into that window is
// dropped, and the next turn's TurnDone never arrives; under parallel test
// load the window is wide enough to hit (observed in CI and on a clean
// main-v2 worktree). Poll the same running||finishing gate the controller
// admission checks.
func waitIdle(t *testing.T, c *Controller) {
	t.Helper()
	deadline := time.Now().Add(30 * time.Second)
	for c.Running() {
		if time.Now().After(deadline) {
			t.Fatal("timed out waiting for the controller to return to idle")
		}
		time.Sleep(time.Millisecond)
	}
}

func waitForTurnDone(t *testing.T, events <-chan event.Event) {
	t.Helper()
	deadline := time.After(30 * time.Second)
	for {
		select {
		case e := <-events:
			if e.Kind == event.TurnDone {
				if e.Err != nil {
					t.Fatalf("turn finished with error: %v", e.Err)
				}
				return
			}
		case <-deadline:
			t.Fatal("timed out waiting for turn_done")
		}
	}
}

func TestStripComposePrefixes(t *testing.T) {
	tests := []struct {
		name  string
		input string
		want  string
	}{
		{
			name:  "plain user message unchanged",
			input: "explain this function",
			want:  "explain this function",
		},
		{
			name:  "plan mode marker stripped",
			input: PlanModeMarker + "\n\nexplain this function",
			want:  "explain this function",
		},
		{
			name:  "legacy plan mode marker stripped",
			input: legacyPlanModeMarker + "\n\nexplain this function",
			want:  "explain this function",
		},
		{
			name:  "plan mode marker without trailing newlines",
			input: PlanModeMarker,
			want:  "",
		},
		{
			name:  "memory update block stripped",
			input: "<memory-update>\nThe following project-memory changes were just made and apply from now on:\n- Saved memory \"rmb\": user balance\n</memory-update>\n\nexplain this",
			want:  "explain this",
		},
		{
			name:  "background jobs block stripped",
			input: "<background-jobs>\n1 completed\n</background-jobs>\n\nexplain this",
			want:  "explain this",
		},
		{
			name:  "hook context block stripped",
			input: "<hook-context event=\"SessionStart\">\nLoad conventions.\n</hook-context>\n\nexplain this",
			want:  "explain this",
		},
		{
			name:  "memory and plan marker both stripped",
			input: "<memory-update>\n- note\n</memory-update>\n\n" + PlanModeMarker + "\n\nexplain this",
			want:  "explain this",
		},
		{
			name:  "empty after stripping",
			input: PlanModeMarker + "\n\n",
			want:  "",
		},
		{
			name:  "memory update only no user text",
			input: "<memory-update>\n- note\n</memory-update>\n\n",
			want:  "",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := StripComposePrefixes(tt.input)
			if got != tt.want {
				t.Errorf("StripComposePrefixes() = %q, want %q", got, tt.want)
			}
		})
	}
}

func TestStripReferencedContextPrefix(t *testing.T) {
	tests := []struct {
		name  string
		input string
		want  string
	}{
		{
			name:  "plain user message unchanged",
			input: "explain this function",
			want:  "explain this function",
		},
		{
			name:  "file reference stripped",
			input: "Referenced context:\n\n<file path=\"main.go\">\nfunc main() {}\n</file>\n\nexplain this function",
			want:  "explain this function",
		},
		{
			name:  "multiple file references stripped",
			input: "Referenced context:\n\n<file path=\"a.go\">\npackage a\n</file>\n\n<file path=\"b.go\">\npackage b\n</file>\n\ncompare these files",
			want:  "compare these files",
		},
		{
			name:  "dir reference stripped",
			input: "Referenced context:\n\n<dir path=\"src\">\nmain.go\nutil.go\n</dir>\n\nlist the files",
			want:  "list the files",
		},
		{
			name:  "resource reference stripped",
			input: "Referenced context:\n\n<resource ref=\"@server/res\">\ndata\n</resource>\n\nanalyze this",
			want:  "analyze this",
		},
		{
			name:  "image reference stripped",
			input: "Referenced context:\n\n<image path=\"screenshot.png\">\n[image attachment available at @screenshot.png]\n</image>\n\nwhat is in this image",
			want:  "what is in this image",
		},
		{
			name:  "only reference no user text",
			input: "Referenced context:\n\n<file path=\"main.go\">\nfunc main() {}\n</file>\n\n",
			want:  "",
		},
		{
			name:  "empty input",
			input: "",
			want:  "",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := StripReferencedContextPrefix(tt.input)
			if got != tt.want {
				t.Errorf("StripReferencedContextPrefix() = %q, want %q", got, tt.want)
			}
		})
	}
}

func TestIsSyntheticUserMessage(t *testing.T) {
	tests := []struct {
		name  string
		input string
		want  bool
	}{
		{
			name:  "plan approved message",
			input: planApprovedMessage,
			want:  true,
		},
		{
			name:  "plan approved message with reasoning language",
			input: reasoningLanguageBlock("zh") + "\n\n" + planApprovedMessage,
			want:  true,
		},
		{
			name:  "stream recovery interrupted tool",
			input: "The previous assistant response was interrupted while a tool call was streaming. Continue the same task now.",
			want:  true,
		},
		{
			name:  "stream recovery interrupted text",
			input: "The previous assistant response was interrupted during streaming. Continue the same task from immediately after the partial assistant message above.",
			want:  true,
		},
		{
			name:  "empty final retry",
			input: "The previous assistant response finished without any visible answer text. Continue the same task now and provide a concise visible answer.",
			want:  true,
		},
		{
			name:  "readiness retry",
			input: "Host final-answer readiness check failed. Before giving a final answer, address the missing host-observable receipts: missing evidence.",
			want:  true,
		},
		{
			name:  "executor handoff",
			input: "You are already in the executor phase. The planner's read-only limitations do not apply to you.",
			want:  true,
		},
		{
			name:  "regular user message",
			input: "explain this function",
			want:  false,
		},
		{
			name:  "plan mode marker in message",
			input: PlanModeMarker + "\n\nexplain this",
			want:  false,
		},
		{
			name:  "stream recovery interrupted before visible",
			input: "The previous assistant response was interrupted during streaming before visible answer text was completed. Continue the same task now.",
			want:  true,
		},
		{
			name:  "user quoting interrupted response not synthetic",
			input: "The previous assistant response was interrupted by my VPN, can you retry?",
			want:  false,
		},
		{
			name:  "compaction fold summary",
			input: "<compaction-summary>\nSummary of earlier conversation (older messages were compacted to save context):\nDid things with tools.\n</compaction-summary>",
			want:  true,
		},
		{
			name:  "summarize-from fold",
			input: "Summary of the later conversation (compacted from here on):\nDid more things.",
			want:  true,
		},
		{
			name:  "summarize-upto fold",
			input: "Summary of earlier conversation (compacted up to here):\nDid earlier things.",
			want:  true,
		},
		{
			name:  "user mentioning a summary is not synthetic",
			input: "Summary of what I want: fix the login bug first.",
			want:  false,
		},
		{
			name:  "mid-turn steer is not synthetic (handled separately in historyMessages)",
			input: agent.MidTurnSteerPrefix + "\nplease use smaller diffs",
			want:  false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := IsSyntheticUserMessage(tt.input)
			if got != tt.want {
				t.Errorf("IsSyntheticUserMessage() = %v, want %v", got, tt.want)
			}
		})
	}
}
