返回 DeepSeek-Reasonix
input_test.go
根目录 / internal / control / input_test.go
1 package control
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "runtime"
8 "strings"
9 "testing"
10 "time"
11
12 "reasonix/internal/agent"
13
14 "reasonix/internal/command"
15 "reasonix/internal/event"
16 "reasonix/internal/hook"
17 "reasonix/internal/memory"
18 "reasonix/internal/provider"
19 "reasonix/internal/skill"
20 "reasonix/internal/tool"
21 )
22
23 type fakeTurnRunner struct {
24 inputs []string
25 }
26
27 func (f *fakeTurnRunner) Run(ctx context.Context, input string) error {
28 f.inputs = append(f.inputs, input)
29 return nil
30 }
31
32 type fakeLanguageRunner struct {
33 fakeTurnRunner
34 responseLang string
35 lang string
36 }
37
38 func (f *fakeLanguageRunner) SetResponseLanguage(lang string) {
39 f.responseLang = lang
40 }
41
42 func (f *fakeLanguageRunner) SetReasoningLanguage(lang string) {
43 f.lang = lang
44 }
45
46 func TestCustomCommandLookup(t *testing.T) {
47 c := newOwnedTestController(t, Options{Commands: []command.Command{{Name: "review"}, {Name: "git:commit"}}})
48
49 if _, ok := c.CustomCommand("/review the diff"); !ok {
50 t.Error("review should be found")
51 }
52 if _, ok := c.CustomCommand("/git:commit"); !ok {
53 t.Error("git:commit should be found")
54 }
55 if _, ok := c.CustomCommand("/missing"); ok {
56 t.Error("missing should not be found")
57 }
58 }
59
60 func TestSkillsReflectStoreChangesAfterControllerBuild(t *testing.T) {
61 project := t.TempDir()
62 home := t.TempDir()
63 store := skill.New(skill.Options{HomeDir: home, ProjectRoot: project, DisableBuiltins: true})
64 c := newOwnedTestController(t, Options{SkillStore: store, Skills: store.List()})
65
66 if _, ok := c.RunSkill("/hot now"); ok {
67 t.Fatal("skill should not exist before it is written")
68 }
69 writeControlSkill(t, project, ".reasonix/skills/hot/SKILL.md", "---\nname: hot\ndescription: Hot install\n---\nHot body")
70
71 if skills := c.Skills(); len(skills) != 1 || skills[0].Name != "hot" {
72 t.Fatalf("Skills() = %+v, want newly installed hot skill", skills)
73 }
74 sent, ok := c.RunSkill("/hot now")
75 if !ok {
76 t.Fatal("RunSkill should find newly installed skill")
77 }
78 if !strings.Contains(sent, "Hot body") || !strings.Contains(sent, "Arguments: now") {
79 t.Fatalf("rendered skill = %q", sent)
80 }
81 }
82
83 func TestSubmitSlashSubagentRunsIsolatedAndPersistsDistilledAnswer(t *testing.T) {
84 dir := t.TempDir()
85 path := filepath.Join(dir, "session.jsonl")
86 sess := agent.NewSession("parent system")
87 exec := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
88 events := make(chan event.Event, 16)
89 mainRunner := &fakeTurnRunner{}
90 var calls int
91 var gotSkill skill.Skill
92 var gotTask, gotParent, gotCallID string
93 var gotPlanMode bool
94 var gotHostInitiated bool
95 runner := func(ctx context.Context, sk skill.Skill, task string, opts skill.SubagentRunOptions) (string, error) {
96 calls++
97 gotSkill = sk
98 gotTask = task
99 gotParent = agent.ParentSession(ctx)
100 gotCallID, _, _, _ = agent.CallContext(ctx)
101 gotPlanMode = agent.PlanModeFromContext(ctx)
102 gotHostInitiated = opts.HostInitiated
103 agent.NestedSink(ctx, event.Discard).Emit(event.Event{
104 Kind: event.ToolDispatch,
105 Tool: event.Tool{ID: "child-read", Name: "read_file", ReadOnly: true},
106 })
107 return "isolated answer", nil
108 }
109 c := newOwnedTestController(t, Options{
110 Runner: mainRunner,
111 Executor: exec,
112 Sink: event.FuncSink(func(e event.Event) { events <- e }),
113 SessionDir: dir,
114 SessionPath: path,
115 Skills: []skill.Skill{{
116 Name: "helper", Description: "isolated helper", Body: "secret child system prompt",
117 RunAs: skill.RunSubagent, Invocation: "manual", Scope: skill.ScopeGlobal,
118 }},
119 SkillRunner: runner,
120 ReadOnlySkillRunner: func(context.Context, skill.Skill, string, skill.SubagentRunOptions) (string, error) {
121 t.Fatal("normal-mode slash invocation must not use the read-only runner")
122 return "", nil
123 },
124 SkillProfile: func(skill.Skill) *event.Profile { return &event.Profile{Model: "test/model", Effort: "high"} },
125 })
126 defer c.Close()
127
128 c.SubmitDisplay("/helper inspect auth", "/helper inspect auth")
129 gotEvents := waitForTurnEvents(t, events)
130 if calls != 1 || gotSkill.Name != "helper" || !strings.Contains(gotTask, "inspect auth") {
131 t.Fatalf("isolated runner calls=%d skill=%q task=%q", calls, gotSkill.Name, gotTask)
132 }
133 if len(mainRunner.inputs) != 0 {
134 t.Fatalf("main runner must not receive a runAs=subagent slash turn: %q", mainRunner.inputs)
135 }
136 if gotParent != agent.BranchID(path) || !strings.HasPrefix(gotCallID, "slash-skill-") || gotPlanMode || !gotHostInitiated {
137 t.Fatalf("runner context parent=%q call=%q plan=%v hostInitiated=%v", gotParent, gotCallID, gotPlanMode, gotHostInitiated)
138 }
139 msgs := c.History()
140 if len(msgs) != 3 || !agent.IsUserAuthoredTurnMessage(msgs[1]) || msgs[2].Role != provider.RoleAssistant {
141 t.Fatalf("parent history = %+v, want system/user/assistant", msgs)
142 }
143 if !strings.Contains(msgs[1].Content, "inspect auth") || strings.Contains(msgs[1].Content, gotSkill.Body) {
144 t.Fatalf("parent user message should contain the task but not child system prompt: %q", msgs[1].Content)
145 }
146 if msgs[2].Content != "isolated answer" {
147 t.Fatalf("parent distilled answer = %q", msgs[2].Content)
148 }
149 var sawStart, sawParent, sawNested, sawText bool
150 for _, e := range gotEvents {
151 switch {
152 case e.Kind == event.TurnStarted:
153 sawStart = true
154 case e.Kind == event.ToolDispatch && e.Tool.Name == "run_skill":
155 sawParent = e.Tool.Profile != nil && e.Tool.Profile.Model == "test/model"
156 case e.Kind == event.ToolDispatch && e.Tool.Name == "read_file":
157 sawNested = e.Tool.ParentID == gotCallID && strings.HasPrefix(e.Tool.ID, gotCallID+"/")
158 case e.Kind == event.Text && e.Text == "isolated answer":
159 sawText = true
160 }
161 }
162 if !sawStart || !sawParent || !sawNested || !sawText {
163 t.Fatalf("slash subagent events missing: start=%v parent=%v nested=%v text=%v events=%+v", sawStart, sawParent, sawNested, sawText, gotEvents)
164 }
165
166 // The isolated turn must release the ordinary foreground admission gate so
167 // the same session can keep chatting immediately afterwards.
168 waitIdle(t, c)
169 c.SubmitUserTurn("next turn", "next turn")
170 waitForTurnEvents(t, events)
171 if len(mainRunner.inputs) != 1 || !strings.Contains(mainRunner.inputs[0], "next turn") {
172 t.Fatalf("normal chat did not resume after slash subagent: %q", mainRunner.inputs)
173 }
174 }
175
176 func TestSubmitInvocationDisplayExecutesStructuredEntitiesInVisualOrder(t *testing.T) {
177 sess := agent.NewSession("parent system")
178 exec := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
179 events := make(chan event.Event, 24)
180 mainRunner := &fakeTurnRunner{}
181 var names, tasks []string
182 c := newOwnedTestController(t, Options{
183 Runner: mainRunner,
184 Executor: exec,
185 Sink: event.FuncSink(func(e event.Event) { events <- e }),
186 Skills: []skill.Skill{
187 {Name: "format", Body: "FORMAT_RULE", RunAs: skill.RunInline, Scope: skill.ScopeGlobal},
188 {Name: "first", Body: "FIRST_SYSTEM", RunAs: skill.RunSubagent, Scope: skill.ScopeGlobal},
189 {Name: "second", Body: "SECOND_SYSTEM", RunAs: skill.RunSubagent, Scope: skill.ScopeGlobal},
190 },
191 SkillRunner: func(_ context.Context, sk skill.Skill, task string, _ skill.SubagentRunOptions) (string, error) {
192 names = append(names, sk.Name)
193 tasks = append(tasks, task)
194 return sk.Name + " answer", nil
195 },
196 })
197 defer c.Close()
198
199 input := "历史会话:prior\n\n当前用户问题:\ninspect auth"
200 c.SubmitInvocationDisplay("inspect auth", input, []InvocationRequest{
201 {Name: "second", Kind: "subagent", Offset: 20},
202 {Name: "format", Kind: "skill", Offset: 0},
203 {Name: "first", Kind: "subagent", Offset: 10},
204 })
205 waitForTurnEvents(t, events)
206 waitIdle(t, c)
207
208 if strings.Join(names, ",") != "first,second" {
209 t.Fatalf("subagent execution order = %v, want visual order first,second", names)
210 }
211 if len(tasks) != 2 || !strings.Contains(tasks[0], "FORMAT_RULE") || !strings.Contains(tasks[0], "历史会话:prior") || tasks[0] != tasks[1] {
212 t.Fatalf("structured tasks = %#v", tasks)
213 }
214 if len(mainRunner.inputs) != 0 {
215 t.Fatalf("main runner received structured subagent turn: %q", mainRunner.inputs)
216 }
217 msgs := c.History()
218 if len(msgs) != 5 || msgs[1].Origin != provider.MessageOriginHost ||
219 !agent.IsUserAuthoredTurnMessage(msgs[2]) || msgs[3].Content != "first answer" || msgs[4].Content != "second answer" {
220 t.Fatalf("parent history = %+v", msgs)
221 }
222 }
223
224 func TestSubmitInvocationDisplayPreparesPluginSubagentBindings(t *testing.T) {
225 home := t.TempDir()
226 pluginRoot := t.TempDir()
227 writeControlSkill(t, pluginRoot, "helper/SKILL.md", "---\ndescription: Plugin helper\nrunAs: subagent\n---\nCall search.")
228 store := skill.New(skill.Options{
229 HomeDir: home, CustomPaths: []string{pluginRoot},
230 PluginPaths: map[string][]string{pluginRoot: {"search-plugin"}}, DisableBuiltins: true,
231 })
232 store.ConfigureToolBindings(func(skill.Skill) []tool.MCPBinding {
233 return []tool.MCPBinding{{
234 Package: "search-plugin", Server: "search", RawName: "search",
235 VisibleName: "search", CallableName: "mcp__search__search", CapabilityID: "mcp-tool:search/search",
236 }}
237 })
238
239 sess := agent.NewSession("parent system")
240 exec := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
241 events := make(chan event.Event, 12)
242 var got skill.Skill
243 c := newOwnedTestController(t, Options{
244 Executor: exec, SkillStore: store, Skills: store.List(),
245 Sink: event.FuncSink(func(e event.Event) { events <- e }),
246 SkillRunner: func(_ context.Context, sk skill.Skill, _ string, _ skill.SubagentRunOptions) (string, error) {
247 got = sk
248 return "done", nil
249 },
250 })
251 defer c.Close()
252
253 c.SubmitInvocationDisplay("inspect", "inspect", []InvocationRequest{{Name: "search-plugin:helper", Kind: "subagent"}})
254 waitForTurnEvents(t, events)
255 waitIdle(t, c)
256 if !strings.Contains(got.Body, "## Runtime MCP tool bindings") || !strings.Contains(got.Body, "`mcp__search__search`") {
257 t.Fatalf("structured plugin subagent was not prepared: %q", got.Body)
258 }
259 }
260
261 func TestRunSubagentProfilePreparesPluginBindings(t *testing.T) {
262 home := t.TempDir()
263 pluginRoot := t.TempDir()
264 writeControlSkill(t, pluginRoot, "helper/SKILL.md", "---\ndescription: Plugin helper\nrunAs: subagent\n---\nCall search.")
265 store := skill.New(skill.Options{
266 HomeDir: home, CustomPaths: []string{pluginRoot},
267 PluginPaths: map[string][]string{pluginRoot: {"search-plugin"}}, DisableBuiltins: true,
268 })
269 store.ConfigureToolBindings(func(skill.Skill) []tool.MCPBinding {
270 return []tool.MCPBinding{{
271 Package: "search-plugin", Server: "search", RawName: "search",
272 VisibleName: "search", CallableName: "mcp__search__search", CapabilityID: "mcp-tool:search/search",
273 }}
274 })
275
276 var got skill.Skill
277 c := newOwnedTestController(t, Options{
278 SkillStore: store, Skills: store.List(),
279 SkillRunner: func(_ context.Context, sk skill.Skill, _ string, _ skill.SubagentRunOptions) (string, error) {
280 got = sk
281 return "done", nil
282 },
283 })
284 defer c.Close()
285
286 answer, err := c.RunSubagentProfile(context.Background(), "search-plugin:helper", "inspect", false)
287 if err != nil || answer != "done" {
288 t.Fatalf("RunSubagentProfile() = %q, %v", answer, err)
289 }
290 if !strings.Contains(got.Body, "## Runtime MCP tool bindings") || !strings.Contains(got.Body, "`mcp__search__search`") {
291 t.Fatalf("headless plugin subagent was not prepared: %q", got.Body)
292 }
293 }
294
295 func TestSubmitInvocationDisplayRunsInlineSkillWithoutArguments(t *testing.T) {
296 runner := &fakeTurnRunner{}
297 c := newOwnedTestController(t, Options{
298 Runner: runner,
299 Skills: []skill.Skill{{Name: "init", Body: "INITIALIZE_PROJECT", RunAs: skill.RunInline, Scope: skill.ScopeGlobal}},
300 })
301 c.SubmitInvocationDisplay("", "", []InvocationRequest{{Name: "init", Kind: "skill", Offset: 0}})
302 waitIdle(t, c)
303 if len(runner.inputs) != 1 || !strings.Contains(runner.inputs[0], "INITIALIZE_PROJECT") {
304 t.Fatalf("inline-only structured input = %q", runner.inputs)
305 }
306 }
307
308 func TestSubmitInvocationDisplayRunsInlineSkillInsideActiveGoal(t *testing.T) {
309 prov := &scriptedTurns{turns: goalToolTurn(GoalStatusComplete, "notes listed", "")}
310 ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
311 events := make(chan event.Event, 8)
312 c := newOwnedTestController(t, Options{
313 Runner: ag,
314 Executor: ag,
315 Sink: event.FuncSink(func(e event.Event) {
316 if e.Kind == event.TurnDone || e.Kind == event.Notice {
317 events <- e
318 }
319 }),
320 Skills: []skill.Skill{{Name: "notes", Body: "INSPECT_NOTES", RunAs: skill.RunInline, Scope: skill.ScopeGlobal}},
321 })
322 defer c.Close()
323 c.SetGoalWithResearchMode("list the existing notes", GoalResearchOff)
324 c.SubmitInvocationDisplay(
325 "list the existing notes",
326 "list the existing notes",
327 []InvocationRequest{{Name: "notes", Kind: "skill", Offset: 0}},
328 )
329 waitForTurnDone(t, events)
330
331 if prov.call != 2 {
332 t.Fatalf("active Goal structured turns = %d, want 1", prov.call)
333 }
334 input := firstUserMessage(ag.Session().Messages)
335 for _, want := range []string{"<active-goal>\nlist the existing notes", "INSPECT_NOTES", "list the existing notes"} {
336 if !strings.Contains(input, want) {
337 t.Fatalf("active Goal structured input missing %q: %q", want, input)
338 }
339 }
340 }
341
342 func TestSubmitInvocationDisplayRunsSubagentSkillInsideActiveGoal(t *testing.T) {
343 sess := agent.NewSession("")
344 prov := &scriptedTurns{turns: goalToolTurn(GoalStatusComplete, "notes reviewed", "")}
345 exec := agent.New(prov, goalRegistry(), sess, agent.Options{}, event.Discard)
346 events := make(chan event.Event, 8)
347 var gotTask string
348 c := newOwnedTestController(t, Options{
349 Executor: exec, Runner: exec,
350 Sink: event.FuncSink(func(e event.Event) {
351 if e.Kind == event.TurnDone || e.Kind == event.Notice {
352 events <- e
353 }
354 }),
355 Skills: []skill.Skill{{
356 Name: "research", Body: "RESEARCH_NOTES", RunAs: skill.RunSubagent, Scope: skill.ScopeGlobal,
357 }},
358 SkillRunner: func(_ context.Context, _ skill.Skill, task string, _ skill.SubagentRunOptions) (string, error) {
359 gotTask = task
360 return "Notes listed.\n\n[goal:complete]", nil
361 },
362 })
363 defer c.Close()
364 c.SetGoalWithResearchMode("list the existing notes", GoalResearchOff)
365 c.SubmitInvocationDisplay(
366 "list the existing notes",
367 "list the existing notes",
368 []InvocationRequest{{Name: "research", Kind: "subagent", Offset: 0}},
369 )
370 waitForTurnDone(t, events)
371
372 if !strings.Contains(gotTask, "<active-goal>\nlist the existing notes") {
373 t.Fatalf("active Goal subagent task missing goal: %q", gotTask)
374 }
375 if !strings.Contains(gotTask, "list the existing notes") {
376 t.Fatalf("active Goal subagent task missing user task: %q", gotTask)
377 }
378 }
379
380 func TestSubmitSlashSubagentUsesPermissionedRunnerInPlanMode(t *testing.T) {
381 sess := agent.NewSession("parent system")
382 exec := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
383 events := make(chan event.Event, 12)
384 var normalCalls, readOnlyCalls int
385 var gotTask string
386 var gotPlanMode bool
387 c := newOwnedTestController(t, Options{
388 Executor: exec,
389 Sink: event.FuncSink(func(e event.Event) { events <- e }),
390 Skills: []skill.Skill{{
391 Name: "helper", Description: "isolated helper", Body: "child prompt",
392 RunAs: skill.RunSubagent, Invocation: "manual", Scope: skill.ScopeGlobal,
393 }},
394 SkillRunner: func(ctx context.Context, _ skill.Skill, task string, _ skill.SubagentRunOptions) (string, error) {
395 normalCalls++
396 gotTask = task
397 gotPlanMode = agent.PlanModeFromContext(ctx)
398 return "permissioned answer", nil
399 },
400 ReadOnlySkillRunner: func(context.Context, skill.Skill, string, skill.SubagentRunOptions) (string, error) {
401 readOnlyCalls++
402 return "", nil
403 },
404 })
405 c.SetPlanMode(true)
406 c.Submit("/helper inspect only")
407 gotEvents := waitForTurnEvents(t, events)
408 if normalCalls != 1 || readOnlyCalls != 0 || !gotPlanMode || !strings.Contains(gotTask, PlanModeMarker) {
409 t.Fatalf("plan-mode runners normal=%d readonly=%d plan=%v task=%q", normalCalls, readOnlyCalls, gotPlanMode, gotTask)
410 }
411 for _, e := range gotEvents {
412 if e.Kind == event.ToolDispatch && e.Tool.Name == "run_skill" && e.Tool.ReadOnly {
413 t.Fatal("Plan must not relabel a writer-capable slash skill as read-only")
414 }
415 }
416 }
417
418 func TestSubmitStructuredSubagentUsesPermissionedRunnerInPlanMode(t *testing.T) {
419 sess := agent.NewSession("parent system")
420 exec := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
421 events := make(chan event.Event, 12)
422 var normalCalls, readOnlyCalls int
423 var gotTask string
424 var gotPlanMode bool
425 c := newOwnedTestController(t, Options{
426 Executor: exec,
427 Sink: event.FuncSink(func(e event.Event) { events <- e }),
428 Skills: []skill.Skill{{
429 Name: "helper", Description: "isolated helper", Body: "child prompt",
430 RunAs: skill.RunSubagent, Invocation: "manual", Scope: skill.ScopeGlobal,
431 }},
432 SkillRunner: func(ctx context.Context, _ skill.Skill, task string, _ skill.SubagentRunOptions) (string, error) {
433 normalCalls++
434 gotTask = task
435 gotPlanMode = agent.PlanModeFromContext(ctx)
436 return "permissioned answer", nil
437 },
438 ReadOnlySkillRunner: func(context.Context, skill.Skill, string, skill.SubagentRunOptions) (string, error) {
439 readOnlyCalls++
440 return "", nil
441 },
442 })
443 c.SetPlanMode(true)
444 c.SubmitInvocationDisplay("inspect only", "inspect only", []InvocationRequest{{Name: "helper", Kind: "subagent"}})
445 gotEvents := waitForTurnEvents(t, events)
446 if normalCalls != 1 || readOnlyCalls != 0 || !gotPlanMode || !strings.Contains(gotTask, PlanModeMarker) {
447 t.Fatalf("plan structured runners normal=%d readonly=%d plan=%v task=%q", normalCalls, readOnlyCalls, gotPlanMode, gotTask)
448 }
449 for _, e := range gotEvents {
450 if e.Kind == event.ToolDispatch && e.Tool.Name == "run_skill" && e.Tool.ReadOnly {
451 t.Fatal("Plan must not relabel a writer-capable structured subagent as read-only")
452 }
453 }
454 }
455
456 func TestSubmitSlashSubagentRequiresTask(t *testing.T) {
457 events := make(chan event.Event, 2)
458 var calls int
459 c := newOwnedTestController(t, Options{
460 Sink: event.FuncSink(func(e event.Event) { events <- e }),
461 Skills: []skill.Skill{{
462 Name: "helper", RunAs: skill.RunSubagent, Invocation: "manual", Scope: skill.ScopeGlobal,
463 }},
464 SkillRunner: func(context.Context, skill.Skill, string, skill.SubagentRunOptions) (string, error) {
465 calls++
466 return "", nil
467 },
468 })
469 c.Submit("/helper")
470 select {
471 case e := <-events:
472 if e.Kind != event.Notice || !strings.Contains(e.Text, "usage: /helper <task>") {
473 t.Fatalf("event = %+v", e)
474 }
475 case <-time.After(time.Second):
476 t.Fatal("missing usage notice")
477 }
478 if calls != 0 || c.Running() || len(c.History()) != 0 {
479 t.Fatalf("taskless invocation calls=%d running=%v history=%v", calls, c.Running(), c.History())
480 }
481 }
482
483 func TestSubmitSlashSubagentWithoutRunnerFinishesWithError(t *testing.T) {
484 events := make(chan event.Event, 2)
485 c := newOwnedTestController(t, Options{
486 Sink: event.FuncSink(func(e event.Event) { events <- e }),
487 Skills: []skill.Skill{{
488 Name: "helper", RunAs: skill.RunSubagent, Invocation: "manual", Scope: skill.ScopeGlobal,
489 }},
490 })
491 c.Submit("/helper inspect auth")
492 gotEvents := waitForTurnEvents(t, events)
493 waitIdle(t, c)
494 if terminal := gotEvents[len(gotEvents)-1]; terminal.Kind != event.TurnDone || terminal.Err == nil ||
495 !strings.Contains(terminal.Err.Error(), "runner is unavailable") {
496 t.Fatalf("missing terminal runner error: %+v", gotEvents)
497 }
498 }
499
500 func TestCancelSlashSubagentStopsChildAndKeepsParentSessionUsable(t *testing.T) {
501 sess := agent.NewSession("parent system")
502 exec := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
503 events := make(chan event.Event, 12)
504 started := make(chan struct{})
505 c := newOwnedTestController(t, Options{
506 Executor: exec,
507 Sink: event.FuncSink(func(e event.Event) { events <- e }),
508 Skills: []skill.Skill{{
509 Name: "helper", RunAs: skill.RunSubagent, Invocation: "manual", Scope: skill.ScopeGlobal,
510 }},
511 SkillRunner: func(ctx context.Context, _ skill.Skill, _ string, _ skill.SubagentRunOptions) (string, error) {
512 close(started)
513 <-ctx.Done()
514 return "", ctx.Err()
515 },
516 })
517 c.Submit("/helper inspect auth")
518 select {
519 case <-started:
520 case <-time.After(5 * time.Second):
521 t.Fatal("slash subagent did not start")
522 }
523 c.Cancel()
524 gotEvents := waitForTurnEvents(t, events)
525 waitIdle(t, c)
526 if c.Running() {
527 t.Fatal("controller still running after cancelling slash subagent")
528 }
529 msgs := c.History()
530 if len(msgs) != 2 || !agent.IsUserAuthoredTurnMessage(msgs[1]) {
531 t.Fatalf("cancelled slash history = %+v, want system + preserved user task", msgs)
532 }
533 for _, e := range gotEvents {
534 if e.Kind == event.Message || (e.Kind == event.Text && e.Text != "") {
535 t.Fatalf("cancelled slash subagent emitted a final answer: %+v", e)
536 }
537 }
538 }
539
540 func waitForTurnEvents(t *testing.T, events <-chan event.Event) []event.Event {
541 t.Helper()
542 deadline := time.After(10 * time.Second)
543 var got []event.Event
544 for {
545 select {
546 case e := <-events:
547 got = append(got, e)
548 if e.Kind == event.TurnDone {
549 return got
550 }
551 case <-deadline:
552 t.Fatalf("timed out waiting for TurnDone; events=%+v", got)
553 }
554 }
555 }
556
557 func TestRunSkillUsesQualifiedPluginNameAndHiddenShortCompatibility(t *testing.T) {
558 home := t.TempDir()
559 pluginRoot := t.TempDir()
560 writeControlSkill(t, pluginRoot, "plan/SKILL.md", "---\ndescription: Plugin plan\n---\nPlugin body")
561 store := skill.New(skill.Options{
562 HomeDir: home, CustomPaths: []string{pluginRoot},
563 PluginPaths: map[string][]string{pluginRoot: {"superpowers"}}, DisableBuiltins: true,
564 })
565 c := newOwnedTestController(t, Options{SkillStore: store, Skills: store.List()})
566
567 if visible := c.SlashSkills(); len(visible) != 1 || visible[0].SlashName() != "superpowers:plan" {
568 t.Fatalf("SlashSkills() = %+v", visible)
569 }
570 for _, input := range []string{"/superpowers:plan now", "/plan now"} {
571 sent, ok := c.RunSkill(input)
572 if !ok || !strings.Contains(sent, "Plugin body") || !strings.Contains(sent, "Arguments: now") {
573 t.Fatalf("RunSkill(%q) = %q, %v", input, sent, ok)
574 }
575 }
576 }
577
578 func writeControlSkill(t *testing.T, root, rel, body string) {
579 t.Helper()
580 path := filepath.Join(root, rel)
581 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
582 t.Fatal(err)
583 }
584 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
585 t.Fatal(err)
586 }
587 }
588
589 func TestComposePlanModeMarker(t *testing.T) {
590 c := newOwnedTestController(t, Options{}) // no executor — SetPlanMode still tracks the flag
591
592 if got := c.Compose("hi"); got != "hi" {
593 t.Errorf("plan off: Compose = %q, want verbatim", got)
594 }
595
596 c.SetPlanMode(true)
597 got := c.Compose("hi")
598 if !strings.HasPrefix(got, PlanModeMarker) || !strings.HasSuffix(got, "hi") {
599 t.Errorf("plan on: Compose = %q, want marker-prefixed", got)
600 }
601 }
602
603 func TestPlanModeMarkerSeparatesWorkflowFromPermissions(t *testing.T) {
604 for _, want := range []string{"planning workflow", "research", "ask", "todo_write", "Do not begin implementation", "host blocks state-changing actions", "Permissions and Sandbox"} {
605 if !strings.Contains(PlanModeMarker, want) {
606 t.Fatalf("PlanModeMarker missing %q:\n%s", want, PlanModeMarker)
607 }
608 }
609 }
610
611 func TestComposeReasoningLanguagePreference(t *testing.T) {
612 auto := newOwnedTestController(t, Options{ReasoningLanguage: "auto"})
613 if got := auto.Compose("hi"); got != "hi" {
614 t.Fatalf("auto reasoning language should not alter the turn, got %q", got)
615 }
616
617 zh := newOwnedTestController(t, Options{ReasoningLanguage: "zh"})
618 got := zh.Compose("hi")
619 if !strings.HasPrefix(got, "<reasoning-language>") || !strings.Contains(got, "简体中文") || !strings.HasSuffix(got, "hi") {
620 t.Fatalf("zh reasoning language should ride the user turn, got %q", got)
621 }
622 if stripped := StripComposePrefixes(got); stripped != "hi" {
623 t.Fatalf("StripComposePrefixes = %q, want hi", stripped)
624 }
625
626 autoZh := newOwnedTestController(t, Options{ReasoningLanguage: "auto"})
627 got = autoZh.Compose("解释 AuthHandler 的 panic")
628 if !strings.HasPrefix(got, "<reasoning-language>") || !strings.Contains(got, "简体中文") || !strings.HasSuffix(got, "解释 AuthHandler 的 panic") {
629 t.Fatalf("auto reasoning language should infer Chinese from the user prompt, got %q", got)
630 }
631 }
632
633 func TestRunComposesResponseLanguagePreference(t *testing.T) {
634 runner := &fakeTurnRunner{}
635 c := newOwnedTestController(t, Options{ResponseLanguage: "en", Runner: runner})
636
637 if err := c.Run(context.Background(), "hi"); err != nil {
638 t.Fatal(err)
639 }
640 if len(runner.inputs) != 1 {
641 t.Fatalf("runner inputs = %d, want 1", len(runner.inputs))
642 }
643 got := runner.inputs[0]
644 if !strings.HasPrefix(got, "<response-language>") || !strings.Contains(got, "use English") || !strings.HasSuffix(got, "hi") {
645 t.Fatalf("headless Run should compose the response language preference, got %q", got)
646 }
647 }
648
649 func TestRunComposesReasoningLanguagePreference(t *testing.T) {
650 runner := &fakeTurnRunner{}
651 c := newOwnedTestController(t, Options{ReasoningLanguage: "zh", Runner: runner})
652
653 if err := c.Run(context.Background(), "hi"); err != nil {
654 t.Fatal(err)
655 }
656 if len(runner.inputs) != 1 {
657 t.Fatalf("runner inputs = %d, want 1", len(runner.inputs))
658 }
659 got := runner.inputs[0]
660 if !strings.HasPrefix(got, "<reasoning-language>") || !strings.Contains(got, "简体中文") || !strings.HasSuffix(got, "hi") {
661 t.Fatalf("headless Run should compose the reasoning language preference, got %q", got)
662 }
663 }
664
665 func TestRunInjectsSessionStartHookContextOnce(t *testing.T) {
666 runner := &fakeTurnRunner{}
667 hooks := hook.NewRunner([]hook.ResolvedHook{{
668 HookConfig: hook.HookConfig{Command: "session-start"},
669 Event: hook.SessionStart,
670 Scope: hook.ScopeGlobal,
671 }}, "/tmp", func(context.Context, hook.SpawnInput) hook.SpawnResult {
672 return hook.SpawnResult{ExitCode: 0, Stdout: `{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Load workspace conventions."}}`}
673 }, nil)
674 c := newOwnedTestController(t, Options{Runner: runner, Hooks: hooks})
675
676 if err := c.Run(context.Background(), "hi"); err != nil {
677 t.Fatal(err)
678 }
679 if len(runner.inputs) != 1 {
680 t.Fatalf("runner inputs = %d, want 1", len(runner.inputs))
681 }
682 first := runner.inputs[0]
683 if !strings.Contains(first, `<hook-context event="SessionStart">`) || !strings.Contains(first, "Load workspace conventions.") || !strings.HasSuffix(first, "hi") {
684 t.Fatalf("first input missing session hook context: %q", first)
685 }
686
687 if err := c.Run(context.Background(), "again"); err != nil {
688 t.Fatal(err)
689 }
690 if len(runner.inputs) != 2 {
691 t.Fatalf("runner inputs = %d, want 2", len(runner.inputs))
692 }
693 if strings.Contains(runner.inputs[1], "<hook-context") {
694 t.Fatalf("second input should not repeat hook context: %q", runner.inputs[1])
695 }
696 }
697
698 func TestSyntheticComposeDoesNotDrainSessionStartHookContext(t *testing.T) {
699 c := newOwnedTestController(t, Options{})
700 c.enqueueHookContexts([]string{"Load once."})
701
702 if got := c.compose("synthetic", "synthetic", false); strings.Contains(got, "<hook-context") {
703 t.Fatalf("synthetic compose should not inject hook context: %q", got)
704 }
705 got := c.Compose("real")
706 if !strings.Contains(got, `<hook-context event="SessionStart">`) || !strings.Contains(got, "Load once.") || !strings.HasSuffix(got, "real") {
707 t.Fatalf("real compose should drain hook context: %q", got)
708 }
709 if again := c.Compose("again"); strings.Contains(again, "<hook-context") {
710 t.Fatalf("hook context should be drained once, got %q", again)
711 }
712 }
713
714 func TestComposeAutomaticallyRecallsMemoryOnlyForRealUserTurns(t *testing.T) {
715 store := memory.Store{Dir: t.TempDir()}
716 if _, err := store.Save(memory.Memory{
717 Name: "authhandler-panic", Title: "AuthHandler panic",
718 Description: "AuthHandler panic on missing session metadata",
719 Type: memory.TypeProject, Scope: memory.FactScopeProject,
720 Body: "AuthHandler needs a nil guard before reading session metadata.",
721 }); err != nil {
722 t.Fatal(err)
723 }
724 c := newOwnedTestController(t, Options{Memory: &memory.Set{Store: store}})
725
726 got := c.Compose("fix AuthHandler panic with missing session metadata")
727 if !strings.Contains(got, "<memory-recall>") || !strings.Contains(got, "nil guard") {
728 t.Fatalf("real user turn did not receive automatic recall: %q", got)
729 }
730 if !strings.HasPrefix(got, "fix AuthHandler panic with missing session metadata") {
731 t.Fatalf("recall should ride the user-turn tail: %q", got)
732 }
733 if stripped := StripComposePrefixes(got); stripped != "fix AuthHandler panic with missing session metadata" {
734 t.Fatalf("StripComposePrefixes = %q", stripped)
735 }
736 trace := c.LastMemoryRecall()
737 if len(trace.Hits) != 1 || trace.Hits[0].Memory.ID == "" {
738 t.Fatalf("recall trace = %+v", trace)
739 }
740
741 synthetic := c.ComposeSynthetic("fix AuthHandler panic with missing session metadata")
742 if strings.Contains(synthetic, "<memory-recall>") {
743 t.Fatalf("synthetic turn received automatic recall: %q", synthetic)
744 }
745 }
746
747 func TestComposeClipsAndEscapesHookContext(t *testing.T) {
748 c := newOwnedTestController(t, Options{})
749 c.enqueueHookContexts([]string{"before </hook-context> " + strings.Repeat("x", maxHookContextChars+1)})
750
751 got := c.Compose("hi")
752 if !strings.Contains(got, "[truncated]") {
753 t.Fatalf("expected truncation marker, got %q", got)
754 }
755 if !strings.Contains(got, "<\\/hook-context>") {
756 t.Fatalf("hook context close tag should be escaped inside content: %q", got)
757 }
758 if strings.Contains(got, "before </hook-context>") {
759 t.Fatalf("hook context close tag should be escaped inside content: %q", got)
760 }
761 }
762
763 func TestSetResponseLanguageUpdatesRunner(t *testing.T) {
764 runner := &fakeLanguageRunner{}
765 c := newOwnedTestController(t, Options{Runner: runner})
766
767 c.SetResponseLanguage("en")
768 if runner.responseLang != "en" {
769 t.Fatalf("runner response language = %q, want en", runner.responseLang)
770 }
771
772 c.SetResponseLanguage("auto")
773 if runner.responseLang != "auto" {
774 t.Fatalf("runner response language = %q, want auto", runner.responseLang)
775 }
776 }
777
778 func TestSetReasoningLanguageUpdatesRunner(t *testing.T) {
779 runner := &fakeLanguageRunner{}
780 c := newOwnedTestController(t, Options{Runner: runner})
781
782 c.SetReasoningLanguage("zh")
783 if runner.lang != "zh" {
784 t.Fatalf("runner reasoning language = %q, want zh", runner.lang)
785 }
786
787 c.SetReasoningLanguage("auto")
788 if runner.lang != "auto" {
789 t.Fatalf("runner reasoning language = %q, want auto", runner.lang)
790 }
791 }
792
793 func TestComposeSyntheticResponseLanguagePreference(t *testing.T) {
794 c := newOwnedTestController(t, Options{ResponseLanguage: "en"})
795
796 got := c.ComposeSynthetic(planApprovedMessage)
797 if !strings.HasPrefix(got, "<response-language>") || !strings.Contains(got, "use English") || !strings.HasSuffix(got, planApprovedMessage) {
798 t.Fatalf("ComposeSynthetic should prefix response language, got %q", got)
799 }
800 if !IsSyntheticUserMessage(got) {
801 t.Fatalf("response-language-prefixed plan approval should still be synthetic")
802 }
803 }
804
805 func TestComposeSyntheticReasoningLanguagePreference(t *testing.T) {
806 c := newOwnedTestController(t, Options{ReasoningLanguage: "zh"})
807
808 got := c.ComposeSynthetic(planApprovedMessage)
809 if !strings.HasPrefix(got, "<reasoning-language>") || !strings.Contains(got, "简体中文") || !strings.HasSuffix(got, planApprovedMessage) {
810 t.Fatalf("ComposeSynthetic should prefix reasoning language, got %q", got)
811 }
812 if !IsSyntheticUserMessage(got) {
813 t.Fatalf("reasoning-language-prefixed plan approval should still be synthetic")
814 }
815 }
816
817 func TestComposeIncludesActiveGoal(t *testing.T) {
818 c := newOwnedTestController(t, Options{})
819 c.SetGoal("ship the approval redesign")
820
821 got := c.Compose("next step?")
822 if !strings.Contains(got, "<active-goal>\nship the approval redesign") {
823 t.Fatalf("Compose should include active goal block, got %q", got)
824 }
825 if !strings.Contains(got, "update_goal") {
826 t.Fatalf("goal block should instruct the update_goal protocol, got %q", got)
827 }
828 if !strings.HasSuffix(got, "next step?") {
829 t.Fatalf("user text should follow goal block: %q", got)
830 }
831
832 c.ClearGoal()
833 if got := c.Compose("plain"); got != "plain" {
834 t.Fatalf("cleared goal should stop injection, got %q", got)
835 }
836 }
837
838 func TestGoalAutoResearchTriggersForLongHorizonGoals(t *testing.T) {
839 root := t.TempDir()
840 if resolved, err := filepath.EvalSymlinks(root); err == nil {
841 root = resolved
842 }
843 c := newOwnedTestController(t, Options{WorkspaceRoot: root})
844 c.SetGoal("持续排查这个线上卡顿直到根因明确,并验证修复")
845
846 got := c.Compose("next step?")
847 if !strings.Contains(got, "<active-goal>") || strings.Contains(strings.ToLower(got), "autoresearch") {
848 t.Fatalf("unified research Goal prompt = %q", got)
849 }
850 if got := c.goals.budgetClass; got != budgetClassResearch {
851 t.Fatalf("research budget class = %q, want %q", got, budgetClassResearch)
852 }
853 if c.GoalRuntime().TurnsLimit != 0 {
854 t.Fatalf("research Goal should have no turn quota: %+v", c.GoalRuntime())
855 }
856 }
857
858 func TestParseGoalCommandResearchFlags(t *testing.T) {
859 cmd, ok := ParseGoalCommand("/goal --research fix the typo")
860 if !ok || cmd.Action != GoalCommandSet || cmd.Text != "fix the typo" || cmd.ResearchMode != GoalResearchOn || !cmd.DeprecatedBudgetFlag {
861 t.Fatalf("ParseGoalCommand --research = %+v ok=%v", cmd, ok)
862 }
863
864 cmd, ok = ParseGoalCommand("/goal --simple 持续排查直到根因明确")
865 if !ok || cmd.Action != GoalCommandSet || cmd.Text != "持续排查直到根因明确" || cmd.ResearchMode != GoalResearchOff || !cmd.DeprecatedBudgetFlag {
866 t.Fatalf("ParseGoalCommand --simple = %+v ok=%v", cmd, ok)
867 }
868 }
869
870 func TestGoalCommandSetsReportsAndClears(t *testing.T) {
871 var notices []string
872 c := newOwnedTestController(t, Options{Sink: event.FuncSink(func(e event.Event) {
873 if e.Kind == event.Notice {
874 notices = append(notices, e.Text)
875 }
876 })})
877 c.SetPlanMode(true)
878
879 c.Submit("/goal finish the mode redesign")
880 if got := c.Goal(); got != "finish the mode redesign" {
881 t.Fatalf("Goal() = %q", got)
882 }
883 if c.PlanMode() {
884 t.Fatal("/goal should leave plan mode")
885 }
886 c.Submit("/goal")
887 c.Submit("/goal clear")
888 if got := c.Goal(); got != "" {
889 t.Fatalf("goal should be cleared, got %q", got)
890 }
891 joined := strings.Join(notices, "\n")
892 for _, want := range []string{"goal set", "goal: finish the mode redesign", "goal cleared"} {
893 if !strings.Contains(joined, want) {
894 t.Fatalf("notices missing %q: %v", want, notices)
895 }
896 }
897 }
898
899 func TestParseGoalCommandWithStrict(t *testing.T) {
900 tests := []struct {
901 input string
902 text string
903 strict bool
904 ok bool
905 }{
906 {"/goal --strict implement calculator", "implement calculator", true, true},
907 {"/goal implement calculator", "implement calculator", false, true},
908 {"/goal --strict", "", true, true}, // --strict shows status
909 {"/goal --strict status", "", true, true}, // --strict shows status
910 }
911 for _, tt := range tests {
912 cmd, ok := ParseGoalCommand(tt.input)
913 if ok != tt.ok {
914 t.Errorf("ParseGoalCommand(%q) ok = %v, want %v", tt.input, ok, tt.ok)
915 continue
916 }
917 if !ok {
918 continue
919 }
920 if cmd.Text != tt.text {
921 t.Errorf("ParseGoalCommand(%q).Text = %q, want %q", tt.input, cmd.Text, tt.text)
922 }
923 if cmd.Strict != tt.strict {
924 t.Errorf("ParseGoalCommand(%q).Strict = %v, want %v", tt.input, cmd.Strict, tt.strict)
925 }
926 }
927 }
928
929 func TestParseGoalCommandStrictOnlyConsumesLeadingFlags(t *testing.T) {
930 structuredGoal := "implement parser\n\n keep spacing\nliteral --strict stays"
931 cmd, ok := ParseGoalCommand("/goal --strict " + structuredGoal)
932 if !ok {
933 t.Fatal("ParseGoalCommand returned ok=false")
934 }
935 if !cmd.Strict {
936 t.Fatal("leading --strict should enable strict mode")
937 }
938 if cmd.Text != structuredGoal {
939 t.Fatalf("goal text was rewritten:\nwant %q\ngot %q", structuredGoal, cmd.Text)
940 }
941
942 cmd, ok = ParseGoalCommand("/goal implement parser --strict literally")
943 if !ok {
944 t.Fatal("ParseGoalCommand with literal --strict returned ok=false")
945 }
946 if cmd.Strict {
947 t.Fatal("non-leading --strict should remain part of the goal text")
948 }
949 if want := "implement parser --strict literally"; cmd.Text != want {
950 t.Fatalf("goal text = %q, want %q", cmd.Text, want)
951 }
952 }
953
954 func TestQueueMemoryDoesNotCreateLegacyMemoryUpdate(t *testing.T) {
955 c := newOwnedTestController(t, Options{})
956
957 c.QueueMemory("Saved memory \"rmb\": user's balance is in RMB")
958 got := c.Compose("hello")
959 if got != "hello" {
960 t.Fatalf("background write must not create a legacy memory update: %q", got)
961 }
962 }
963
964 func TestMemoryQuickAddNoteRequiresWhitespace(t *testing.T) {
965 tests := []struct {
966 in string
967 note string
968 ok bool
969 }{
970 {in: "# remember this", note: "remember this", ok: true},
971 {in: " #\tremember this ", note: "remember this", ok: true},
972 {in: "#7 needs work", ok: false},
973 {in: "#issue needs work", ok: false},
974 {in: "# Heading", note: "Heading", ok: true},
975 {in: "#", ok: false},
976 // Multi-line input is NOT a quick-add — it's a Markdown heading (# Context)
977 // followed by structured content. Desktop users pasting COSTAR-style prompts
978 // hit this when the first line starts with "# ".
979 {in: "# Context\n\n- file.go\n", ok: false},
980 {in: "# Heading\nmore text", ok: false},
981 {in: " # Context\n - file.go ", ok: false},
982 }
983 for _, tt := range tests {
984 got, ok := MemoryQuickAddNote(tt.in)
985 if ok != tt.ok || got != tt.note {
986 t.Errorf("MemoryQuickAddNote(%q) = (%q,%v), want (%q,%v)", tt.in, got, ok, tt.note, tt.ok)
987 }
988 }
989 }
990
991 func TestRememberCommandNote(t *testing.T) {
992 tests := []struct {
993 in string
994 note string
995 ok bool
996 }{
997 {in: "/remember use tabs", note: "use tabs", ok: true},
998 {in: " /remember\tuse tabs ", note: "use tabs", ok: true},
999 {in: "/remember", ok: true},
1000 {in: "/remembering use tabs", ok: false},
1001 }
1002 for _, tt := range tests {
1003 got, ok := RememberCommandNote(tt.in)
1004 if ok != tt.ok || got != tt.note {
1005 t.Errorf("RememberCommandNote(%q) = (%q,%v), want (%q,%v)", tt.in, got, ok, tt.note, tt.ok)
1006 }
1007 }
1008 }
1009
1010 func TestSubmitHashNumberStartsTurn(t *testing.T) {
1011 runner := &fakeTurnRunner{}
1012 events := make(chan event.Event, 4)
1013 c := newOwnedTestController(t, Options{
1014 Runner: runner,
1015 Sink: event.FuncSink(func(e event.Event) {
1016 events <- e
1017 }),
1018 })
1019
1020 const input = "#7 needs work"
1021 c.Submit(input)
1022 waitForTurnDone(t, events)
1023
1024 if len(runner.inputs) != 1 || runner.inputs[0] != input {
1025 t.Fatalf("#number prompt should start a model turn, inputs=%q", runner.inputs)
1026 }
1027 }
1028
1029 func TestSubmitSlashPathDiagnosticStartsTurnWithFileContext(t *testing.T) {
1030 if runtime.GOOS == "windows" {
1031 t.Skip("POSIX absolute file path context is covered on POSIX runners")
1032 }
1033 dir := t.TempDir()
1034 file := filepath.Join(dir, "app", "src", "main", "Foo.kt")
1035 if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil {
1036 t.Fatal(err)
1037 }
1038 if err := os.WriteFile(file, []byte("fun broken() = missingSymbol\n"), 0o644); err != nil {
1039 t.Fatal(err)
1040 }
1041 runner := &fakeTurnRunner{}
1042 events := make(chan event.Event, 4)
1043 c := newOwnedTestController(t, Options{
1044 Runner: runner,
1045 Sink: event.FuncSink(func(e event.Event) {
1046 events <- e
1047 }),
1048 })
1049
1050 input := file + ":12:13: error: unresolved reference: missingSymbol"
1051 c.Submit(input)
1052 waitForTurnDone(t, events)
1053
1054 if len(runner.inputs) != 1 {
1055 t.Fatalf("slash path diagnostic should start a model turn, inputs=%q", runner.inputs)
1056 }
1057 got := runner.inputs[0]
1058 if !strings.Contains(got, "Referenced context:") || !strings.Contains(got, "fun broken() = missingSymbol") {
1059 t.Fatalf("slash path diagnostic should attach file context, got %q", got)
1060 }
1061 if !strings.Contains(got, input) {
1062 t.Fatalf("slash path diagnostic should preserve original error text, got %q", got)
1063 }
1064 }
1065
1066 func TestSubmitMissingSlashPathDiagnosticStartsTurn(t *testing.T) {
1067 runner := &fakeTurnRunner{}
1068 events := make(chan event.Event, 4)
1069 c := newOwnedTestController(t, Options{
1070 Runner: runner,
1071 Sink: event.FuncSink(func(e event.Event) {
1072 events <- e
1073 }),
1074 })
1075
1076 input := "/missing/Foo.kt:12: error: file no longer exists"
1077 c.Submit(input)
1078 waitForTurnDone(t, events)
1079
1080 if len(runner.inputs) != 1 || runner.inputs[0] != input {
1081 t.Fatalf("missing slash path diagnostic should start a raw model turn, inputs=%q", runner.inputs)
1082 }
1083 }
1084
1085 func TestSubmitBlockCommentPrefixStartsTurn(t *testing.T) {
1086 runner := &fakeTurnRunner{}
1087 events := make(chan event.Event, 4)
1088 c := newOwnedTestController(t, Options{
1089 Runner: runner,
1090 Sink: event.FuncSink(func(e event.Event) {
1091 events <- e
1092 }),
1093 })
1094
1095 input := "/**\n * 阿明\n */"
1096 c.Submit(input)
1097 waitForTurnDone(t, events)
1098
1099 if len(runner.inputs) != 1 || runner.inputs[0] != input {
1100 t.Fatalf("block comment prefix should start a model turn, inputs=%q", runner.inputs)
1101 }
1102 }
1103
1104 func TestSubmitUnknownSlashCommandStillReportsNotice(t *testing.T) {
1105 runner := &fakeTurnRunner{}
1106 events := make(chan event.Event, 4)
1107 c := newOwnedTestController(t, Options{
1108 Runner: runner,
1109 Sink: event.FuncSink(func(e event.Event) {
1110 events <- e
1111 }),
1112 })
1113
1114 c.Submit("/definitely-not-a-command")
1115
1116 // Unknown slash input is sent as a regular message (#5756); the notice
1117 // still fires so genuine typos stay visible.
1118 var noticeText string
1119 deadline := time.After(30 * time.Second)
1120 for noticeText == "" {
1121 select {
1122 case e := <-events:
1123 if e.Kind == event.Notice && strings.Contains(e.Text, "unknown command: /definitely-not-a-command") {
1124 noticeText = e.Text
1125 }
1126 case <-deadline:
1127 t.Fatal("timed out waiting for unknown-command notice")
1128 }
1129 }
1130 if !strings.Contains(noticeText, "sent as a regular message") {
1131 t.Fatalf("notice = %q, want the sent-as-message suffix", noticeText)
1132 }
1133 waitForTurnDone(t, events)
1134 if len(runner.inputs) != 1 || !strings.Contains(runner.inputs[0], "/definitely-not-a-command") {
1135 t.Fatalf("unknown slash command should start a model turn with the raw line, inputs=%q", runner.inputs)
1136 }
1137 }
1138
1139 func TestSubmitDocsShowsLocalOverviewAndGroundsModelTurn(t *testing.T) {
1140 runner := &fakeTurnRunner{}
1141 events := make(chan event.Event, 16)
1142 c := newOwnedTestController(t, Options{
1143 Runner: runner,
1144 Sink: event.FuncSink(func(e event.Event) {
1145 events <- e
1146 }),
1147 })
1148
1149 c.Submit("/docs")
1150 select {
1151 case e := <-events:
1152 if e.Kind != event.Notice || !strings.Contains(e.Text, "digest=sha256:") || !strings.Contains(e.Text, "/docs") {
1153 t.Fatalf("bare /docs event = %+v, want local corpus overview", e)
1154 }
1155 case <-time.After(30 * time.Second):
1156 t.Fatal("timed out waiting for /docs overview")
1157 }
1158 if len(runner.inputs) != 0 {
1159 t.Fatalf("bare /docs should not start a model turn, inputs=%q", runner.inputs)
1160 }
1161
1162 c.Submit("/docs 1.19.5 更新日志")
1163 waitForTurnDone(t, events)
1164 if len(runner.inputs) != 1 {
1165 t.Fatalf("/docs query model turns = %d, inputs=%q", len(runner.inputs), runner.inputs)
1166 }
1167 for _, want := range []string{"1.19.5 更新日志", "changelog/v1.19.5.zh-CN.md", "embedded_docs_search_results"} {
1168 if !strings.Contains(runner.inputs[0], want) {
1169 t.Fatalf("grounded /docs prompt missing %q:\n%s", want, runner.inputs[0])
1170 }
1171 }
1172 }
1173
1174 func TestSubmitDocsPreservesExistingCustomCommand(t *testing.T) {
1175 runner := &fakeTurnRunner{}
1176 events := make(chan event.Event, 8)
1177 c := newOwnedTestController(t, Options{
1178 Runner: runner,
1179 Commands: []command.Command{{Name: "docs", Body: "legacy docs workflow: $ARGUMENTS"}},
1180 Sink: event.FuncSink(func(e event.Event) {
1181 events <- e
1182 }),
1183 })
1184
1185 c.Submit("/docs release notes")
1186 waitForTurnDone(t, events)
1187 if len(runner.inputs) != 1 || !strings.Contains(runner.inputs[0], "legacy docs workflow: release notes") {
1188 t.Fatalf("existing /docs custom command was not preserved: %q", runner.inputs)
1189 }
1190 if strings.Contains(runner.inputs[0], "embedded_docs_search_results") {
1191 t.Fatalf("built-in /docs shadowed the existing custom command: %q", runner.inputs[0])
1192 }
1193 }
1194
1195 func TestSubmitQualifiedReasonixDocsPreservesExistingCommandAndUsesNextFallback(t *testing.T) {
1196 runner := &fakeTurnRunner{}
1197 events := make(chan event.Event, 8)
1198 c := newOwnedTestController(t, Options{
1199 Runner: runner,
1200 Commands: []command.Command{
1201 {Name: "docs", Body: "legacy docs workflow: $ARGUMENTS"},
1202 {Name: ReasonixDocsSlashName, Body: "must not shadow built-in docs: $ARGUMENTS"},
1203 },
1204 Sink: event.FuncSink(func(e event.Event) {
1205 events <- e
1206 }),
1207 })
1208
1209 c.Submit("/reasonix:docs existing workflow")
1210 waitForTurnDone(t, events)
1211 if len(runner.inputs) != 1 {
1212 t.Fatalf("qualified custom command model turns = %d, inputs=%q", len(runner.inputs), runner.inputs)
1213 }
1214 if !strings.Contains(runner.inputs[0], "must not shadow built-in docs: existing workflow") {
1215 t.Fatalf("existing qualified custom command was displaced: %q", runner.inputs[0])
1216 }
1217 waitIdle(t, c)
1218
1219 c.Submit("/reasonix:builtin:docs 1.19.5 update notes")
1220 waitForTurnDone(t, events)
1221 if len(runner.inputs) != 2 {
1222 t.Fatalf("generated docs fallback model turns = %d, inputs=%q", len(runner.inputs), runner.inputs)
1223 }
1224 for _, want := range []string{"1.19.5 update notes", "changelog/v1.19.5.md", "embedded_docs_search_results"} {
1225 if !strings.Contains(runner.inputs[1], want) {
1226 t.Fatalf("qualified docs prompt missing %q:\n%s", want, runner.inputs[1])
1227 }
1228 }
1229 if strings.Contains(runner.inputs[1], "must not shadow built-in docs") || strings.Contains(runner.inputs[1], "legacy docs workflow") {
1230 t.Fatalf("qualified built-in docs was shadowed: %q", runner.inputs[1])
1231 }
1232 }
1233
1234 func TestSubmitUserTurnBypassesCommandDispatch(t *testing.T) {
1235 runner := &fakeTurnRunner{}
1236 events := make(chan event.Event, 4)
1237 c := newOwnedTestController(t, Options{
1238 Runner: runner,
1239 Sink: event.FuncSink(func(e event.Event) {
1240 events <- e
1241 }),
1242 })
1243
1244 for _, input := range []string{"!echo should stay a prompt", "/clear"} {
1245 c.SubmitUserTurn(input, input)
1246 waitForTurnDone(t, events)
1247 // The next SubmitUserTurn must wait out the finishing window or it is
1248 // silently dropped by runGuarded — see waitIdle.
1249 waitIdle(t, c)
1250 }
1251
1252 if len(runner.inputs) != 2 {
1253 t.Fatalf("SubmitUserTurn should start model turns, inputs=%q", runner.inputs)
1254 }
1255 if runner.inputs[0] != "!echo should stay a prompt" || runner.inputs[1] != "/clear" {
1256 t.Fatalf("SubmitUserTurn inputs = %q", runner.inputs)
1257 }
1258 }
1259
1260 func TestSubmitRememberCommandQuickAddsMemory(t *testing.T) {
1261 dir := t.TempDir()
1262 runner := &fakeTurnRunner{}
1263 c := newOwnedTestController(t, Options{
1264 Runner: runner,
1265 Memory: memory.Load(memory.Options{CWD: dir}),
1266 })
1267
1268 c.Submit("/remember use tabs")
1269
1270 if len(runner.inputs) != 0 {
1271 t.Fatalf("/remember should not start a model turn, inputs=%q", runner.inputs)
1272 }
1273 body, err := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
1274 if err != nil {
1275 t.Fatal(err)
1276 }
1277 if !strings.Contains(string(body), "- use tabs") {
1278 t.Fatalf("memory file missing note:\n%s", body)
1279 }
1280 }
1281
1282 // waitIdle blocks on the controller-owned boundary until the turn-admission
1283 // gate reopens after both execution and TurnDone fan-out.
1284 func waitIdle(t *testing.T, c *Controller) {
1285 t.Helper()
1286 done, running := c.TurnIdleDone()
1287 if !running {
1288 return
1289 }
1290 select {
1291 case <-done:
1292 case <-time.After(30 * time.Second):
1293 t.Fatalf("timed out waiting for the controller to return to idle: %+v", c.RuntimeStatus())
1294 }
1295 }
1296
1297 func waitForTurnDone(t *testing.T, events <-chan event.Event) {
1298 t.Helper()
1299 deadline := time.After(30 * time.Second)
1300 for {
1301 select {
1302 case e := <-events:
1303 if e.Kind == event.TurnDone {
1304 if e.Err != nil {
1305 t.Fatalf("turn finished with error: %v", e.Err)
1306 }
1307 return
1308 }
1309 case <-deadline:
1310 t.Fatal("timed out waiting for turn_done")
1311 }
1312 }
1313 }
1314
1315 func TestStripComposePrefixes(t *testing.T) {
1316 tests := []struct {
1317 name string
1318 input string
1319 want string
1320 }{
1321 {
1322 name: "plain user message unchanged",
1323 input: "explain this function",
1324 want: "explain this function",
1325 },
1326 {
1327 name: "plan mode marker stripped",
1328 input: PlanModeMarker + "\n\nexplain this function",
1329 want: "explain this function",
1330 },
1331 {
1332 name: "legacy plan mode marker stripped",
1333 input: legacyPlanModeMarker + "\n\nexplain this function",
1334 want: "explain this function",
1335 },
1336 {
1337 name: "plan mode marker without trailing newlines",
1338 input: PlanModeMarker,
1339 want: "",
1340 },
1341 {
1342 name: "memory update block stripped",
1343 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",
1344 want: "explain this",
1345 },
1346 {
1347 name: "background jobs block stripped",
1348 input: "<background-jobs>\n1 completed\n</background-jobs>\n\nexplain this",
1349 want: "explain this",
1350 },
1351 {
1352 name: "hook context block stripped",
1353 input: "<hook-context event=\"SessionStart\">\nLoad conventions.\n</hook-context>\n\nexplain this",
1354 want: "explain this",
1355 },
1356 {
1357 name: "memory and plan marker both stripped",
1358 input: "<memory-update>\n- note\n</memory-update>\n\n" + PlanModeMarker + "\n\nexplain this",
1359 want: "explain this",
1360 },
1361 {
1362 name: "empty after stripping",
1363 input: PlanModeMarker + "\n\n",
1364 want: "",
1365 },
1366 {
1367 name: "memory update only no user text",
1368 input: "<memory-update>\n- note\n</memory-update>\n\n",
1369 want: "",
1370 },
1371 }
1372 for _, tt := range tests {
1373 t.Run(tt.name, func(t *testing.T) {
1374 got := StripComposePrefixes(tt.input)
1375 if got != tt.want {
1376 t.Errorf("StripComposePrefixes() = %q, want %q", got, tt.want)
1377 }
1378 })
1379 }
1380 }
1381
1382 func TestStripReferencedContextPrefix(t *testing.T) {
1383 tests := []struct {
1384 name string
1385 input string
1386 want string
1387 }{
1388 {
1389 name: "plain user message unchanged",
1390 input: "explain this function",
1391 want: "explain this function",
1392 },
1393 {
1394 name: "file reference stripped",
1395 input: "Referenced context:\n\n<file path=\"main.go\">\nfunc main() {}\n</file>\n\nexplain this function",
1396 want: "explain this function",
1397 },
1398 {
1399 name: "multiple file references stripped",
1400 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",
1401 want: "compare these files",
1402 },
1403 {
1404 name: "dir reference stripped",
1405 input: "Referenced context:\n\n<dir path=\"src\">\nmain.go\nutil.go\n</dir>\n\nlist the files",
1406 want: "list the files",
1407 },
1408 {
1409 name: "resource reference stripped",
1410 input: "Referenced context:\n\n<resource ref=\"@server/res\">\ndata\n</resource>\n\nanalyze this",
1411 want: "analyze this",
1412 },
1413 {
1414 name: "image reference stripped",
1415 input: "Referenced context:\n\n<image path=\"screenshot.png\">\n[image attachment available at @screenshot.png]\n</image>\n\nwhat is in this image",
1416 want: "what is in this image",
1417 },
1418 {
1419 name: "only reference no user text",
1420 input: "Referenced context:\n\n<file path=\"main.go\">\nfunc main() {}\n</file>\n\n",
1421 want: "",
1422 },
1423 {
1424 name: "empty input",
1425 input: "",
1426 want: "",
1427 },
1428 }
1429 for _, tt := range tests {
1430 t.Run(tt.name, func(t *testing.T) {
1431 got := StripReferencedContextPrefix(tt.input)
1432 if got != tt.want {
1433 t.Errorf("StripReferencedContextPrefix() = %q, want %q", got, tt.want)
1434 }
1435 })
1436 }
1437 }
1438
1439 func TestIsSyntheticUserMessage(t *testing.T) {
1440 tests := []struct {
1441 name string
1442 input string
1443 want bool
1444 }{
1445 {
1446 name: "plan approved message",
1447 input: planApprovedMessage,
1448 want: true,
1449 },
1450 {
1451 name: "plan approved message with reasoning language",
1452 input: reasoningLanguageBlock("zh") + "\n\n" + planApprovedMessage,
1453 want: true,
1454 },
1455 {
1456 name: "stream recovery interrupted tool",
1457 input: "The previous assistant response was interrupted while a tool call was streaming. Continue the same task now.",
1458 want: true,
1459 },
1460 {
1461 name: "stream recovery interrupted text",
1462 input: "The previous assistant response was interrupted during streaming. Continue the same task from immediately after the partial assistant message above.",
1463 want: true,
1464 },
1465 {
1466 name: "empty final retry",
1467 input: "The previous assistant response finished without any visible answer text. Continue the same task now and provide a concise visible answer.",
1468 want: true,
1469 },
1470 {
1471 name: "readiness retry",
1472 input: "Host final-answer readiness check failed. Before giving a final answer, address the missing host-observable receipts: missing evidence.",
1473 want: true,
1474 },
1475 {
1476 name: "executor handoff",
1477 input: "You are already in the executor phase. The planner's read-only limitations do not apply to you.",
1478 want: true,
1479 },
1480 {
1481 name: "regular user message",
1482 input: "explain this function",
1483 want: false,
1484 },
1485 {
1486 name: "plan mode marker in message",
1487 input: PlanModeMarker + "\n\nexplain this",
1488 want: false,
1489 },
1490 {
1491 name: "stream recovery interrupted before visible",
1492 input: "The previous assistant response was interrupted during streaming before visible answer text was completed. Continue the same task now.",
1493 want: true,
1494 },
1495 {
1496 name: "user quoting interrupted response not synthetic",
1497 input: "The previous assistant response was interrupted by my VPN, can you retry?",
1498 want: false,
1499 },
1500 {
1501 name: "compaction fold summary",
1502 input: "<compaction-summary>\nSummary of earlier conversation (older messages were compacted to save context):\nDid things with tools.\n</compaction-summary>",
1503 want: true,
1504 },
1505 {
1506 name: "summarize-from fold",
1507 input: "Summary of the later conversation (compacted from here on):\nDid more things.",
1508 want: true,
1509 },
1510 {
1511 name: "summarize-upto fold",
1512 input: "Summary of earlier conversation (compacted up to here):\nDid earlier things.",
1513 want: true,
1514 },
1515 {
1516 name: "user mentioning a summary is not synthetic",
1517 input: "Summary of what I want: fix the login bug first.",
1518 want: false,
1519 },
1520 {
1521 name: "mid-turn steer is not synthetic (handled separately in historyMessages)",
1522 input: agent.MidTurnSteerPrefix + "\nplease use smaller diffs",
1523 want: false,
1524 },
1525 }
1526 for _, tt := range tests {
1527 t.Run(tt.name, func(t *testing.T) {
1528 got := IsSyntheticUserMessage(tt.input)
1529 if got != tt.want {
1530 t.Errorf("IsSyntheticUserMessage() = %v, want %v", got, tt.want)
1531 }
1532 })
1533 }
1534 }
1535
1535 lines GO