Skip to content
Open
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
4 changes: 2 additions & 2 deletions internal/db/messages.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions internal/db/sql/messages.sql
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ WHERE session_id = ?;
SELECT *
FROM messages
WHERE session_id = ? AND role = 'user'
ORDER BY created_at DESC;
ORDER BY created_at DESC, rowid DESC;

-- name: ListAllUserMessages :many
SELECT *
FROM messages
WHERE role = 'user'
ORDER BY created_at DESC;
ORDER BY created_at DESC, rowid DESC;
42 changes: 42 additions & 0 deletions internal/message/message_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -693,3 +693,45 @@ func TestUpdate_StructuralFlushUsesMustDeliver(t *testing.T) {
})
}
}

func TestListUserMessages_ReverseSendOrderUnderSpam(t *testing.T) {
t.Parallel()

svc, sessionID := newTestService(t)

sent := []string{"first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth", "tenth"}
for _, p := range sent {
_, err := svc.Create(t.Context(), sessionID, CreateMessageParams{
Role: User,
Parts: []ContentPart{TextContent{Text: p}},
})
require.NoError(t, err)
}

got, err := svc.ListUserMessages(t.Context(), sessionID)
require.NoError(t, err)
require.Len(t, got, len(sent))

bucket := map[int64]int{}
for _, m := range got {
bucket[m.CreatedAt]++
}
colliding := 0
for _, n := range bucket {
if n > 1 {
colliding += n
}
}
t.Logf("created_at collisions: %d/%d rows share a value with at least one peer", colliding, len(got))

want := make([]string, len(sent))
for i, p := range sent {
want[len(sent)-1-i] = p
}
actual := make([]string, len(got))
for i, m := range got {
actual[i] = m.Content().Text
}
require.Equal(t, want, actual,
"history must be reverse-send order even when prompts share a created_at second")
}
22 changes: 22 additions & 0 deletions internal/ui/model/helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package model

import tea "charm.land/bubbletea/v2"

// drainCmd runs a command and flattens any batch into individual messages,
// recursing into nested batches. It does not recurse into sequences.
func drainCmd(cmd tea.Cmd) []tea.Msg {
if cmd == nil {
return nil
}

switch msg := cmd().(type) {
case tea.BatchMsg:
var out []tea.Msg
for _, c := range msg {
out = append(out, drainCmd(c)...)
}
return out
default:
return []tea.Msg{msg}
}
}
18 changes: 18 additions & 0 deletions internal/ui/model/history.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,24 @@ func (m *UI) loadPromptHistory() tea.Cmd {
}
}

// setPromptHistory replaces the loaded prompt history and resets navigation
// back to the live draft.
func (m *UI) setPromptHistory(messages []string) {
m.promptHistory.messages = messages
m.promptHistory.index = -1
m.promptHistory.draft = ""
}

// reloadHistoryForMessage reloads prompt history when a user message is
// created, so a just-sent prompt is immediately navigable even while the
// agent is still generating its response. Returns nil for non-user messages.
func (m *UI) reloadHistoryForMessage(msg message.Message) tea.Cmd {
if msg.Role != message.User {
return nil
}
return m.loadPromptHistory()
}

// handleHistoryUp handles up arrow for history navigation.
func (m *UI) handleHistoryUp(msg tea.Msg) tea.Cmd {
prevHeight := m.textarea.Height()
Expand Down
64 changes: 64 additions & 0 deletions internal/ui/model/history_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package model

import (
"testing"

tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/message"
"github.com/charmbracelet/crush/internal/pubsub"
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/ui/common"
uistyles "github.com/charmbracelet/crush/internal/ui/styles"
"github.com/stretchr/testify/require"
)

// extra mock methods required by the full New()/Update path.
func (w *historyTestWorkspace) PermissionSkipRequests() bool { return false }
func (w *historyTestWorkspace) ProjectNeedsInitialization() (bool, error) { return false, nil }
func (w *historyTestWorkspace) AgentQueuedPrompts(string) int { return 0 }

// TestCreatedEventReloadsPromptHistory drives the real Update with a
// pubsub.CreatedEvent and asserts the wiring (ui.go CreatedEvent ->
// reloadHistoryForMessage) actually reloads prompt history.
func TestCreatedEventReloadsPromptHistory(t *testing.T) {
t.Parallel()

st := uistyles.CharmtonePantera()
cfg := &config.Config{
Options: &config.Options{TUI: &config.TUIOptions{}},
Providers: csync.NewMap[string, config.ProviderConfig](),
}
ws := &historyTestWorkspace{cfg: cfg}
com := &common.Common{Workspace: ws, Styles: &st}

m := New(com, "", false)
m.session = &session.Session{ID: sessionID}

// Give the model a size so layout/render don't run at zero dimensions.
model, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
m = model.(*UI)

const prompt = "hey there"
ws.addUserMessage(sessionID, prompt) // message persisted to the "DB"

created := message.Message{
Role: message.User,
SessionID: sessionID,
Parts: []message.ContentPart{message.TextContent{Text: prompt}},
}

// Drive the REAL Update with a CreatedEvent and process the resulting cmds.
model, cmd := m.Update(pubsub.Event[message.Message]{Type: pubsub.CreatedEvent, Payload: created})
m = model.(*UI)
for _, msg := range drainCmd(cmd) {
if _, ok := msg.(promptHistoryLoadedMsg); ok {
model, _ = m.Update(msg)
m = model.(*UI)
}
}

require.True(t, m.historyPrev(), "after CreatedEvent up must surface the message")
require.Equal(t, prompt, m.textarea.Value())
}
112 changes: 112 additions & 0 deletions internal/ui/model/history_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package model

import (
"context"
"sync"
"testing"

"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/message"
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/workspace"
"github.com/stretchr/testify/require"
)

const sessionID string = "s1"

func TestPromptHistoryNavigationOnSubmission(t *testing.T) {
t.Parallel()

ws := &historyTestWorkspace{busy: true}
m := &UI{com: &common.Common{Workspace: ws}}
m.session = &session.Session{ID: sessionID}
m.textarea = textarea.New()
m.promptHistory.index = -1

const prompt = "hey there"

// Phase 1: on submit the history is reloaded concurrently with AgentRun and
// wins the race, reading the DB before the message is persisted (empty).
applyHistoryReload(t, m, m.loadPromptHistory())

// Phase 2: AgentRun persists the user message, then the CreatedEvent reload
// (reloadHistoryForMessage) picks it up.
require.NoError(t, ws.AgentRun(context.Background(), m.session.ID, prompt))
created := message.Message{
Role: message.User,
SessionID: m.session.ID,
Parts: []message.ContentPart{message.TextContent{Text: prompt}},
}
applyHistoryReload(t, m, m.reloadHistoryForMessage(created))

// Phase 3: pressing "up" must surface the just-sent prompt.
require.True(t, m.historyPrev(), "`up` must insert just sent prompt")
require.Equal(t, prompt, m.textarea.Value())
}

// applyHistoryReload runs a prompt-history command and applies its result the
// same way the model does when handling promptHistoryLoadedMsg in Update.
func applyHistoryReload(t *testing.T, m *UI, cmd tea.Cmd) {
t.Helper()

loaded, ok := cmd().(promptHistoryLoadedMsg)
require.True(t, ok, "command must return promptHistoryLoadedMsg")
m.setPromptHistory(loaded.messages)
}

type historyTestWorkspace struct {
workspace.Workspace

cfg *config.Config
busy bool

mu sync.Mutex
msgs []message.Message
}

func (w *historyTestWorkspace) addUserMessage(sessionID, text string) {
w.mu.Lock()
defer w.mu.Unlock()

msg := message.Message{
Role: message.User,
SessionID: sessionID,
Parts: []message.ContentPart{message.TextContent{Text: text}},
}

w.msgs = append([]message.Message{msg}, w.msgs...)
}

func (w *historyTestWorkspace) snapshot() []message.Message {
w.mu.Lock()
defer w.mu.Unlock()

out := make([]message.Message, len(w.msgs))
copy(out, w.msgs)

return out
}

func (w *historyTestWorkspace) ListUserMessages(_ context.Context, _ string) ([]message.Message, error) {
return w.snapshot(), nil
}

func (w *historyTestWorkspace) ListAllUserMessages(_ context.Context) ([]message.Message, error) {
return w.snapshot(), nil
}

func (w *historyTestWorkspace) AgentIsReady() bool { return true }
func (w *historyTestWorkspace) AgentIsBusy() bool { return w.busy }
func (w *historyTestWorkspace) Config() *config.Config { return w.cfg }

func (w *historyTestWorkspace) CreateSession(_ context.Context, _ string) (session.Session, error) {
return session.Session{ID: sessionID}, nil
}

func (w *historyTestWorkspace) AgentRun(_ context.Context, sessionID, prompt string, _ ...message.Attachment) error {
w.addUserMessage(sessionID, prompt)
return nil
}
11 changes: 6 additions & 5 deletions internal/ui/model/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -583,9 +583,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}

case promptHistoryLoadedMsg:
m.promptHistory.messages = msg.messages
m.promptHistory.index = -1
m.promptHistory.draft = ""
m.setPromptHistory(msg.messages)

case closeDialogMsg:
m.dialog.CloseFrontDialog()
Expand Down Expand Up @@ -622,7 +620,10 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
switch msg.Type {
case pubsub.CreatedEvent:
cmds = append(cmds, m.appendSessionMessage(msg.Payload))
cmds = append(cmds,
m.appendSessionMessage(msg.Payload),
m.reloadHistoryForMessage(msg.Payload),
)
case pubsub.UpdatedEvent:
cmds = append(cmds, m.updateSessionMessage(msg.Payload))
case pubsub.DeletedEvent:
Expand Down Expand Up @@ -1897,7 +1898,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
m.randomizePlaceholders()
m.historyReset()

return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory())
return m.sendMessage(value, attachments...)
case key.Matches(msg, m.keyMap.Chat.NewSession):
if !m.hasSession() {
break
Expand Down
Loading