package tui import ( "context" "strings" "testing" tea "github.com/charmbracelet/bubbletea" "github.com/johannSo/codey/internal/approval" "github.com/johannSo/codey/internal/config" "github.com/johannSo/codey/internal/llm" "github.com/johannSo/codey/internal/skills" ) // newTestModel builds a model far enough to drive Update/View without a // terminal or a live agent. func newTestModel(t *testing.T) *model { t.Helper() m := newModel(context.Background(), Options{ Client: llm.New(llm.WithAPIKey("x")), Root: "/tmp/repo", Policy: approval.New(nil), }) m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) return m } func key(s string) tea.KeyMsg { switch s { case "enter": return tea.KeyMsg{Type: tea.KeyEnter} case "esc": return tea.KeyMsg{Type: tea.KeyEsc} case "ctrl+c": return tea.KeyMsg{Type: tea.KeyCtrlC} case "ctrl+r": return tea.KeyMsg{Type: tea.KeyCtrlR} case "shift+tab": return tea.KeyMsg{Type: tea.KeyShiftTab} case "tab": return tea.KeyMsg{Type: tea.KeyTab} case "backspace": return tea.KeyMsg{Type: tea.KeyBackspace} case "up": return tea.KeyMsg{Type: tea.KeyUp} case "down": return tea.KeyMsg{Type: tea.KeyDown} case "left": return tea.KeyMsg{Type: tea.KeyLeft} case "right": return tea.KeyMsg{Type: tea.KeyRight} } return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} } // typeIn sends a string one keystroke at a time, the way a person produces // it: the completion menu reacts to keys, not to the value being assigned. func typeIn(m *model, s string) { for _, r := range s { m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) } } // pend queues an approval the way the bridge would, and returns the channel // the model is expected to answer on. func pend(m *model, req approval.Request) chan approval.Decision { reply := make(chan approval.Decision, 1) m.Update(approvalMsg{req: req, reply: reply}) return reply } func TestApprovalKeysMapToDecisions(t *testing.T) { cases := []struct { key string want approval.Decision }{ {"y", approval.Once}, {"enter", approval.Once}, {"n", approval.Deny}, {"esc", approval.Deny}, {"t", approval.AlwaysThisTool}, {"a", approval.AlwaysThisCommand}, } for _, tc := range cases { t.Run(tc.key, func(t *testing.T) { m := newTestModel(t) // The session's own agent asks during a turn; answering returns // to it. (A subagent can ask while you are idle — see // TestAnsweringASubagentsApprovalWhileIdleLeavesYouIdle.) m.phase = phaseRunning reply := pend(m, approval.Request{Tool: "bash", Rememberable: true}) if m.phase != phaseApproving { t.Fatalf("phase = %v, want phaseApproving", m.phase) } m.Update(key(tc.key)) select { case got := <-reply: if got != tc.want { t.Errorf("decision = %v, want %v", got, tc.want) } default: t.Fatal("no decision was sent") } if m.phase != phaseRunning { t.Errorf("phase = %v after deciding, want phaseRunning", m.phase) } }) } } // "always this exact one" is refused for an opaque command, and crucially the // agent stays blocked rather than being silently approved or denied. func TestAlwaysThisCommandRefusedWhenNotRememberable(t *testing.T) { m := newTestModel(t) reply := pend(m, approval.Request{Tool: "bash", Rememberable: false}) m.Update(key("a")) select { case got := <-reply: t.Fatalf("sent %v for an unrememberable command; want the prompt to stay open", got) default: } if m.phase != phaseApproving { t.Errorf("phase = %v, want the prompt still open", m.phase) } if !strings.Contains(m.tr.render(), "cannot be remembered") { t.Error("no explanation shown for the refusal") } // It must still be answerable. m.Update(key("y")) if got := <-reply; got != approval.Once { t.Errorf("decision = %v, want Once", got) } } func TestUnknownKeyDuringApprovalDoesNothing(t *testing.T) { m := newTestModel(t) reply := pend(m, approval.Request{Tool: "bash", Rememberable: true}) m.Update(key("z")) select { case got := <-reply: t.Fatalf("stray key sent decision %v", got) default: } if m.phase != phaseApproving { t.Error("stray key dismissed the prompt") } } // Queued approvals are answered one at a time, in order. func TestApprovalsAreQueued(t *testing.T) { m := newTestModel(t) // These are the session's own agent asking, which only happens during a // turn. A subagent can ask while you are idle, and then answering puts // you back to idle rather than into a turn that is not running. m.phase = phaseRunning first := pend(m, approval.Request{Tool: "bash", Rememberable: true}) second := pend(m, approval.Request{Tool: "edit_file", Rememberable: true}) m.Update(key("y")) if got := <-first; got != approval.Once { t.Errorf("first decision = %v", got) } if m.phase != phaseApproving { t.Fatal("phase left approving while a second request was queued") } m.Update(key("n")) if got := <-second; got != approval.Deny { t.Errorf("second decision = %v", got) } if m.phase != phaseRunning { t.Errorf("phase = %v after the queue drained", m.phase) } } // A turn that ends while an approval is outstanding must release the agent // rather than leave it blocked forever. func TestTurnDoneDeniesOutstandingApprovals(t *testing.T) { m := newTestModel(t) reply := pend(m, approval.Request{Tool: "bash"}) m.opts.SessionDir = t.TempDir() m.Update(turnDone{}) select { case got := <-reply: if got != approval.Deny { t.Errorf("decision = %v, want Deny", got) } default: t.Fatal("an outstanding approval was never answered") } if m.phase != phaseIdle { t.Errorf("phase = %v, want phaseIdle", m.phase) } if len(m.pending) != 0 { t.Error("pending approvals were not cleared") } } func TestCtrlCDuringApprovalDeniesAndInterrupts(t *testing.T) { m := newTestModel(t) m.phase = phaseRunning cancelled := false m.cancel = func() { cancelled = true } reply := pend(m, approval.Request{Tool: "bash"}) m.Update(key("ctrl+c")) if got := <-reply; got != approval.Deny { t.Errorf("decision = %v, want Deny", got) } if !cancelled { t.Error("ctrl+c during an approval did not cancel the turn") } if !m.interrupting { t.Error("interrupting flag not set") } } func TestCtrlCInterruptsRunningTurnThenQuitsWhenIdle(t *testing.T) { m := newTestModel(t) cancelled := false m.phase = phaseRunning m.cancel = func() { cancelled = true } if _, cmd := m.Update(key("ctrl+c")); cmd != nil && isQuit(cmd) { t.Fatal("ctrl+c quit the program while a turn was running") } if !cancelled { t.Error("running turn was not cancelled") } if m.quitting { t.Error("model marked quitting during a running turn") } m.phase = phaseIdle if _, cmd := m.Update(key("ctrl+c")); cmd == nil || !isQuit(cmd) { t.Error("ctrl+c while idle did not quit") } } func isQuit(cmd tea.Cmd) bool { if cmd == nil { return false } _, ok := cmd().(tea.QuitMsg) return ok } func TestSlashCommands(t *testing.T) { m := newTestModel(t) m.submit("/help") if !strings.Contains(m.tr.render(), "/allowed") { t.Error("/help did not list commands") } m.submit("/allowed") if !strings.Contains(m.tr.render(), "nothing is permanently allowed") { t.Error("/allowed did not report an empty policy") } m.submit("/nope") if !strings.Contains(m.tr.render(), "unknown command") { t.Error("an unknown command was not reported") } m.submit("/clear") if len(m.tr.entries) != 0 { t.Errorf("/clear left %d entries", len(m.tr.entries)) } if _, cmd := m.submit("/exit"); !isQuit(cmd) { t.Error("/exit did not quit") } } // Input is refused while the agent is working: accepting it would imply it had // been queued, and nothing would ever send it. func TestSlashSkillsListsThem(t *testing.T) { m := newTestModel(t) m.opts.Skills = []skills.Skill{ {Name: "commit-helper", Description: "Use when writing commit messages", Source: "personal"}, } m.submit("/skills") out := m.tr.render() if !strings.Contains(out, "commit-helper") || !strings.Contains(out, "Use when writing commit messages") { t.Errorf("/skills did not list the skill:\n%s", out) } } func TestSlashSkillsWithNoneSaysSo(t *testing.T) { m := newTestModel(t) m.submit("/skills") if !strings.Contains(m.tr.render(), "no skills") { t.Errorf("/skills with none found said nothing useful:\n%s", m.tr.render()) } } func TestKnownSkillIsSentAsAUserTurn(t *testing.T) { m := newTestModel(t) m.opts.Skills = []skills.Skill{{Name: "commit-helper", Description: "d"}} m.submit("/commit-helper fix the flaky test") if m.phase != phaseRunning { t.Errorf("phase = %v, want phaseRunning — the line was not sent", m.phase) } if !strings.Contains(m.tr.render(), "/commit-helper fix the flaky test") { t.Errorf("the user turn is not in the transcript:\n%s", m.tr.render()) } } func TestUnknownSlashCommandStillErrors(t *testing.T) { m := newTestModel(t) m.opts.Skills = []skills.Skill{{Name: "commit-helper", Description: "d"}} m.submit("/commit-helpr") if m.phase == phaseRunning { t.Error("a typo was sent to the model instead of being caught") } if !strings.Contains(m.tr.render(), "unknown command") { t.Errorf("no error for the typo:\n%s", m.tr.render()) } } func TestHelpMentionsSkills(t *testing.T) { m := newTestModel(t) m.submit("/help") if !strings.Contains(m.tr.render(), "/skills") { t.Error("/help does not mention /skills") } } func TestEnterIgnoredWhileRunning(t *testing.T) { m := newTestModel(t) m.phase = phaseRunning m.input.SetValue("another thing") m.Update(key("enter")) if m.input.Value() != "another thing" { t.Error("input was consumed while a turn was running") } } func TestUsageAccumulatesAcrossTurns(t *testing.T) { m := newTestModel(t) m.Update(evUsage{usage: llm.Usage{InputTokens: 10, OutputTokens: 3, CacheReadInputTokens: 100}}) m.Update(evUsage{usage: llm.Usage{InputTokens: 5, OutputTokens: 2}}) if m.usage.InputTokens != 15 || m.usage.OutputTokens != 5 || m.usage.CacheReadInputTokens != 100 { t.Errorf("usage = %+v, want in 15 out 5 cached 100", m.usage) } if s := m.status(); !strings.Contains(s, "15") { t.Errorf("status line does not show usage: %q", s) } } // The cost is the last field on the status line and the easiest one to lose to // a width miscount; at a normal width it must survive intact. func TestStatusLineKeepsCostAtNormalWidth(t *testing.T) { m := newTestModel(t) m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) m.Update(evUsage{usage: llm.Usage{InputTokens: 2400, OutputTokens: 190, CacheReadInputTokens: 16000}}) status := m.status() if _, known := m.usage.Cost(m.opts.Client.Model()); !known { t.Skip("no pricing for the test model") } if strings.HasSuffix(strings.TrimSpace(status), "…") { t.Errorf("status line was truncated at width 120: %q", status) } if !strings.Contains(status, "$") { t.Errorf("cost missing from the status line: %q", status) } } // Approving a call records the decision on that call, so the transcript reads // in the order things actually happened. func TestApprovalNoteAttachesToTheCall(t *testing.T) { m := newTestModel(t) m.tr.startTool("bash", "command=go build ./...") reply := pend(m, approval.Request{Tool: "bash", Rememberable: true}) m.Update(key("y")) <-reply m.tr.finishTool("bash", "build ok", false) out := m.tr.render() note, result := strings.Index(out, "approved once"), strings.Index(out, "build ok") if note < 0 || result < 0 || note > result { t.Errorf("approval did not render with the call it gated:\n%s", out) } } func TestToggleVerboseRedrawsTranscript(t *testing.T) { m := newTestModel(t) m.tr.startTool("bash", "command=go test") m.tr.finishTool("bash", strings.Repeat("noise\n", 30), false) if !strings.Contains(m.tr.render(), "more lines") { t.Fatal("output was not collapsed to begin with") } m.Update(key("ctrl+r")) if strings.Contains(m.tr.render(), "more lines") { t.Error("ctrl+r did not expand tool output") } } // The view must render at any size without panicking; a resize to something // tiny is the classic crash. func TestViewRendersAtAwkwardSizes(t *testing.T) { for _, size := range [][2]int{{80, 24}, {20, 6}, {200, 60}, {1, 1}} { m := newTestModel(t) m.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]}) m.tr.addUser("hello") m.tr.startTool("edit_file", "path=main.go") m.tr.finishTool("edit_file", "--- a\n+++ b\n@@ -1 +1 @@\n-x\n+y\n", false) m.sync() if got := m.View(); got == "" { t.Errorf("empty view at %dx%d", size[0], size[1]) } pend(m, approval.Request{Tool: "bash", Summary: "run a shell command", Detail: "rm -rf /", Rememberable: false}) if got := m.View(); !strings.Contains(got, "bash") { t.Errorf("approval not shown at %dx%d", size[0], size[1]) } } } func TestViewShowsApprovalOptionsConditionally(t *testing.T) { m := newTestModel(t) pend(m, approval.Request{Tool: "bash", Summary: "run it", Detail: "ls", Rememberable: true}) if got := m.approvalView(); !strings.Contains(got, "always this exact one") { t.Error("rememberable request did not offer the exact-command option") } m2 := newTestModel(t) pend(m2, approval.Request{Tool: "bash", Summary: "run it", Detail: "eval $(x)", Rememberable: false}) if got := m2.approvalView(); strings.Contains(got, "always this exact one") { t.Error("opaque command offered an option that would be a lie") } } // ---- the setup wizard, from the chat model's side ------------------------ // A first run opens the wizard before anything else is on screen. That is the // whole point of the FirstRun flag: nobody should have to know /setup exists // to be asked the questions once. func TestFirstRunOpensTheWizard(t *testing.T) { m := newModel(context.Background(), Options{ Client: llm.New(llm.WithAPIKey("x")), Root: "/tmp/repo", Policy: approval.New(nil), FirstRun: true, }) m.Init() m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) if m.wiz == nil { t.Fatal("a first run did not open the wizard") } if !strings.Contains(m.View(), "codey") { t.Error("the wizard is open but its screen is not what View returns") } } func TestASecondRunDoesNotOpenTheWizard(t *testing.T) { m := newTestModel(t) m.Init() if m.wiz != nil { t.Error("the wizard opened without a first run") } } // While the wizard is up it owns every key: a keystroke meant for it must not // also reach the prompt underneath. func TestWizardSwallowsKeysMeantForIt(t *testing.T) { m := newTestModel(t) m.Update(key("/setup")) m.submit("/setup") if m.wiz == nil { t.Fatal("/setup did not open the wizard") } before := m.input.Value() m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) if m.input.Value() != before { t.Errorf("a key reached the prompt through the wizard: %q", m.input.Value()) } } // Finishing hands the answers to OnConfig — the only path by which anything is // saved or applied — and puts the chat back. func TestFinishingTheWizardAppliesAndCloses(t *testing.T) { var got *config.Config m := newModel(context.Background(), Options{ Client: llm.New(llm.WithAPIKey("x")), Root: "/tmp/repo", Policy: approval.New(nil), SaveConfig: func(c config.Config) error { got = &c return nil }, }) m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) m.submit("/setup") m.Update(modelsMsg{models: pricedModels()}) for i := 0; i < 400 && m.wiz != nil; i++ { m.Update(key("enter")) m.Update(wizTick{}) } if m.wiz != nil { t.Fatal("the wizard never closed") } if got == nil { t.Fatal("SaveConfig was never called") } if got.Model == "" || got.Effort == "" { t.Errorf("SaveConfig got an incomplete config: %+v", *got) } if m.opts.Effort != got.Effort { t.Errorf("the status bar still reports effort %q, want %q", m.opts.Effort, got.Effort) } if !strings.Contains(m.tr.render(), "setup:") { t.Error("the change was never reported in the transcript") } if m.opts.Client.Model() != got.Model { t.Errorf("the client is on %q, want the chosen %q", m.opts.Client.Model(), got.Model) } } // Cancelling must change nothing, and must say so rather than leaving the user // wondering whether it took. func TestCancellingTheWizardChangesNothing(t *testing.T) { called := false m := newModel(context.Background(), Options{ Client: llm.New(llm.WithAPIKey("x")), Root: "/tmp/repo", Policy: approval.New(nil), Config: config.Config{Effort: "low"}, SaveConfig: func(config.Config) error { called = true; return nil }, }) m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) m.submit("/setup") m.Update(key("ctrl+c")) if m.wiz != nil { t.Fatal("ctrl+c did not close the wizard") } if called { t.Error("a cancelled wizard still applied its answers") } if m.quitting { t.Error("ctrl+c in the wizard quit the whole program") } if !strings.Contains(m.tr.render(), "cancelled") { t.Error("cancelling said nothing") } } // Changing the model mid-turn would mean the turn finishes on a different // model than it started on, and the session log would be wrong about which. func TestSetupIsRefusedWhileTheAgentIsWorking(t *testing.T) { m := newTestModel(t) m.phase = phaseRunning m.submit("/setup") if m.wiz != nil { t.Error("/setup opened the wizard mid-turn") } if !strings.Contains(m.tr.render(), "interrupt first") { t.Error("/setup was refused without saying why") } } // /effort is the one command that changes how the next turn runs, so it has to // take, be visible in the chrome, and reach the agent. func TestEffortCommandSetsTheLevel(t *testing.T) { m := newTestModel(t) m.submit("/effort xhigh") if m.opts.Effort != "xhigh" { t.Errorf("effort = %q, want xhigh", m.opts.Effort) } if !strings.Contains(m.activity(), "xhigh") { t.Errorf("the effort pill still reads %q", m.activity()) } if !strings.Contains(m.tr.render(), "xhigh") { t.Error("the change was not reported in the transcript") } } func TestEffortCommandRejectsAnUnknownLevel(t *testing.T) { m := newTestModel(t) m.opts.Effort = "high" m.submit("/effort ludicrous") if m.opts.Effort != "high" { t.Errorf("effort = %q, want the previous level kept", m.opts.Effort) } out := m.tr.render() if !strings.Contains(out, "ludicrous") || !strings.Contains(out, "xhigh") { t.Errorf("want the bad level named and the good ones listed, got %q", out) } if m.phase != phaseIdle { t.Error("a rejected effort still started a turn") } }