From d8825ba59dd4102265bd1cba1ea5c21c73d398bb Mon Sep 17 00:00:00 2001 From: Vasily Styagov Date: Sun, 31 May 2026 18:16:17 +0100 Subject: [PATCH 1/2] fix: sort history prompts by both timestamp and id --- internal/db/messages.sql.go | 4 +-- internal/db/sql/messages.sql | 4 +-- internal/message/message_test.go | 42 ++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/internal/db/messages.sql.go b/internal/db/messages.sql.go index 44e8bb366b..1f5644f4e1 100644 --- a/internal/db/messages.sql.go +++ b/internal/db/messages.sql.go @@ -111,7 +111,7 @@ const listAllUserMessages = `-- name: ListAllUserMessages :many SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message FROM messages WHERE role = 'user' -ORDER BY created_at DESC +ORDER BY created_at DESC, rowid DESC ` func (q *Queries) ListAllUserMessages(ctx context.Context) ([]Message, error) { @@ -193,7 +193,7 @@ const listUserMessagesBySession = `-- name: ListUserMessagesBySession :many SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message FROM messages WHERE session_id = ? AND role = 'user' -ORDER BY created_at DESC +ORDER BY created_at DESC, rowid DESC ` func (q *Queries) ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) { diff --git a/internal/db/sql/messages.sql b/internal/db/sql/messages.sql index 91d158eb1f..1df5cbb42c 100644 --- a/internal/db/sql/messages.sql +++ b/internal/db/sql/messages.sql @@ -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; diff --git a/internal/message/message_test.go b/internal/message/message_test.go index 279e0455de..adf7e4716b 100644 --- a/internal/message/message_test.go +++ b/internal/message/message_test.go @@ -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") +} From ab1705faa5c1ecf21a9349ab40b5c3495c836d8d Mon Sep 17 00:00:00 2001 From: Vasily Styagov Date: Mon, 1 Jun 2026 22:03:08 +0100 Subject: [PATCH 2/2] fix: load history sequentially on prompt submission --- internal/ui/model/helpers_test.go | 22 ++++ internal/ui/model/history.go | 18 +++ internal/ui/model/history_integration_test.go | 64 ++++++++++ internal/ui/model/history_test.go | 112 ++++++++++++++++++ internal/ui/model/ui.go | 11 +- 5 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 internal/ui/model/helpers_test.go create mode 100644 internal/ui/model/history_integration_test.go create mode 100644 internal/ui/model/history_test.go diff --git a/internal/ui/model/helpers_test.go b/internal/ui/model/helpers_test.go new file mode 100644 index 0000000000..1f217cc12f --- /dev/null +++ b/internal/ui/model/helpers_test.go @@ -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} + } +} diff --git a/internal/ui/model/history.go b/internal/ui/model/history.go index 7495bd7808..e608fb1729 100644 --- a/internal/ui/model/history.go +++ b/internal/ui/model/history.go @@ -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() diff --git a/internal/ui/model/history_integration_test.go b/internal/ui/model/history_integration_test.go new file mode 100644 index 0000000000..54beeb9c62 --- /dev/null +++ b/internal/ui/model/history_integration_test.go @@ -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()) +} diff --git a/internal/ui/model/history_test.go b/internal/ui/model/history_test.go new file mode 100644 index 0000000000..0656485d4e --- /dev/null +++ b/internal/ui/model/history_test.go @@ -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 +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 6b60b50371..5565180a80 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -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() @@ -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: @@ -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