返回 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 := New(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 := New(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 := New(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 || msgs[1].Role != provider.RoleUser || 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 := New(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) != 4 || msgs[1].Role != provider.RoleUser || msgs[2].Content != "first answer" || msgs[3].Content != "second answer" {
219 t.Fatalf("parent history = %+v", msgs)
220 }
221 }
222
223 func TestSubmitInvocationDisplayPreparesPluginSubagentBindings(t *testing.T) {
224 home := t.TempDir()
225 pluginRoot := t.TempDir()
226 writeControlSkill(t, pluginRoot, "helper/SKILL.md", "---\ndescription: Plugin helper\nrunAs: subagent\n---\nCall search.")
227 store := skill.New(skill.Options{
228 HomeDir: home, CustomPaths: []string{pluginRoot},
229 PluginPaths: map[string][]string{pluginRoot: {"search-plugin"}}, DisableBuiltins: true,
230 })
231 store.ConfigureToolBindings(func(skill.Skill) []tool.MCPBinding {
232 return []tool.MCPBinding{{
233 Package: "search-plugin", Server: "search", RawName: "search",
234 VisibleName: "search", CallableName: "mcp__search__search", CapabilityID: "mcp-tool:search/search",
235 }}
236 })
237
238 sess := agent.NewSession("parent system")
239 exec := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
240 events := make(chan event.Event, 12)
241 var got skill.Skill
242 c := New(Options{
243 Executor: exec, SkillStore: store, Skills: store.List(),
244 Sink: event.FuncSink(func(e event.Event) { events <- e }),
245 SkillRunner: func(_ context.Context, sk skill.Skill, _ string, _ skill.SubagentRunOptions) (string, error) {
246 got = sk
247 return "done", nil
248 },
249 })
250 defer c.Close()
251
252 c.SubmitInvocationDisplay("inspect", "inspect", []InvocationRequest{{Name: "search-plugin:helper", Kind: "subagent"}})
253 waitForTurnEvents(t, events)
254 waitIdle(t, c)
255 if !strings.Contains(got.Body, "## Runtime MCP tool bindings") || !strings.Contains(got.Body, "`mcp__search__search`") {
256 t.Fatalf("structured plugin subagent was not prepared: %q", got.Body)
257 }
258 }
259
260 func TestRunSubagentProfilePreparesPluginBindings(t *testing.T) {
261 home := t.TempDir()
262 pluginRoot := t.TempDir()
263 writeControlSkill(t, pluginRoot, "helper/SKILL.md", "---\ndescription: Plugin helper\nrunAs: subagent\n---\nCall search.")
264 store := skill.New(skill.Options{
265 HomeDir: home, CustomPaths: []string{pluginRoot},
266 PluginPaths: map[string][]string{pluginRoot: {"search-plugin"}}, DisableBuiltins: true,
267 })
268 store.ConfigureToolBindings(func(skill.Skill) []tool.MCPBinding {
269 return []tool.MCPBinding{{
270 Package: "search-plugin", Server: "search", RawName: "search",
271 VisibleName: "search", CallableName: "mcp__search__search", CapabilityID: "mcp-tool:search/search",
272 }}
273 })
274
275 var got skill.Skill
276 c := New(Options{
277 SkillStore: store, Skills: store.List(),
278 SkillRunner: func(_ context.Context, sk skill.Skill, _ string, _ skill.SubagentRunOptions) (string, error) {
279 got = sk
280 return "done", nil
281 },
282 })
283 defer c.Close()
284
285 answer, err := c.RunSubagentProfile(context.Background(), "search-plugin:helper", "inspect", false)
286 if err != nil || answer != "done" {
287 t.Fatalf("RunSubagentProfile() = %q, %v", answer, err)
288 }
289 if !strings.Contains(got.Body, "## Runtime MCP tool bindings") || !strings.Contains(got.Body, "`mcp__search__search`") {
290 t.Fatalf("headless plugin subagent was not prepared: %q", got.Body)
291 }
292 }
293
294 func TestSubmitInvocationDisplayRunsInlineSkillWithoutArguments(t *testing.T) {
295 runner := &fakeTurnRunner{}
296 c := New(Options{
297 Runner: runner,
298 Skills: []skill.Skill{{Name: "init", Body: "INITIALIZE_PROJECT", RunAs: skill.RunInline, Scope: skill.ScopeGlobal}},
299 })
300 c.SubmitInvocationDisplay("", "", []InvocationRequest{{Name: "init", Kind: "skill", Offset: 0}})
301 waitIdle(t, c)
302 if len(runner.inputs) != 1 || !strings.Contains(runner.inputs[0], "INITIALIZE_PROJECT") {
303 t.Fatalf("inline-only structured input = %q", runner.inputs)
304 }
305 }
306
307 func TestSubmitInvocationDisplayRunsInlineSkillInsideActiveGoal(t *testing.T) {
308 prov := &scriptedTurns{turns: [][]provider.Chunk{
309 textTurn("Notes listed.\n\n[goal:complete]"),
310 }}
311 ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
312 events := make(chan event.Event, 8)
313 c := New(Options{
314 Runner: ag,
315 Executor: ag,
316 Sink: event.FuncSink(func(e event.Event) {
317 if e.Kind == event.TurnDone || e.Kind == event.Notice {
318 events <- e
319 }
320 }),
321 Skills: []skill.Skill{{Name: "notes", Body: "INSPECT_NOTES", RunAs: skill.RunInline, Scope: skill.ScopeGlobal}},
322 })
323 defer c.Close()
324 c.SetGoalWithResearchMode("list the existing notes", GoalResearchOff)
325 c.SubmitInvocationDisplay(
326 "list the existing notes",
327 "list the existing notes",
328 []InvocationRequest{{Name: "notes", Kind: "skill", Offset: 0}},
329 )
330 waitForTurnDone(t, events)
331
332 if prov.call != 1 {
333 t.Fatalf("active Goal structured turns = %d, want 1", prov.call)
334 }
335 input := firstUserMessage(ag.Session().Messages)
336 for _, want := range []string{"<active-goal>\nlist the existing notes", "INSPECT_NOTES", "list the existing notes"} {
337 if !strings.Contains(input, want) {
338 t.Fatalf("active Goal structured input missing %q: %q", want, input)
339 }
340 }
341 }
342
343 func TestSubmitInvocationDisplayRunsSubagentSkillInsideActiveGoal(t *testing.T) {
344 sess := agent.NewSession("")
345 exec := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
346 events := make(chan event.Event, 8)
347 var gotTask string
348 c := New(Options{
349 Executor: 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 := New(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 := New(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 := New(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 := New(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 len(gotEvents) != 1 || gotEvents[0].Kind != event.TurnDone || gotEvents[0].Err == nil ||
495 !strings.Contains(gotEvents[0].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 := New(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 || msgs[1].Role != provider.RoleUser {
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 := New(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 := New(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", "not a permission boundary", "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 := New(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 := New(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 := New(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 := New(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 := New(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 := New(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 := New(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 := New(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 := New(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 := New(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 := New(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 := New(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 := New(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 := New(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 := New(Options{WorkspaceRoot: root})
844 c.SetGoal("持续排查这个线上卡顿直到根因明确,并验证修复")
845
846 got := c.Compose("next step?")
847 for _, want := range []string{
848 "AutoResearch protocol",
849 "<autoresearch-runtime>",
850 "task_id:",
851 "pivot_required:",
852 "stale_count >= 2",
853 "durable strategy for this Goal",
854 } {
855 if !strings.Contains(got, want) {
856 t.Fatalf("AutoResearch goal block missing %q:\n%s", want, got)
857 }
858 }
859 }
860
861 func TestGoalAutoResearchCanBeForcedOrDisabled(t *testing.T) {
862 c := New(Options{})
863 c.SetGoalWithResearchMode("fix the typo and add a test", GoalResearchOn)
864 if got := c.Compose("start"); !strings.Contains(got, "AutoResearch protocol") {
865 t.Fatalf("forced research goal should include AutoResearch protocol:\n%s", got)
866 }
867
868 c.SetGoalWithResearchMode("持续排查这个线上卡顿直到根因明确", GoalResearchOff)
869 if got := c.Compose("start"); strings.Contains(got, "AutoResearch protocol") {
870 t.Fatalf("simple override should suppress AutoResearch protocol:\n%s", got)
871 }
872 }
873
874 func TestGoalCommandPreservesResearchModeFlags(t *testing.T) {
875 c := New(Options{})
876 if !c.applyGoalCommand("/goal --research fix the typo", "") {
877 t.Fatal("goal command was not parsed")
878 }
879 if got := c.Compose("start"); !strings.Contains(got, "AutoResearch protocol") {
880 t.Fatalf("/goal --research should force AutoResearch through command dispatch:\n%s", got)
881 }
882
883 c = New(Options{})
884 if !c.applyGoalCommand("/goal --simple 持续排查这个线上卡顿直到根因明确", "") {
885 t.Fatal("goal command was not parsed")
886 }
887 if got := c.Compose("start"); strings.Contains(got, "AutoResearch protocol") {
888 t.Fatalf("/goal --simple should suppress AutoResearch through command dispatch:\n%s", got)
889 }
890 }
891
892 func TestParseGoalCommandResearchFlags(t *testing.T) {
893 cmd, ok := ParseGoalCommand("/goal --research fix the typo")
894 if !ok || cmd.Action != GoalCommandSet || cmd.Text != "fix the typo" || cmd.ResearchMode != GoalResearchOn {
895 t.Fatalf("ParseGoalCommand --research = %+v ok=%v", cmd, ok)
896 }
897
898 cmd, ok = ParseGoalCommand("/goal --simple 持续排查直到根因明确")
899 if !ok || cmd.Action != GoalCommandSet || cmd.Text != "持续排查直到根因明确" || cmd.ResearchMode != GoalResearchOff {
900 t.Fatalf("ParseGoalCommand --simple = %+v ok=%v", cmd, ok)
901 }
902 }
903
904 func TestGoalCommandSetsReportsAndClears(t *testing.T) {
905 var notices []string
906 c := New(Options{Sink: event.FuncSink(func(e event.Event) {
907 if e.Kind == event.Notice {
908 notices = append(notices, e.Text)
909 }
910 })})
911 c.SetPlanMode(true)
912
913 c.Submit("/goal finish the mode redesign")
914 if got := c.Goal(); got != "finish the mode redesign" {
915 t.Fatalf("Goal() = %q", got)
916 }
917 if c.PlanMode() {
918 t.Fatal("/goal should leave plan mode")
919 }
920 c.Submit("/goal")
921 c.Submit("/goal clear")
922 if got := c.Goal(); got != "" {
923 t.Fatalf("goal should be cleared, got %q", got)
924 }
925 joined := strings.Join(notices, "\n")
926 for _, want := range []string{"goal set", "goal: finish the mode redesign", "goal cleared"} {
927 if !strings.Contains(joined, want) {
928 t.Fatalf("notices missing %q: %v", want, notices)
929 }
930 }
931 }
932
933 func TestParseGoalCommandWithStrict(t *testing.T) {
934 tests := []struct {
935 input string
936 text string
937 strict bool
938 ok bool
939 }{
940 {"/goal --strict implement calculator", "implement calculator", true, true},
941 {"/goal implement calculator", "implement calculator", false, true},
942 {"/goal --strict", "", true, true}, // --strict shows status
943 {"/goal --strict status", "", true, true}, // --strict shows status
944 }
945 for _, tt := range tests {
946 cmd, ok := ParseGoalCommand(tt.input)
947 if ok != tt.ok {
948 t.Errorf("ParseGoalCommand(%q) ok = %v, want %v", tt.input, ok, tt.ok)
949 continue
950 }
951 if !ok {
952 continue
953 }
954 if cmd.Text != tt.text {
955 t.Errorf("ParseGoalCommand(%q).Text = %q, want %q", tt.input, cmd.Text, tt.text)
956 }
957 if cmd.Strict != tt.strict {
958 t.Errorf("ParseGoalCommand(%q).Strict = %v, want %v", tt.input, cmd.Strict, tt.strict)
959 }
960 }
961 }
962
963 func TestParseGoalCommandStrictOnlyConsumesLeadingFlags(t *testing.T) {
964 structuredGoal := "implement parser\n\n keep spacing\nliteral --strict stays"
965 cmd, ok := ParseGoalCommand("/goal --strict " + structuredGoal)
966 if !ok {
967 t.Fatal("ParseGoalCommand returned ok=false")
968 }
969 if !cmd.Strict {
970 t.Fatal("leading --strict should enable strict mode")
971 }
972 if cmd.Text != structuredGoal {
973 t.Fatalf("goal text was rewritten:\nwant %q\ngot %q", structuredGoal, cmd.Text)
974 }
975
976 cmd, ok = ParseGoalCommand("/goal implement parser --strict literally")
977 if !ok {
978 t.Fatal("ParseGoalCommand with literal --strict returned ok=false")
979 }
980 if cmd.Strict {
981 t.Fatal("non-leading --strict should remain part of the goal text")
982 }
983 if want := "implement parser --strict literally"; cmd.Text != want {
984 t.Fatalf("goal text = %q, want %q", cmd.Text, want)
985 }
986 }
987
988 func TestComposeDrainsQueuedMemory(t *testing.T) {
989 c := New(Options{}) // no executor/memory — QueueMemory still queues a turn-tail note
990
991 c.QueueMemory("Saved memory \"rmb\": user's balance is in RMB")
992 got := c.Compose("hello")
993 if !strings.Contains(got, "<memory-update>") || !strings.Contains(got, "user's balance is in RMB") {
994 t.Fatalf("queued memory should ride the turn: %q", got)
995 }
996 if !strings.HasSuffix(got, "hello") {
997 t.Fatalf("user text should follow the memory block: %q", got)
998 }
999 if got2 := c.Compose("again"); got2 != "again" {
1000 t.Fatalf("pendingMemory should drain after one turn, got %q", got2)
1001 }
1002 }
1003
1004 func TestMemoryQuickAddNoteRequiresWhitespace(t *testing.T) {
1005 tests := []struct {
1006 in string
1007 note string
1008 ok bool
1009 }{
1010 {in: "# remember this", note: "remember this", ok: true},
1011 {in: " #\tremember this ", note: "remember this", ok: true},
1012 {in: "#7 needs work", ok: false},
1013 {in: "#issue needs work", ok: false},
1014 {in: "# Heading", note: "Heading", ok: true},
1015 {in: "#", ok: false},
1016 // Multi-line input is NOT a quick-add — it's a Markdown heading (# Context)
1017 // followed by structured content. Desktop users pasting COSTAR-style prompts
1018 // hit this when the first line starts with "# ".
1019 {in: "# Context\n\n- file.go\n", ok: false},
1020 {in: "# Heading\nmore text", ok: false},
1021 {in: " # Context\n - file.go ", ok: false},
1022 }
1023 for _, tt := range tests {
1024 got, ok := MemoryQuickAddNote(tt.in)
1025 if ok != tt.ok || got != tt.note {
1026 t.Errorf("MemoryQuickAddNote(%q) = (%q,%v), want (%q,%v)", tt.in, got, ok, tt.note, tt.ok)
1027 }
1028 }
1029 }
1030
1031 func TestRememberCommandNote(t *testing.T) {
1032 tests := []struct {
1033 in string
1034 note string
1035 ok bool
1036 }{
1037 {in: "/remember use tabs", note: "use tabs", ok: true},
1038 {in: " /remember\tuse tabs ", note: "use tabs", ok: true},
1039 {in: "/remember", ok: true},
1040 {in: "/remembering use tabs", ok: false},
1041 }
1042 for _, tt := range tests {
1043 got, ok := RememberCommandNote(tt.in)
1044 if ok != tt.ok || got != tt.note {
1045 t.Errorf("RememberCommandNote(%q) = (%q,%v), want (%q,%v)", tt.in, got, ok, tt.note, tt.ok)
1046 }
1047 }
1048 }
1049
1050 func TestSubmitHashNumberStartsTurn(t *testing.T) {
1051 runner := &fakeTurnRunner{}
1052 events := make(chan event.Event, 4)
1053 c := New(Options{
1054 Runner: runner,
1055 Sink: event.FuncSink(func(e event.Event) {
1056 events <- e
1057 }),
1058 })
1059
1060 const input = "#7 needs work"
1061 c.Submit(input)
1062 waitForTurnDone(t, events)
1063
1064 if len(runner.inputs) != 1 || runner.inputs[0] != input {
1065 t.Fatalf("#number prompt should start a model turn, inputs=%q", runner.inputs)
1066 }
1067 }
1068
1069 func TestSubmitSlashPathDiagnosticStartsTurnWithFileContext(t *testing.T) {
1070 if runtime.GOOS == "windows" {
1071 t.Skip("POSIX absolute file path context is covered on POSIX runners")
1072 }
1073 dir := t.TempDir()
1074 file := filepath.Join(dir, "app", "src", "main", "Foo.kt")
1075 if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil {
1076 t.Fatal(err)
1077 }
1078 if err := os.WriteFile(file, []byte("fun broken() = missingSymbol\n"), 0o644); err != nil {
1079 t.Fatal(err)
1080 }
1081 runner := &fakeTurnRunner{}
1082 events := make(chan event.Event, 4)
1083 c := New(Options{
1084 Runner: runner,
1085 Sink: event.FuncSink(func(e event.Event) {
1086 events <- e
1087 }),
1088 })
1089
1090 input := file + ":12:13: error: unresolved reference: missingSymbol"
1091 c.Submit(input)
1092 waitForTurnDone(t, events)
1093
1094 if len(runner.inputs) != 1 {
1095 t.Fatalf("slash path diagnostic should start a model turn, inputs=%q", runner.inputs)
1096 }
1097 got := runner.inputs[0]
1098 if !strings.Contains(got, "Referenced context:") || !strings.Contains(got, "fun broken() = missingSymbol") {
1099 t.Fatalf("slash path diagnostic should attach file context, got %q", got)
1100 }
1101 if !strings.Contains(got, input) {
1102 t.Fatalf("slash path diagnostic should preserve original error text, got %q", got)
1103 }
1104 }
1105
1106 func TestSubmitMissingSlashPathDiagnosticStartsTurn(t *testing.T) {
1107 runner := &fakeTurnRunner{}
1108 events := make(chan event.Event, 4)
1109 c := New(Options{
1110 Runner: runner,
1111 Sink: event.FuncSink(func(e event.Event) {
1112 events <- e
1113 }),
1114 })
1115
1116 input := "/missing/Foo.kt:12: error: file no longer exists"
1117 c.Submit(input)
1118 waitForTurnDone(t, events)
1119
1120 if len(runner.inputs) != 1 || runner.inputs[0] != input {
1121 t.Fatalf("missing slash path diagnostic should start a raw model turn, inputs=%q", runner.inputs)
1122 }
1123 }
1124
1125 func TestSubmitBlockCommentPrefixStartsTurn(t *testing.T) {
1126 runner := &fakeTurnRunner{}
1127 events := make(chan event.Event, 4)
1128 c := New(Options{
1129 Runner: runner,
1130 Sink: event.FuncSink(func(e event.Event) {
1131 events <- e
1132 }),
1133 })
1134
1135 input := "/**\n * 阿明\n */"
1136 c.Submit(input)
1137 waitForTurnDone(t, events)
1138
1139 if len(runner.inputs) != 1 || runner.inputs[0] != input {
1140 t.Fatalf("block comment prefix should start a model turn, inputs=%q", runner.inputs)
1141 }
1142 }
1143
1144 func TestSubmitUnknownSlashCommandStillReportsNotice(t *testing.T) {
1145 runner := &fakeTurnRunner{}
1146 events := make(chan event.Event, 4)
1147 c := New(Options{
1148 Runner: runner,
1149 Sink: event.FuncSink(func(e event.Event) {
1150 events <- e
1151 }),
1152 })
1153
1154 c.Submit("/definitely-not-a-command")
1155
1156 // Unknown slash input is sent as a regular message (#5756); the notice
1157 // still fires so genuine typos stay visible.
1158 var noticeText string
1159 deadline := time.After(30 * time.Second)
1160 for noticeText == "" {
1161 select {
1162 case e := <-events:
1163 if e.Kind == event.Notice && strings.Contains(e.Text, "unknown command: /definitely-not-a-command") {
1164 noticeText = e.Text
1165 }
1166 case <-deadline:
1167 t.Fatal("timed out waiting for unknown-command notice")
1168 }
1169 }
1170 if !strings.Contains(noticeText, "sent as a regular message") {
1171 t.Fatalf("notice = %q, want the sent-as-message suffix", noticeText)
1172 }
1173 waitForTurnDone(t, events)
1174 if len(runner.inputs) != 1 || !strings.Contains(runner.inputs[0], "/definitely-not-a-command") {
1175 t.Fatalf("unknown slash command should start a model turn with the raw line, inputs=%q", runner.inputs)
1176 }
1177 }
1178
1179 func TestSubmitDocsShowsLocalOverviewAndGroundsModelTurn(t *testing.T) {
1180 runner := &fakeTurnRunner{}
1181 events := make(chan event.Event, 16)
1182 c := New(Options{
1183 Runner: runner,
1184 Sink: event.FuncSink(func(e event.Event) {
1185 events <- e
1186 }),
1187 })
1188
1189 c.Submit("/docs")
1190 select {
1191 case e := <-events:
1192 if e.Kind != event.Notice || !strings.Contains(e.Text, "digest=sha256:") || !strings.Contains(e.Text, "/docs") {
1193 t.Fatalf("bare /docs event = %+v, want local corpus overview", e)
1194 }
1195 case <-time.After(30 * time.Second):
1196 t.Fatal("timed out waiting for /docs overview")
1197 }
1198 if len(runner.inputs) != 0 {
1199 t.Fatalf("bare /docs should not start a model turn, inputs=%q", runner.inputs)
1200 }
1201
1202 c.Submit("/docs 1.19.5 更新日志")
1203 waitForTurnDone(t, events)
1204 if len(runner.inputs) != 1 {
1205 t.Fatalf("/docs query model turns = %d, inputs=%q", len(runner.inputs), runner.inputs)
1206 }
1207 for _, want := range []string{"1.19.5 更新日志", "changelog/v1.19.5.zh-CN.md", "embedded_docs_search_results"} {
1208 if !strings.Contains(runner.inputs[0], want) {
1209 t.Fatalf("grounded /docs prompt missing %q:\n%s", want, runner.inputs[0])
1210 }
1211 }
1212 }
1213
1214 func TestSubmitDocsPreservesExistingCustomCommand(t *testing.T) {
1215 runner := &fakeTurnRunner{}
1216 events := make(chan event.Event, 8)
1217 c := New(Options{
1218 Runner: runner,
1219 Commands: []command.Command{{Name: "docs", Body: "legacy docs workflow: $ARGUMENTS"}},
1220 Sink: event.FuncSink(func(e event.Event) {
1221 events <- e
1222 }),
1223 })
1224
1225 c.Submit("/docs release notes")
1226 waitForTurnDone(t, events)
1227 if len(runner.inputs) != 1 || !strings.Contains(runner.inputs[0], "legacy docs workflow: release notes") {
1228 t.Fatalf("existing /docs custom command was not preserved: %q", runner.inputs)
1229 }
1230 if strings.Contains(runner.inputs[0], "embedded_docs_search_results") {
1231 t.Fatalf("built-in /docs shadowed the existing custom command: %q", runner.inputs[0])
1232 }
1233 }
1234
1235 func TestSubmitQualifiedReasonixDocsPreservesExistingCommandAndUsesNextFallback(t *testing.T) {
1236 runner := &fakeTurnRunner{}
1237 events := make(chan event.Event, 8)
1238 c := New(Options{
1239 Runner: runner,
1240 Commands: []command.Command{
1241 {Name: "docs", Body: "legacy docs workflow: $ARGUMENTS"},
1242 {Name: ReasonixDocsSlashName, Body: "must not shadow built-in docs: $ARGUMENTS"},
1243 },
1244 Sink: event.FuncSink(func(e event.Event) {
1245 events <- e
1246 }),
1247 })
1248
1249 c.Submit("/reasonix:docs existing workflow")
1250 waitForTurnDone(t, events)
1251 if len(runner.inputs) != 1 {
1252 t.Fatalf("qualified custom command model turns = %d, inputs=%q", len(runner.inputs), runner.inputs)
1253 }
1254 if !strings.Contains(runner.inputs[0], "must not shadow built-in docs: existing workflow") {
1255 t.Fatalf("existing qualified custom command was displaced: %q", runner.inputs[0])
1256 }
1257 waitIdle(t, c)
1258
1259 c.Submit("/reasonix:builtin:docs 1.19.5 update notes")
1260 waitForTurnDone(t, events)
1261 if len(runner.inputs) != 2 {
1262 t.Fatalf("generated docs fallback model turns = %d, inputs=%q", len(runner.inputs), runner.inputs)
1263 }
1264 for _, want := range []string{"1.19.5 update notes", "changelog/v1.19.5.md", "embedded_docs_search_results"} {
1265 if !strings.Contains(runner.inputs[1], want) {
1266 t.Fatalf("qualified docs prompt missing %q:\n%s", want, runner.inputs[1])
1267 }
1268 }
1269 if strings.Contains(runner.inputs[1], "must not shadow built-in docs") || strings.Contains(runner.inputs[1], "legacy docs workflow") {
1270 t.Fatalf("qualified built-in docs was shadowed: %q", runner.inputs[1])
1271 }
1272 }
1273
1274 func TestSubmitUserTurnBypassesCommandDispatch(t *testing.T) {
1275 runner := &fakeTurnRunner{}
1276 events := make(chan event.Event, 4)
1277 c := New(Options{
1278 Runner: runner,
1279 Sink: event.FuncSink(func(e event.Event) {
1280 events <- e
1281 }),
1282 })
1283
1284 for _, input := range []string{"!echo should stay a prompt", "/clear"} {
1285 c.SubmitUserTurn(input, input)
1286 waitForTurnDone(t, events)
1287 // The next SubmitUserTurn must wait out the finishing window or it is
1288 // silently dropped by runGuarded — see waitIdle.
1289 waitIdle(t, c)
1290 }
1291
1292 if len(runner.inputs) != 2 {
1293 t.Fatalf("SubmitUserTurn should start model turns, inputs=%q", runner.inputs)
1294 }
1295 if runner.inputs[0] != "!echo should stay a prompt" || runner.inputs[1] != "/clear" {
1296 t.Fatalf("SubmitUserTurn inputs = %q", runner.inputs)
1297 }
1298 }
1299
1300 func TestSubmitRememberCommandQuickAddsMemory(t *testing.T) {
1301 dir := t.TempDir()
1302 runner := &fakeTurnRunner{}
1303 c := New(Options{
1304 Runner: runner,
1305 Memory: memory.Load(memory.Options{CWD: dir}),
1306 })
1307
1308 c.Submit("/remember use tabs")
1309
1310 if len(runner.inputs) != 0 {
1311 t.Fatalf("/remember should not start a model turn, inputs=%q", runner.inputs)
1312 }
1313 body, err := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
1314 if err != nil {
1315 t.Fatal(err)
1316 }
1317 if !strings.Contains(string(body), "- use tabs") {
1318 t.Fatalf("memory file missing note:\n%s", body)
1319 }
1320 }
1321
1322 // waitIdle blocks until the controller's turn-admission gate reopens.
1323 // TurnDone is emitted INSIDE the finishing window (finishGuardedTurn sets
1324 // running=false, finishing=true, emits, then clears finishing), and runGuarded
1325 // silently no-ops while finishing is set — so "received TurnDone" does NOT
1326 // mean "may submit the next turn". A submit raced into that window is
1327 // dropped, and the next turn's TurnDone never arrives; under parallel test
1328 // load the window is wide enough to hit (observed in CI and on a clean
1329 // main-v2 worktree). Poll the same running||finishing gate the controller
1330 // admission checks.
1331 func waitIdle(t *testing.T, c *Controller) {
1332 t.Helper()
1333 deadline := time.Now().Add(30 * time.Second)
1334 for c.Running() {
1335 if time.Now().After(deadline) {
1336 t.Fatal("timed out waiting for the controller to return to idle")
1337 }
1338 time.Sleep(time.Millisecond)
1339 }
1340 }
1341
1342 func waitForTurnDone(t *testing.T, events <-chan event.Event) {
1343 t.Helper()
1344 deadline := time.After(30 * time.Second)
1345 for {
1346 select {
1347 case e := <-events:
1348 if e.Kind == event.TurnDone {
1349 if e.Err != nil {
1350 t.Fatalf("turn finished with error: %v", e.Err)
1351 }
1352 return
1353 }
1354 case <-deadline:
1355 t.Fatal("timed out waiting for turn_done")
1356 }
1357 }
1358 }
1359
1360 func TestStripComposePrefixes(t *testing.T) {
1361 tests := []struct {
1362 name string
1363 input string
1364 want string
1365 }{
1366 {
1367 name: "plain user message unchanged",
1368 input: "explain this function",
1369 want: "explain this function",
1370 },
1371 {
1372 name: "plan mode marker stripped",
1373 input: PlanModeMarker + "\n\nexplain this function",
1374 want: "explain this function",
1375 },
1376 {
1377 name: "legacy plan mode marker stripped",
1378 input: legacyPlanModeMarker + "\n\nexplain this function",
1379 want: "explain this function",
1380 },
1381 {
1382 name: "plan mode marker without trailing newlines",
1383 input: PlanModeMarker,
1384 want: "",
1385 },
1386 {
1387 name: "memory update block stripped",
1388 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",
1389 want: "explain this",
1390 },
1391 {
1392 name: "background jobs block stripped",
1393 input: "<background-jobs>\n1 completed\n</background-jobs>\n\nexplain this",
1394 want: "explain this",
1395 },
1396 {
1397 name: "hook context block stripped",
1398 input: "<hook-context event=\"SessionStart\">\nLoad conventions.\n</hook-context>\n\nexplain this",
1399 want: "explain this",
1400 },
1401 {
1402 name: "memory and plan marker both stripped",
1403 input: "<memory-update>\n- note\n</memory-update>\n\n" + PlanModeMarker + "\n\nexplain this",
1404 want: "explain this",
1405 },
1406 {
1407 name: "empty after stripping",
1408 input: PlanModeMarker + "\n\n",
1409 want: "",
1410 },
1411 {
1412 name: "memory update only no user text",
1413 input: "<memory-update>\n- note\n</memory-update>\n\n",
1414 want: "",
1415 },
1416 }
1417 for _, tt := range tests {
1418 t.Run(tt.name, func(t *testing.T) {
1419 got := StripComposePrefixes(tt.input)
1420 if got != tt.want {
1421 t.Errorf("StripComposePrefixes() = %q, want %q", got, tt.want)
1422 }
1423 })
1424 }
1425 }
1426
1427 func TestStripReferencedContextPrefix(t *testing.T) {
1428 tests := []struct {
1429 name string
1430 input string
1431 want string
1432 }{
1433 {
1434 name: "plain user message unchanged",
1435 input: "explain this function",
1436 want: "explain this function",
1437 },
1438 {
1439 name: "file reference stripped",
1440 input: "Referenced context:\n\n<file path=\"main.go\">\nfunc main() {}\n</file>\n\nexplain this function",
1441 want: "explain this function",
1442 },
1443 {
1444 name: "multiple file references stripped",
1445 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",
1446 want: "compare these files",
1447 },
1448 {
1449 name: "dir reference stripped",
1450 input: "Referenced context:\n\n<dir path=\"src\">\nmain.go\nutil.go\n</dir>\n\nlist the files",
1451 want: "list the files",
1452 },
1453 {
1454 name: "resource reference stripped",
1455 input: "Referenced context:\n\n<resource ref=\"@server/res\">\ndata\n</resource>\n\nanalyze this",
1456 want: "analyze this",
1457 },
1458 {
1459 name: "image reference stripped",
1460 input: "Referenced context:\n\n<image path=\"screenshot.png\">\n[image attachment available at @screenshot.png]\n</image>\n\nwhat is in this image",
1461 want: "what is in this image",
1462 },
1463 {
1464 name: "only reference no user text",
1465 input: "Referenced context:\n\n<file path=\"main.go\">\nfunc main() {}\n</file>\n\n",
1466 want: "",
1467 },
1468 {
1469 name: "empty input",
1470 input: "",
1471 want: "",
1472 },
1473 }
1474 for _, tt := range tests {
1475 t.Run(tt.name, func(t *testing.T) {
1476 got := StripReferencedContextPrefix(tt.input)
1477 if got != tt.want {
1478 t.Errorf("StripReferencedContextPrefix() = %q, want %q", got, tt.want)
1479 }
1480 })
1481 }
1482 }
1483
1484 func TestIsSyntheticUserMessage(t *testing.T) {
1485 tests := []struct {
1486 name string
1487 input string
1488 want bool
1489 }{
1490 {
1491 name: "plan approved message",
1492 input: planApprovedMessage,
1493 want: true,
1494 },
1495 {
1496 name: "plan approved message with reasoning language",
1497 input: reasoningLanguageBlock("zh") + "\n\n" + planApprovedMessage,
1498 want: true,
1499 },
1500 {
1501 name: "stream recovery interrupted tool",
1502 input: "The previous assistant response was interrupted while a tool call was streaming. Continue the same task now.",
1503 want: true,
1504 },
1505 {
1506 name: "stream recovery interrupted text",
1507 input: "The previous assistant response was interrupted during streaming. Continue the same task from immediately after the partial assistant message above.",
1508 want: true,
1509 },
1510 {
1511 name: "empty final retry",
1512 input: "The previous assistant response finished without any visible answer text. Continue the same task now and provide a concise visible answer.",
1513 want: true,
1514 },
1515 {
1516 name: "readiness retry",
1517 input: "Host final-answer readiness check failed. Before giving a final answer, address the missing host-observable receipts: missing evidence.",
1518 want: true,
1519 },
1520 {
1521 name: "executor handoff",
1522 input: "You are already in the executor phase. The planner's read-only limitations do not apply to you.",
1523 want: true,
1524 },
1525 {
1526 name: "regular user message",
1527 input: "explain this function",
1528 want: false,
1529 },
1530 {
1531 name: "plan mode marker in message",
1532 input: PlanModeMarker + "\n\nexplain this",
1533 want: false,
1534 },
1535 {
1536 name: "stream recovery interrupted before visible",
1537 input: "The previous assistant response was interrupted during streaming before visible answer text was completed. Continue the same task now.",
1538 want: true,
1539 },
1540 {
1541 name: "user quoting interrupted response not synthetic",
1542 input: "The previous assistant response was interrupted by my VPN, can you retry?",
1543 want: false,
1544 },
1545 {
1546 name: "compaction fold summary",
1547 input: "<compaction-summary>\nSummary of earlier conversation (older messages were compacted to save context):\nDid things with tools.\n</compaction-summary>",
1548 want: true,
1549 },
1550 {
1551 name: "summarize-from fold",
1552 input: "Summary of the later conversation (compacted from here on):\nDid more things.",
1553 want: true,
1554 },
1555 {
1556 name: "summarize-upto fold",
1557 input: "Summary of earlier conversation (compacted up to here):\nDid earlier things.",
1558 want: true,
1559 },
1560 {
1561 name: "user mentioning a summary is not synthetic",
1562 input: "Summary of what I want: fix the login bug first.",
1563 want: false,
1564 },
1565 {
1566 name: "mid-turn steer is not synthetic (handled separately in historyMessages)",
1567 input: agent.MidTurnSteerPrefix + "\nplease use smaller diffs",
1568 want: false,
1569 },
1570 }
1571 for _, tt := range tests {
1572 t.Run(tt.name, func(t *testing.T) {
1573 got := IsSyntheticUserMessage(tt.input)
1574 if got != tt.want {
1575 t.Errorf("IsSyntheticUserMessage() = %v, want %v", got, tt.want)
1576 }
1577 })
1578 }
1579 }
1580
1580 lines GO