Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions cmd/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,8 @@ func newChatModel(ref *progRef, systemPrompt string, settings hawkconfig.Setting
startup.EndPhase("newChatModel:ui-init")

startup.MarkPhase("newChatModel:effectiveModelAndProvider")
effectiveModel, effectiveProvider := effectiveModelAndProvider(settings)
selection := resolveSelection(settings)
effectiveModel, effectiveProvider := selection.Model, selection.Provider
startup.EndPhase("newChatModel:effectiveModelAndProvider")

startup.MarkPhase("newChatModel:defaultRegistry")
Expand All @@ -134,7 +135,7 @@ func newChatModel(ref *progRef, systemPrompt string, settings hawkconfig.Setting
startup.EndPhase("newChatModel:defaultRegistry")

startup.MarkPhase("newChatModel:newHawkSession")
sess := newHawkSession(settings, effectiveProvider, effectiveModel, systemPrompt, registry)
sess := newHawkSessionFromSelection(selection, systemPrompt, registry)
startup.EndPhase("newChatModel:newHawkSession")

startup.MarkPhase("newChatModel:configureSession")
Expand Down Expand Up @@ -299,7 +300,7 @@ func newChatModel(ref *progRef, systemPrompt string, settings hawkconfig.Setting
ok := sandbox.DockerAvailable()
dockerRunning = &ok
}
m.welcomeCache = buildWelcomeMessage(sess, sid, registry, saved, settings, false, initWidth, initHeight, dockerRunning)
m.welcomeCache = buildWelcomeMessage(sess, sid, registry, saved, settings, len(pr.SmartSkills), false, initWidth, initHeight, dockerRunning)
m.messages = append(m.messages, displayMsg{role: "welcome", content: m.welcomeCache})

// Wire permission system
Expand Down Expand Up @@ -446,6 +447,7 @@ func autoIndexCodegraph() {
}

func runChat() error {
startup.Reset()
startBackgroundCatalogRefresh(context.Background())

// Auto-index codegraph in background if .codegraph exists
Expand Down
81 changes: 81 additions & 0 deletions cmd/chat_journey_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package cmd

import (
"context"
"strings"
"testing"

"github.com/GrayCodeAI/eyrie/credentials"
hawkconfig "github.com/GrayCodeAI/hawk/internal/config"
)

func requireChatModel(t *testing.T, model any) *chatModel {
t.Helper()
switch v := model.(type) {
case *chatModel:
return v
case chatModel:
return &v
default:
t.Fatalf("unexpected model type %T", model)
return nil
}
}

func TestChatJourney_ConfigPermissionsAndCoreCommands(t *testing.T) {
hawkconfig.InvalidateConfigUICache()
isolateCredentialHome(t)
store := &credentials.MapStore{}
credentials.SetDefaultStore(store)
t.Cleanup(func() {
credentials.SetDefaultStore(nil)
hawkconfig.InvalidateConfigUICache()
})

ctx := context.Background()
if err := store.Set(ctx, credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil {
t.Fatal(err)
}
hawkconfig.InvalidateConfigUICache()

m := newTestChatModel()

result, cmd := m.handleCommand("/config")
if cmd != nil {
t.Fatal("expected /config to open inline panel without tea command")
}
m = requireChatModel(t, result)
if !m.configOpen || m.configTab != configTabModels {
t.Fatalf("/config should open models tab once credentials exist, got open=%v tab=%d", m.configOpen, m.configTab)
}

result, _ = m.handleCommand("/config set provider openrouter")
m = requireChatModel(t, result)
if got := strings.ToLower(lastSystemMessage(m.messages)); !strings.Contains(got, "openrouter") {
t.Fatalf("unexpected provider update message: %q", got)
}

if got := m.session.Provider(); got != "openrouter" {
t.Fatalf("session provider = %q, want openrouter", got)
}

result, _ = m.handleCommand("/permissions allow Bash(git:*)")
m = requireChatModel(t, result)
if got := lastSystemMessage(m.messages); !strings.Contains(got, "Allow rules updated.") {
t.Fatalf("unexpected allow update message: %q", got)
}

result, _ = m.handleCommand("/permissions rules")
m = requireChatModel(t, result)
if got := lastSystemMessage(m.messages); !strings.Contains(got, "Bash(git:*)") {
t.Fatalf("permission rules summary missing allow rule: %q", got)
}

for _, slash := range []string{"/help", "/status", "/tools"} {
result, _ = m.handleCommand(slash)
m = requireChatModel(t, result)
if msg := strings.TrimSpace(lastSystemMessage(m.messages)); msg == "" {
t.Fatalf("%s should append a system message", slash)
}
}
}
4 changes: 2 additions & 2 deletions cmd/chat_layout_mouse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ func TestView_LineCountMatchesHeight(t *testing.T) {
if m.footerTopY() <= m.chatPaneTopY() {
t.Fatalf("footerTopY %d must be below chat top %d", m.footerTopY(), m.chatPaneTopY())
}
// Footer must start on the same row View() renders the container/model line.
// Footer must start on the same row View() renders the host/container line.
footerIdx := -1
for i, line := range lines {
if strings.Contains(line, "Default") || strings.Contains(line, "Container:") {
if strings.Contains(line, "Host mode:") || strings.Contains(line, "Container:") {
footerIdx = i
break
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/chat_layout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func TestView_PinsWelcomeAboveViewport(t *testing.T) {
if !strings.HasPrefix(got, "HAWK LOGO") {
t.Fatalf("welcome should be pinned at top, got prefix: %q", got[:min(40, len(got))])
}
if !strings.Contains(got, "Default") && !strings.Contains(got, "Container:") {
if !strings.Contains(got, "Host mode:") && !strings.Contains(got, "Container:") {
t.Fatalf("footer should be present at bottom")
}
}
44 changes: 41 additions & 3 deletions cmd/chat_model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,17 @@ package cmd
import (
"strings"
"testing"
"time"

"github.com/GrayCodeAI/eyrie/credentials"
"github.com/GrayCodeAI/hawk/internal/bridge/sessioncapture"
hawkconfig "github.com/GrayCodeAI/hawk/internal/config"
"github.com/GrayCodeAI/hawk/internal/engine"
"github.com/GrayCodeAI/hawk/internal/feature/shellmode"
"github.com/GrayCodeAI/hawk/internal/storage"
"github.com/GrayCodeAI/hawk/internal/tool"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
)

func newTestChatModel() *chatModel {
Expand All @@ -16,6 +22,8 @@ func newTestChatModel() *chatModel {
sess.SetTestClient(engine.NewMockClientForTest())

m := &chatModel{
input: textarea.New(),
viewport: viewport.New(120, 12),
session: sess,
registry: tool.NewRegistry(),
partial: &strings.Builder{},
Expand All @@ -27,10 +35,27 @@ func newTestChatModel() *chatModel {
termCtx: sessioncapture.NewTerminalContext(),
ghostText: NewGhostText(),
inputIndicator: &InputIndicator{},
hintsLoader: engine.NewHintsLoader(),
selfImprover: engine.NewSelfImprover(),
codingSoul: engine.LoadCodingSoul(),
brailleSpinner: NewBrailleSpinner(SpinnerHawk, "Thinking"),
}
return m
}

func isolateChatCommandSweepEnv(t *testing.T) {
t.Helper()
root := t.TempDir()
storage.SetTestDirs(t, root)
isolateCredentialHome(t)
hawkconfig.InvalidateConfigUICache()
credentials.SetDefaultStore(&credentials.MapStore{})
t.Cleanup(func() {
credentials.SetDefaultStore(nil)
hawkconfig.InvalidateConfigUICache()
})
}

func TestChatModel_SlashHelp(t *testing.T) {
m := newTestChatModel()
result, _ := m.handleCommand("/help")
Expand Down Expand Up @@ -150,7 +175,6 @@ func TestChatModel_SlashUnknown(t *testing.T) {
}

func TestChatModel_ManyCommands(t *testing.T) {
t.Skip("flaky: race condition with global state access") // TODO: https://github.com/GrayCodeAI/hawk/issues/26
commands := []string{
"/context", "/env", "/hooks", "/stats",
"/compact", "/diff", "/branch", "/vim",
Expand Down Expand Up @@ -187,11 +211,20 @@ func TestChatModel_ManyCommands(t *testing.T) {

for _, cmd := range commands {
t.Run(cmd, func(t *testing.T) {
isolateChatCommandSweepEnv(t)
m := newTestChatModel()
result, _ := m.handleCommand(cmd)
if result == nil {
t.Errorf("%s returned nil model", cmd)
}
cm := requireChatModel(t, result)
if cm.cancel != nil {
cm.cancel()
}
if cm.loopCancel != nil {
cm.loopCancel()
}
time.Sleep(10 * time.Millisecond)
})
}
}
Expand Down Expand Up @@ -224,8 +257,7 @@ func TestChatModel_SlashExport(t *testing.T) {
}

func TestChatModel_StreamingCommands(t *testing.T) {
t.Skip("flaky: race condition with startStream goroutines") // TODO: https://github.com/GrayCodeAI/hawk/issues/27
// These trigger startStream but progRef is nil-safe so they won't panic
// These trigger startStream; cancel promptly so the test doesn't leak workers.
commands := []string{
"/doctor", "/commit", "/review",
"/summary", "/security-review",
Expand All @@ -235,12 +267,18 @@ func TestChatModel_StreamingCommands(t *testing.T) {

for _, cmd := range commands {
t.Run(cmd, func(t *testing.T) {
isolateChatCommandSweepEnv(t)
m := newTestChatModel()
m.session.AddUser("some context for the command")
result, _ := m.handleCommand(cmd)
if result == nil {
t.Errorf("%s returned nil model", cmd)
}
cm := requireChatModel(t, result)
if cm.cancel != nil {
cm.cancel()
}
time.Sleep(10 * time.Millisecond)
})
}
}
Loading
Loading