From 0d865c9bb829bc498d8894ef84cde3ce71b7a455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Fiere?= Date: Wed, 19 Aug 2026 22:28:01 +0200 Subject: [PATCH] feat(prompt-history): add prompt numbers and auto-load older pages (#2814) --- .../internal/db/dialect/promptindex.go | 60 +++ .../internal/office/testharness/routes.go | 51 ++- .../office/testharness/routes_test.go | 246 +++++++++++ .../executor/executor_mocks_test.go | 5 + .../task/handlers/message_handlers.go | 3 +- .../handlers/message_handlers_blocked_test.go | 8 + .../task/handlers/process_handlers_test.go | 5 + apps/backend/internal/task/models/models.go | 7 + .../internal/task/repository/interface.go | 5 + .../task/repository/sqlite/base_migrations.go | 54 +++ .../task/repository/sqlite/base_schema.go | 14 + .../sqlite/clarification_scanner_test.go | 146 +++++++ .../task/repository/sqlite/message.go | 136 +++++- .../repository/sqlite/message_prompt_index.go | 263 ++++++++++++ .../message_prompt_index_postgres_test.go | 280 ++++++++++++ .../sqlite/prompt_index_create_test.go | 269 ++++++++++++ .../repository/sqlite/prompt_index_test.go | 301 +++++++++++++ .../internal/task/service/service_events.go | 7 + .../internal/task/service/service_messages.go | 52 ++- .../task/service/service_prompt_index_test.go | 328 ++++++++++++++ apps/backend/pkg/api/v1/task.go | 5 + apps/web/app/page.tsx | 1 + .../task/chat/message-list-native-scroll.ts | 69 +-- .../task/chat/message-list-native.tsx | 7 +- .../task/chat/message-list-shared.test.tsx | 2 +- .../chat/use-drain-older-messages.test.ts | 115 ++++- .../task/chat/use-drain-older-messages.ts | 81 ++-- .../prompt-history-panel-content.test.tsx | 343 ++++++++++++++- .../task/prompt-history-panel-content.tsx | 160 ++++++- apps/web/components/task/task-chat-panel.tsx | 23 +- apps/web/e2e/helpers/api-client.ts | 12 +- .../e2e/helpers/prompt-history-long-seed.ts | 102 +++++ .../task/mobile-prompt-history-panel.spec.ts | 121 +++++- .../task/prompt-history-auto-load.spec.ts | 105 +++++ .../tests/task/prompt-history-panel.spec.ts | 13 +- .../hooks/domains/session/message-backfill.ts | 73 ++-- .../session/older-message-pagination.test.ts | 228 ++++++++++ .../session/older-message-pagination.ts | 133 ++++++ .../session/use-session-messages.test.ts | 55 ++- .../domains/session/use-session-messages.ts | 2 +- apps/web/hooks/use-lazy-load-messages.test.ts | 116 +++++ apps/web/hooks/use-lazy-load-messages.ts | 151 +++---- apps/web/hooks/use-lazy-load-sentinel.test.ts | 405 ++++++++++++++++++ apps/web/hooks/use-lazy-load-sentinel.ts | 196 +++++++++ apps/web/lib/prompt-history.test.ts | 128 +++++- apps/web/lib/prompt-history.ts | 100 ++++- apps/web/lib/ssr/mapper.ts | 1 + .../state/slices/session/message-signature.ts | 35 +- .../session-slice.merge-messages.test.ts | 71 +++ .../lib/state/slices/session/session-slice.ts | 20 +- apps/web/lib/state/slices/session/types.ts | 10 +- apps/web/lib/state/store.ts | 7 +- apps/web/lib/types/http.ts | 4 + apps/web/lib/types/session-events.ts | 3 + apps/web/lib/ws/handlers/messages.test.ts | 76 +++- apps/web/lib/ws/handlers/messages.ts | 1 + .../prompt-history-numbers-autoload/plan.md | 104 +++++ .../task-01-backend-prompt-index.md | 75 ++++ .../task-02-frontend-numbering.md | 75 ++++ .../task-03-frontend-auto-load.md | 86 ++++ .../task-04-e2e-and-harness.md | 81 ++++ docs/specs/ui/prompt-history-panel.md | 30 +- 62 files changed, 5352 insertions(+), 313 deletions(-) create mode 100644 apps/backend/internal/db/dialect/promptindex.go create mode 100644 apps/backend/internal/task/repository/sqlite/clarification_scanner_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/message_prompt_index.go create mode 100644 apps/backend/internal/task/repository/sqlite/message_prompt_index_postgres_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/prompt_index_create_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/prompt_index_test.go create mode 100644 apps/backend/internal/task/service/service_prompt_index_test.go create mode 100644 apps/web/e2e/helpers/prompt-history-long-seed.ts create mode 100644 apps/web/e2e/tests/task/prompt-history-auto-load.spec.ts create mode 100644 apps/web/hooks/domains/session/older-message-pagination.test.ts create mode 100644 apps/web/hooks/domains/session/older-message-pagination.ts create mode 100644 apps/web/hooks/use-lazy-load-messages.test.ts create mode 100644 apps/web/hooks/use-lazy-load-sentinel.test.ts create mode 100644 apps/web/hooks/use-lazy-load-sentinel.ts create mode 100644 docs/plans/prompt-history-numbers-autoload/plan.md create mode 100644 docs/plans/prompt-history-numbers-autoload/task-01-backend-prompt-index.md create mode 100644 docs/plans/prompt-history-numbers-autoload/task-02-frontend-numbering.md create mode 100644 docs/plans/prompt-history-numbers-autoload/task-03-frontend-auto-load.md create mode 100644 docs/plans/prompt-history-numbers-autoload/task-04-e2e-and-harness.md diff --git a/apps/backend/internal/db/dialect/promptindex.go b/apps/backend/internal/db/dialect/promptindex.go new file mode 100644 index 0000000000..43918a5619 --- /dev/null +++ b/apps/backend/internal/db/dialect/promptindex.go @@ -0,0 +1,60 @@ +package dialect + +import "fmt" + +// NormalizedMicrosecond returns a SQL expression evaluating to the UTC +// microsecond-ordering key of a message timestamp: exactly +// `YYYY-MM-DD HH:MM:SS.ffffff` (space separator, zero-padded 6-digit fraction, +// no zone suffix). It is the single source of truth for the prompt-ordering +// key used by the paginated message window `ORDER BY`, the outer cursor +// predicate/ordering, and the single-row correlated count, so SQLite, +// PostgreSQL, and the frontend's `epochNanoseconds / 1000n` UTC floor all +// agree on row order. +// +// On PostgreSQL the column is a naive TIMESTAMP storing UTC wall-clock at +// microsecond precision, so the column itself is the key (an identity/cast). +// On SQLite the column is TEXT written by mattn/go-sqlite3 as +// `YYYY-MM-DD HH:MM:SS.fffffffff+00:00` (Go's `.999999999` layout trims +// trailing zeros; the offset is always present, and exact-second values have +// no fraction at all). datetime() parses the FULL stored string — wall clock, +// fraction, and offset — and converts to UTC at second precision; the fraction +// is carried separately (right-padded to 9 digits, truncated to 6) because +// datetime() and strftime('%f') drop sub-second precision and julianday lacks +// float precision for microseconds. The fraction dot, when present, sits at +// fixed position 20 in both the driver format and RFC3339 forms; the digit +// run starts at position 21 and ends at the first `+`/`-`/`Z`. Without a dot +// the run is empty (exact-second and offset-only forms). +func NormalizedMicrosecond(driver, expr string) string { + if IsPostgres(driver) { + return expr + } + rest := "substr(" + expr + ", 21)" + digitRun := "CASE " + + "WHEN instr(" + rest + ", '+') > 0 THEN substr(" + expr + ", 21, instr(" + rest + ", '+') - 1) " + + "WHEN instr(" + rest + ", '-') > 0 THEN substr(" + expr + ", 21, instr(" + rest + ", '-') - 1) " + + "WHEN instr(" + rest + ", 'Z') > 0 THEN substr(" + expr + ", 21, instr(" + rest + ", 'Z') - 1) " + + "ELSE " + rest + " END" + return fmt.Sprintf( + "substr(datetime(%s), 1, 19) || '.' || substr((CASE WHEN substr(%s, 20, 1) = '.' THEN %s ELSE '' END) || '000000000', 1, 6)", + expr, expr, digitRun, + ) +} + +// PromptOrderIndexDDL returns the additive expression index that turns the +// prompt-ordering window pass into an index-only scan with no per-page sort +// and serves the outer cursor predicate and ORDER BY. The indexed expression +// textually equals NormalizedMicrosecond output, so the query expression and +// the index cannot drift. CREATE INDEX IF NOT EXISTS is replay-safe on both +// dialects. +func PromptOrderIndexDDL(driver, indexName, table string) string { + if IsPostgres(driver) { + return fmt.Sprintf( + "CREATE INDEX IF NOT EXISTS %s ON %s(task_session_id, created_at, id)", + indexName, table, + ) + } + return fmt.Sprintf( + "CREATE INDEX IF NOT EXISTS %s ON %s(task_session_id, (%s), id)", + indexName, table, NormalizedMicrosecond(driver, "created_at"), + ) +} diff --git a/apps/backend/internal/office/testharness/routes.go b/apps/backend/internal/office/testharness/routes.go index 082fac74cd..28cf9c8705 100644 --- a/apps/backend/internal/office/testharness/routes.go +++ b/apps/backend/internal/office/testharness/routes.go @@ -574,20 +574,31 @@ func publishSessionStateChanged(ctx context.Context, eventBus bus.EventBus, sess } } +// messageCreatedAtKey is the JSON key for seeded message timestamps. +const messageCreatedAtKey = "created_at" + type seedMessageRequest struct { SessionID string `json:"session_id"` Type string `json:"type"` Content string `json:"content,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` + // AuthorType defaults to "agent"; "user" seeds a prompt row whose + // prompt_index is computed by the repository's atomic create boundary. + AuthorType string `json:"author_type,omitempty"` + // CreatedAt is an optional RFC3339 timestamp used for deterministic + // ordering; when omitted the repository assigns a live timestamp. + CreatedAt *time.Time `json:"created_at,omitempty"` // TurnMetadata is persisted on the ensured turn (the same turn the seeded // message is attached to), so e2e specs can exercise the message metadata // dialog's turn_metadata field end to end. TurnMetadata map[string]interface{} `json:"turn_metadata,omitempty"` } -// seedMessageHandler inserts a synthetic agent message, ensuring an active -// turn exists for the session and applying optional turn_metadata to it so -// e2e specs can exercise the message metadata dialog end to end. +// seedMessageHandler inserts a synthetic message, ensuring an active turn +// exists for the session and applying optional turn_metadata to it so e2e +// specs can exercise the message metadata dialog end to end. author_type +// defaults to agent; user seeds are read back through GetMessageWithPromptIndex +// so the WS event carries the same prompt_index the HTTP list path reports. func seedMessageHandler(repo *sqliterepo.Repository, eventBus bus.EventBus, log *logger.Logger) gin.HandlerFunc { return func(c *gin.Context) { var req seedMessageRequest @@ -638,16 +649,26 @@ func seedMessageHandler(repo *sqliterepo.Repository, eventBus bus.EventBus, log } meta["seeded_by_e2e_mock"] = true + authorType := models.MessageAuthorAgent + if req.AuthorType == string(models.MessageAuthorUser) { + authorType = models.MessageAuthorUser + } msg := &models.Message{ ID: uuid.New().String(), TaskSessionID: session.ID, TaskID: session.TaskID, TurnID: turnID, - AuthorType: models.MessageAuthorAgent, + AuthorType: authorType, Type: models.MessageType(req.Type), Content: req.Content, Metadata: meta, - CreatedAt: time.Now().UTC(), + } + // CreatedAt stays zero unless explicitly supplied: the repository + // boundary treats a zero timestamp as a LIVE create (advancing a + // colliding key), while explicit timestamps must be strictly after + // the session's newest user message. + if req.CreatedAt != nil { + msg.CreatedAt = req.CreatedAt.UTC() } if err := repo.CreateMessage(ctx, msg); err != nil { log.Error("test harness: create message failed", zap.Error(err)) @@ -655,11 +676,23 @@ func seedMessageHandler(repo *sqliterepo.Repository, eventBus bus.EventBus, log return } + // User seeds carry the repository-computed ordinal (and any timestamp + // advance the boundary applied), so the WS event matches the HTTP + // list path exactly. + if msg.AuthorType == models.MessageAuthorUser { + if indexed, err := repo.GetMessageWithPromptIndex(ctx, msg.ID); err == nil { + msg = indexed + } else { + log.Warn("test harness: re-read indexed seed failed", zap.Error(err)) + } + } + publishMessageAdded(ctx, eventBus, msg, log) c.JSON(http.StatusOK, gin.H{"message_id": msg.ID}) } } +// publishMessageAdded emits a message-added bus event for a seeded message so connected clients receive it without a refresh. func publishMessageAdded(ctx context.Context, eventBus bus.EventBus, msg *models.Message, log *logger.Logger) { if eventBus == nil { return @@ -673,7 +706,13 @@ func publishMessageAdded(ctx context.Context, eventBus bus.EventBus, msg *models "content": msg.Content, "type": string(msg.Type), "requests_input": msg.RequestsInput, - "created_at": msg.CreatedAt.Format(time.RFC3339), + // RFC3339Nano preserves fractional precision so seeded user prompts + // order deterministically on the client. + messageCreatedAtKey: msg.CreatedAt.Format(time.RFC3339Nano), + } + // User rows carry their stable prompt ordinal in the live WS event. + if msg.PromptIndex > 0 { + data["prompt_index"] = msg.PromptIndex } if msg.Metadata != nil { data["metadata"] = msg.Metadata diff --git a/apps/backend/internal/office/testharness/routes_test.go b/apps/backend/internal/office/testharness/routes_test.go index 40f42f9a77..e1b4002762 100644 --- a/apps/backend/internal/office/testharness/routes_test.go +++ b/apps/backend/internal/office/testharness/routes_test.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "testing" "time" @@ -19,6 +20,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" officemodels "github.com/kandev/kandev/internal/office/models" officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite" @@ -517,6 +519,250 @@ func TestSeedMessageRejectsUnknownSession(t *testing.T) { } } +// seedMessageAndCollect seeds a session + a message through the harness routes +// and returns the repo, the session id, the response message id, and any +// message.added events published to the bus. +func seedMessageAndCollect(t *testing.T, body map[string]interface{}) (*sqliterepo.Repository, string, string, []*bus.Event) { + t.Helper() + repo, sqlxDB := newTestRepo(t) + taskID := uuid.New().String() + seedTask(t, sqlxDB, taskID) + eb := bus.NewMemoryEventBus(logger.Default()) + var published []*bus.Event + var mu sync.Mutex + if _, err := eb.Subscribe(events.MessageAdded, func(_ context.Context, e *bus.Event) error { + mu.Lock() + defer mu.Unlock() + published = append(published, e) + return nil + }); err != nil { + t.Fatalf("subscribe: %v", err) + } + r := newRouter(t, repo, eb) + + sessBody := mustJSON(t, map[string]interface{}{"task_id": taskID, "state": "RUNNING"}) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/_test/task-sessions", bytes.NewReader(sessBody)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("session seed: expected 200, got %d body=%s", w.Code, w.Body.String()) + } + var sessResp seedTaskSessionResponse + _ = json.Unmarshal(w.Body.Bytes(), &sessResp) + + body["session_id"] = sessResp.SessionID + msgBody := mustJSON(t, body) + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodPost, "/api/v1/_test/messages", bytes.NewReader(msgBody)) + req2.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w2, req2) + if w2.Code != http.StatusOK { + t.Fatalf("message seed: expected 200, got %d body=%s", w2.Code, w2.Body.String()) + } + var resp struct { + MessageID string `json:"message_id"` + } + _ = json.Unmarshal(w2.Body.Bytes(), &resp) + return repo, sessResp.SessionID, resp.MessageID, published +} + +// TestSeedMessageUserAuthorPersistsPromptIndex verifies that a seeded user message is persisted with a derived prompt index. +func TestSeedMessageUserAuthorPersistsPromptIndex(t *testing.T) { + repo, _, messageID, published := seedMessageAndCollect(t, map[string]interface{}{ + "type": "message", + "content": "user prompt", + "author_type": "user", + }) + + indexed, err := repo.GetMessageWithPromptIndex(context.Background(), messageID) + if err != nil { + t.Fatalf("GetMessageWithPromptIndex: %v", err) + } + if indexed.AuthorType != models.MessageAuthorUser { + t.Fatalf("author type = %q, want user", indexed.AuthorType) + } + if indexed.PromptIndex != 1 { + t.Fatalf("prompt_index = %d, want 1", indexed.PromptIndex) + } + // The ordinal is read-time derived: the legacy hot path stays zero. + legacy, err := repo.GetMessage(context.Background(), messageID) + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if legacy.PromptIndex != 0 { + t.Fatalf("legacy GetMessage prompt_index = %d, want 0", legacy.PromptIndex) + } + + // The published WS event carries the ordinal and RFC3339Nano precision. + if len(published) != 1 { + t.Fatalf("published events = %d, want 1", len(published)) + } + data, ok := published[0].Data.(map[string]interface{}) + if !ok { + t.Fatalf("event data = %T, want map", published[0].Data) + } + if got, ok := data["prompt_index"]; !ok || got != 1 { + t.Fatalf("event prompt_index = %#v, want 1", got) + } + if created, ok := data["created_at"].(string); !ok || !strings.Contains(created, ".") { + t.Fatalf("event created_at = %#v, want RFC3339Nano fractional precision", data["created_at"]) + } +} + +// TestSeedMessageDefaultsToAgentWithoutPromptIndex verifies that non-user seeded messages carry no prompt index. +func TestSeedMessageDefaultsToAgentWithoutPromptIndex(t *testing.T) { + repo, _, messageID, published := seedMessageAndCollect(t, map[string]interface{}{ + "type": "message", + "content": "agent reply", + }) + + indexed, err := repo.GetMessageWithPromptIndex(context.Background(), messageID) + if err != nil { + t.Fatalf("GetMessageWithPromptIndex: %v", err) + } + if indexed.AuthorType != models.MessageAuthorAgent { + t.Fatalf("author type = %q, want agent default", indexed.AuthorType) + } + if indexed.PromptIndex != 0 { + t.Fatalf("agent prompt_index = %d, want 0", indexed.PromptIndex) + } + if len(published) != 1 { + t.Fatalf("published events = %d, want 1", len(published)) + } + data, ok := published[0].Data.(map[string]interface{}) + if !ok { + t.Fatalf("event data = %T, want map", published[0].Data) + } + if _, ok := data["prompt_index"]; ok { + t.Fatalf("agent event carries prompt_index %v", data["prompt_index"]) + } +} + +// TestSeedMessageExplicitPastTimestampRejected verifies that explicit timestamps before the newest session message are rejected. +func TestSeedMessageExplicitPastTimestampRejected(t *testing.T) { + repo, _, messageID, _ := seedMessageAndCollect(t, map[string]interface{}{ + "type": "message", + "content": "first", + "author_type": "user", + }) + // A second user seed with an explicit timestamp EARLIER than the newest + // user message is an invalid reordering: the explicit-import branch + // rejects it (the live branch would have advanced instead). + past := time.Now().UTC().Add(-time.Hour).Format(time.RFC3339Nano) + second := mustJSON(t, map[string]interface{}{ + "session_id": seedSessionIDFromMessage(t, repo, messageID), + "type": "message", + "content": "second", + "author_type": "user", + "created_at": past, + }) + r := newRouter(t, repo, nil) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/_test/messages", bytes.NewReader(second)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + if w.Code != http.StatusInternalServerError { + t.Fatalf("explicit-past seed status = %d body=%s, want 500 (strictly-after-max rejection)", w.Code, w.Body.String()) + } +} + +// seedSessionIDFromMessage resolves the session id for a seeded message. +func seedSessionIDFromMessage(t *testing.T, repo *sqliterepo.Repository, messageID string) string { + t.Helper() + msg, err := repo.GetMessage(context.Background(), messageID) + if err != nil { + t.Fatalf("GetMessage(%s): %v", messageID, err) + } + return msg.TaskSessionID +} + +// TestSeedMessageExplicitCreatedAtPreserved verifies that an explicit future-ordered created_at is preserved on the persisted row. +func TestSeedMessageExplicitCreatedAtPreserved(t *testing.T) { + explicit := time.Date(2026, 8, 19, 10, 0, 0, 123456000, time.UTC) + repo, _, messageID, published := seedMessageAndCollect(t, map[string]interface{}{ + "type": "message", + "content": "deterministic prompt", + "author_type": "user", + "created_at": explicit.Format(time.RFC3339Nano), + }) + + stored, err := repo.GetMessage(context.Background(), messageID) + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if !stored.CreatedAt.Equal(explicit) { + t.Fatalf("stored created_at = %v, want explicit %v", stored.CreatedAt, explicit) + } + indexed, err := repo.GetMessageWithPromptIndex(context.Background(), messageID) + if err != nil { + t.Fatalf("GetMessageWithPromptIndex: %v", err) + } + if indexed.PromptIndex != 1 { + t.Fatalf("prompt_index = %d, want 1", indexed.PromptIndex) + } + // The WS event preserves the explicit timestamp with full fractional + // precision (RFC3339Nano), not second-truncated RFC3339. + if len(published) != 1 { + t.Fatalf("published events = %d, want 1", len(published)) + } + data, ok := published[0].Data.(map[string]interface{}) + if !ok { + t.Fatalf("event data = %T, want map", published[0].Data) + } + created, ok := data["created_at"].(string) + if !ok { + t.Fatalf("event created_at = %#v, want string", data["created_at"]) + } + parsed, err := time.Parse(time.RFC3339Nano, created) + if err != nil { + t.Fatalf("event created_at %q not RFC3339Nano: %v", created, err) + } + if !parsed.Equal(explicit) { + t.Fatalf("event created_at = %v, want %v", parsed, explicit) + } +} + +// TestSeedMessageUserOrdinalsIncrement verifies that consecutive seeded user messages receive strictly increasing ordinals. +func TestSeedMessageUserOrdinalsIncrement(t *testing.T) { + repo, sessionID, _, _ := seedMessageAndCollect(t, map[string]interface{}{ + "type": "message", + "content": "first", + "author_type": "user", + }) + // Second user seed on the same session gets ordinal 2. + second := mustJSON(t, map[string]interface{}{ + "session_id": sessionID, + "type": "message", + "content": "second", + "author_type": "user", + }) + r := newRouter(t, repo, nil) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/_test/messages", bytes.NewReader(second)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("second seed: expected 200, got %d body=%s", w.Code, w.Body.String()) + } + msgs, err := repo.ListMessages(context.Background(), sessionID) + if err != nil { + t.Fatalf("list messages: %v", err) + } + if len(msgs) != 2 { + t.Fatalf("messages = %d, want 2", len(msgs)) + } + for _, m := range msgs { + indexed, err := repo.GetMessageWithPromptIndex(context.Background(), m.ID) + if err != nil { + t.Fatalf("GetMessageWithPromptIndex(%s): %v", m.ID, err) + } + if indexed.AuthorType == models.MessageAuthorUser && indexed.PromptIndex < 1 { + t.Fatalf("user message %s prompt_index = %d, want >= 1", m.ID, indexed.PromptIndex) + } + } +} + // TestSeedMessagePersistsTurnMetadata covers the harness turn-metadata // contract: a later seed updates the pre-existing active turn's metadata, and // a seed without turn_metadata leaves existing metadata untouched. diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go index bb8fdd7eec..b07deeddde 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go @@ -760,6 +760,11 @@ func (m *mockRepository) CreateMessage(ctx context.Context, message *models.Mess func (m *mockRepository) GetMessage(ctx context.Context, id string) (*models.Message, error) { return nil, nil } + +// GetMessageWithPromptIndex returns the message for id with its derived prompt index, mirroring the repository contract. +func (m *mockRepository) GetMessageWithPromptIndex(ctx context.Context, id string) (*models.Message, error) { + return nil, nil +} func (m *mockRepository) GetMessageByToolCallID(ctx context.Context, sessionID, toolCallID string) (*models.Message, error) { return nil, nil } diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go index 6f77b940d6..15fcc6be89 100644 --- a/apps/backend/internal/task/handlers/message_handlers.go +++ b/apps/backend/internal/task/handlers/message_handlers.go @@ -290,6 +290,7 @@ type wsAddMessageRequest struct { EntityReferences []v1.EntityReference `json:"entity_references,omitempty"` } +// wsAddMessage handles an incoming add-message WebSocket action, persisting the user message and dispatching the turn and orchestrator flow. func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req wsAddMessageRequest if err := msg.ParsePayload(&req); err != nil { @@ -312,7 +313,7 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w // before checking the live session state or running turn-start hooks so a // retry is a read, not a second prompt. if req.ClientMessageID != "" { - existing, err := h.service.GetMessage(ctx, req.ClientMessageID) + existing, err := h.service.GetMessageWithPromptIndex(ctx, req.ClientMessageID) switch { case err == nil && existing != nil: // The turn-start hook may switch the task's primary session before diff --git a/apps/backend/internal/task/handlers/message_handlers_blocked_test.go b/apps/backend/internal/task/handlers/message_handlers_blocked_test.go index 6ccaddecf7..82e1475053 100644 --- a/apps/backend/internal/task/handlers/message_handlers_blocked_test.go +++ b/apps/backend/internal/task/handlers/message_handlers_blocked_test.go @@ -66,6 +66,14 @@ func (r *messageAddSwitchRepo) GetMessage(_ context.Context, id string) (*models return nil, sql.ErrNoRows } +// GetMessageWithPromptIndex returns the message for id with its derived prompt index, mirroring the repository contract. +func (r *messageAddSwitchRepo) GetMessageWithPromptIndex(_ context.Context, id string) (*models.Message, error) { + if r.idempotentMessage != nil && r.idempotentMessage.ID == id { + return r.idempotentMessage, nil + } + return nil, sql.ErrNoRows +} + func (r *messageAddSwitchRepo) GetTask(_ context.Context, id string) (*models.Task, error) { r.taskGetCalls++ if task, ok := r.tasks[id]; ok { diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index 58ca3c0b3b..c680abf4ce 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -223,6 +223,11 @@ func (m *mockRepository) CreateMessage(ctx context.Context, message *models.Mess func (m *mockRepository) GetMessage(ctx context.Context, id string) (*models.Message, error) { return nil, nil } + +// GetMessageWithPromptIndex returns the message for id with its derived prompt index, mirroring the repository contract. +func (m *mockRepository) GetMessageWithPromptIndex(ctx context.Context, id string) (*models.Message, error) { + return nil, nil +} func (m *mockRepository) GetMessageByToolCallID(ctx context.Context, sessionID, toolCallID string) (*models.Message, error) { return nil, nil } diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index cbbf51478a..a17df042f1 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -1141,6 +1141,12 @@ type Message struct { RequestsInput bool `json:"requests_input"` // True if agent is requesting user input CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` // Authoritative per-message change signal + // PromptIndex is the 1-based ordinal of the message among ALL user + // messages of its session, ordered by normalized-microsecond created_at + // ascending with ties broken by id ascending. Read-time derived: zero for + // non-user messages and for legacy 12-column reads, and serialized only + // when > 0 (json omitempty). + PromptIndex int `json:"prompt_index,omitempty"` } // ToAPI converts internal Message to API type. @@ -1175,6 +1181,7 @@ func (m *Message) ToAPI() *v1.Message { RequestsInput: m.RequestsInput, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, + PromptIndex: m.PromptIndex, } if hasHidden { result.RawContent = m.Content diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index f1d87415c2..a7239e34b5 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -201,6 +201,11 @@ type WorkflowRepository interface { type MessageRepository interface { CreateMessage(ctx context.Context, message *models.Message) error GetMessage(ctx context.Context, id string) (*models.Message, error) + // GetMessageWithPromptIndex retrieves a message by ID with its computed + // prompt_index (1-based ordinal among the session's user messages). + // Used by the idempotent WS replay/response path and user update-event + // publication; hot-path reads stay on GetMessage. + GetMessageWithPromptIndex(ctx context.Context, id string) (*models.Message, error) GetMessageByToolCallID(ctx context.Context, sessionID, toolCallID string) (*models.Message, error) GetMessageByPendingID(ctx context.Context, sessionID, pendingID string) (*models.Message, error) FindMessageByPendingID(ctx context.Context, pendingID string) (*models.Message, error) diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations.go b/apps/backend/internal/task/repository/sqlite/base_migrations.go index 00a5dba837..e6f4079575 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations.go @@ -365,6 +365,60 @@ func (r *Repository) runMigrations() error { // backfill belongs in the migration phase. r.migrateSubagentContextBackfill() + // Durable per-session prompt ordinals. prompt_seq is allocated from a + // per-session sequence counter inside the create write boundary, so an + // ordinal survives message deletion and clock corrections. Previously the + // ordinal was derived from the session's remaining user rows (a correlated + // count), which renumbered prompts after a delete. SQLite forbids + // non-constant column defaults, so the column is added with DEFAULT 0 and + // existing user rows are backfilled with the previously derived ordinal + // (count of user rows ordered before them by the normalized microsecond + // key, ties by id); the counter is seeded to each session's backfilled + // maximum. The backfill predicate (prompt_seq = 0) and ON CONFLICT keep + // every statement idempotent across replays. + r.migrate.Apply("task_session_messages.prompt_seq", `ALTER TABLE task_session_messages ADD COLUMN prompt_seq INTEGER NOT NULL DEFAULT 0`) + r.migrate.Apply("task_session_prompt_seq.table", ` + CREATE TABLE IF NOT EXISTS task_session_prompt_seq ( + task_session_id TEXT PRIMARY KEY, + last_seq INTEGER NOT NULL + )`) + if err := r.backfillPromptSeq(); err != nil { + return err + } + + return nil +} + +// backfillPromptSeq assigns existing user rows their previously derived +// ordinal (the correlated count over the session's user rows using the same +// normalized-microsecond predicate the create boundary used) and seeds each +// session's sequence counter at its backfilled maximum. Idempotent: user rows +// already carrying a nonzero prompt_seq are untouched, and the counter seed +// ignores existing rows. +func (r *Repository) backfillPromptSeq() error { + nmU := dialect.NormalizedMicrosecond(r.db.DriverName(), "u.created_at") + nmM := dialect.NormalizedMicrosecond(r.db.DriverName(), "task_session_messages.created_at") + update := fmt.Sprintf(` + UPDATE task_session_messages + SET prompt_seq = ( + SELECT COUNT(*) FROM task_session_messages u + WHERE u.task_session_id = task_session_messages.task_session_id + AND u.author_type = 'user' + AND (%s < %s OR (%s = %s AND u.id <= task_session_messages.id)) + ) + WHERE author_type = 'user' AND prompt_seq = 0`, nmU, nmM, nmU, nmM) + if _, err := r.db.Exec(update); err != nil { + return fmt.Errorf("backfill prompt_seq: %w", err) + } + seed := ` + INSERT INTO task_session_prompt_seq (task_session_id, last_seq) + SELECT task_session_id, MAX(prompt_seq) FROM task_session_messages + WHERE author_type = 'user' AND prompt_seq > 0 + GROUP BY task_session_id + ON CONFLICT(task_session_id) DO NOTHING` + if _, err := r.db.Exec(seed); err != nil { + return fmt.Errorf("seed prompt sequence counters: %w", err) + } return nil } diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index 99452bee10..c32c9a81f6 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -39,6 +39,7 @@ func (r *Repository) initSchema() error { r.healSessionTaskEnvironmentIDs, r.ensureWorkspaceIndexes, r.ensureMessageMetadataIndexes, + r.ensurePromptOrderIndex, } for _, step := range steps { if err := step(); err != nil { @@ -109,6 +110,19 @@ func (r *Repository) ensureMessageMetadataIndexes() error { return nil } +// ensurePromptOrderIndex creates the additive expression index +// (task_session_id, normalized_microsecond(created_at), id) that makes the +// prompt-ordering window pass index-only. Read-time derived prompt ordinals +// need no data-column or table-shape migration; this one additive index is +// the only schema change. +func (r *Repository) ensurePromptOrderIndex() error { + ddl := dialect.PromptOrderIndexDDL(r.db.DriverName(), "idx_messages_prompt_order", "task_session_messages") + if _, err := r.db.Exec(ddl); err != nil { + return fmt.Errorf("create prompt-order index: %w", err) + } + return nil +} + // ensureRunnerProjectionTables creates stub workflow_steps and // workflow_step_participants tables if they're not yet present. The // task repo's task SELECT projection includes a correlated subquery diff --git a/apps/backend/internal/task/repository/sqlite/clarification_scanner_test.go b/apps/backend/internal/task/repository/sqlite/clarification_scanner_test.go new file mode 100644 index 0000000000..1e0772d339 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/clarification_scanner_test.go @@ -0,0 +1,146 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +// seedClarificationBundle seeds a session/turn plus a pending multi-question +// clarification bundle (two clarification_request messages sharing one +// pending_id), the fixture every clarification repository path reads. +func seedClarificationBundle(t *testing.T, repo *Repository, taskID, sessionID, turnID, pendingID string, base time.Time) { + t.Helper() + seedPendingActionSession(t, repo, taskID, sessionID) + createPendingActionTurn(t, repo, taskID, sessionID, turnID, base, base) + createClarificationBundleMessage(t, repo, taskID+"-m1", taskID, sessionID, turnID, pendingID, "q1", base) + createClarificationBundleMessage(t, repo, taskID+"-m2", taskID, sessionID, turnID, pendingID, "q2", base.Add(time.Second)) +} + +// TestClarificationScannerRegression exercises every legacy 12-column +// scanMessageRows / inline-scanner caller in the clarification delivery and +// response paths after the enriched 13-column scanner was introduced: they +// must still scan and return full messages. +func TestClarificationScannerRegression(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 17, 9, 30, 0, 0, time.UTC) + + // Active-lookup and multi-row pending lookup (scanMessageRows). + seedClarificationBundle(t, repo, "task-clar-scan", "session-clar-scan", "turn-clar-scan", "pending-scan", base) + active, err := repo.FindActiveClarificationMessagesBySessionID(ctx, "session-clar-scan") + if err != nil { + t.Fatalf("FindActiveClarificationMessagesBySessionID: %v", err) + } + if len(active) != 2 { + t.Fatalf("active clarifications = %d, want 2", len(active)) + } + if active[0].Type != models.MessageTypeClarificationRequest || active[0].Metadata["pending_id"] != "pending-scan" { + t.Errorf("active row lost type/metadata: %+v", active[0]) + } + + byPending, err := repo.FindMessagesByPendingID(ctx, "pending-scan") + if err != nil { + t.Fatalf("FindMessagesByPendingID: %v", err) + } + if len(byPending) != 2 { + t.Fatalf("pending bundle = %d rows, want 2", len(byPending)) + } + if byPending[0].Metadata["pending_id"] != "pending-scan" { + t.Errorf("pending bundle row lost metadata: %+v", byPending[0].Metadata) + } + + // Single-row inline 12-column scans. + single, err := repo.GetMessageByPendingID(ctx, "session-clar-scan", "pending-scan") + if err != nil { + t.Fatalf("GetMessageByPendingID: %v", err) + } + if single.Metadata["question_id"] != "q1" { + t.Errorf("GetMessageByPendingID metadata = %+v, want q1", single.Metadata) + } + one, err := repo.FindMessageByPendingID(ctx, "pending-scan") + if err != nil { + t.Fatalf("FindMessageByPendingID: %v", err) + } + if one.ID != "task-clar-scan-m2" { // newest of the bundle + t.Errorf("FindMessageByPendingID = %s, want newest bundle row", one.ID) + } + perQuestion, err := repo.FindMessageByPendingIDAndQuestion(ctx, "session-clar-scan", "pending-scan", "q1") + if err != nil { + t.Fatalf("FindMessageByPendingIDAndQuestion: %v", err) + } + if perQuestion.ID != "task-clar-scan-m1" { + t.Errorf("FindMessageByPendingIDAndQuestion = %s, want q1 row", perQuestion.ID) + } + + // Detach (RETURNING scanMessageRows). + detached, err := repo.DetachActiveClarificationMessagesBySessionID(ctx, "session-clar-scan") + if err != nil { + t.Fatalf("DetachActiveClarificationMessagesBySessionID: %v", err) + } + if len(detached) != 2 { + t.Fatalf("detached rows = %d, want 2", len(detached)) + } + if detached[0].TaskSessionID != "session-clar-scan" || detached[0].Metadata["agent_disconnected"] != true { + t.Errorf("detached row lost fields: %+v", detached[0]) + } + + // Expire (RETURNING scanMessageRows) on a fresh bundle. + seedClarificationBundle(t, repo, "task-clar-expire", "session-clar-expire", "turn-clar-expire", "pending-expire", base.Add(time.Hour)) + expired, err := repo.ExpireActiveClarificationBundle(ctx, "session-clar-expire", "pending-expire") + if err != nil { + t.Fatalf("ExpireActiveClarificationBundle: %v", err) + } + if len(expired) != 2 { + t.Fatalf("expired rows = %d, want 2", len(expired)) + } + if expired[0].Metadata["status"] != "expired" { + t.Errorf("expired metadata status = %v, want expired", expired[0].Metadata["status"]) + } + + // Complete → Restore → Complete → Finalize: the claim/restorable/delivery + // bundle loads all scan through the legacy 12-column scanner. + seedClarificationBundle(t, repo, "task-clar-claim", "session-clar-claim", "turn-clar-claim", "pending-claim", base.Add(2*time.Hour)) + responses := map[string]interface{}{ + "q1": map[string]interface{}{"question_id": "q1", "custom_text": "yes"}, + "q2": map[string]interface{}{"question_id": "q2", "custom_text": "also yes"}, + } + claimed, claimedOK, err := repo.CompleteActiveClarificationBundle(ctx, "pending-claim", clarificationStatusAnswered, responses) + if err != nil || !claimedOK { + t.Fatalf("CompleteActiveClarificationBundle = claimed %v, %v; want true, nil", claimedOK, err) + } + if len(claimed) != 2 { + t.Fatalf("claimed rows = %d, want 2", len(claimed)) + } + if marker := claimed[0].Metadata[clarificationResponseDeliveryPendingKey]; marker != true { + t.Errorf("claimed row delivery marker = %v, want true", marker) + } + + restored, restoredOK, err := repo.RestoreActiveClarificationBundle(ctx, "pending-claim", clarificationStatusAnswered, claimed) + if err != nil || !restoredOK { + t.Fatalf("RestoreActiveClarificationBundle = restored %v, %v; want true, nil", restoredOK, err) + } + if len(restored) != 2 { + t.Fatalf("restored rows = %d, want 2", len(restored)) + } + if restored[0].Metadata["pending_id"] != "pending-claim" || restored[0].Metadata["status"] != "pending" { + t.Errorf("restored row lost fields: %+v", restored[0]) + } + + claimed2, claimedOK2, err := repo.CompleteActiveClarificationBundle(ctx, "pending-claim", clarificationStatusAnswered, responses) + if err != nil || !claimedOK2 { + t.Fatalf("second CompleteActiveClarificationBundle = claimed %v, %v; want true, nil", claimedOK2, err) + } + finalized, finalizedOK, err := repo.FinalizeClarificationResponseDelivery(ctx, "pending-claim", clarificationStatusAnswered, claimed2) + if err != nil || !finalizedOK { + t.Fatalf("FinalizeClarificationResponseDelivery = finalized %v, %v; want true, nil", finalizedOK, err) + } + if len(finalized) != 2 { + t.Fatalf("finalized rows = %d, want 2", len(finalized)) + } + if marker := finalized[0].Metadata[clarificationResponseDeliveryPendingKey]; marker != nil { + t.Errorf("finalized row still carries delivery marker: %v", marker) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/message.go b/apps/backend/internal/task/repository/sqlite/message.go index 5c78e2d164..5aee72e517 100644 --- a/apps/backend/internal/task/repository/sqlite/message.go +++ b/apps/backend/internal/task/repository/sqlite/message.go @@ -21,12 +21,6 @@ func (r *Repository) CreateMessage(ctx context.Context, message *models.Message) if message.ID == "" { message.ID = uuid.New().String() } - if message.CreatedAt.IsZero() { - message.CreatedAt = time.Now().UTC() - } - if message.UpdatedAt.IsZero() { - message.UpdatedAt = message.CreatedAt - } if message.AuthorType == "" { message.AuthorType = models.MessageAuthorUser } @@ -50,6 +44,20 @@ func (r *Repository) CreateMessage(ctx context.Context, message *models.Message) metadataJSON = string(metadataBytes) } + if message.AuthorType == models.MessageAuthorUser { + // User messages get their prompt-ordering timestamp and ordinal from + // the atomic per-session create boundary (see + // createUserMessageWithBoundary). Agent/tool messages stay on the hot + // path below. + return r.createUserMessageWithBoundary(ctx, message, requestsInput, messageType, metadataJSON) + } + + if message.CreatedAt.IsZero() { + message.CreatedAt = time.Now().UTC() + } + if message.UpdatedAt.IsZero() { + message.UpdatedAt = message.CreatedAt + } return r.insertMessageWithSessionLock(ctx, message, requestsInput, messageType, metadataJSON) } @@ -61,9 +69,9 @@ func (r *Repository) insertMessageRow( messageType, metadataJSON string, ) error { _, err := execer.ExecContext(ctx, r.db.Rebind(` - INSERT INTO task_session_messages (id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `), message.ID, message.TaskSessionID, message.TaskID, message.TurnID, message.AuthorType, message.AuthorID, message.Content, requestsInput, messageType, metadataJSON, message.CreatedAt, message.UpdatedAt) + INSERT INTO task_session_messages (id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at, updated_at, prompt_seq) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), message.ID, message.TaskSessionID, message.TaskID, message.TurnID, message.AuthorType, message.AuthorID, message.Content, requestsInput, messageType, metadataJSON, message.CreatedAt, message.UpdatedAt, message.PromptIndex) return err } @@ -169,13 +177,51 @@ func (r *Repository) ListMessagesPaginated(ctx context.Context, sessionID string if err != nil { return nil, false, err } - query, args := buildListMessagesQuery(sessionID, opts, cursor, sortDir, limit) + // The outer cursor bound is the cursor row's normalized-microsecond key, + // derived through the same SQL expression the per-row key uses — never a + // driver-parsed Go time.Time (mattn/go-sqlite3 serializes time.Time as + // RFC3339Nano, which mis-compares lexicographically against the + // space-separated per-row expression and silently shifts page boundaries). + var cursorKey string + if cursor != nil { + cursorKey, err = r.messageCursorKey(ctx, cursor.ID) + if err != nil { + return nil, false, err + } + } + query, args := buildListMessagesQuery(r.ro.DriverName(), sessionID, opts, cursor, cursorKey, sortDir, limit) rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...) if err != nil { return nil, false, err } defer func() { _ = rows.Close() }() - return scanMessageRows(rows, limit) + return scanPromptIndexedMessageRows(rows, limit) +} + +// messageCursorKey returns the normalized-microsecond ordering key of the +// cursor row, derived through the same SQL expression the per-row key uses. +// On SQLite the expression yields the key text directly; on PostgreSQL the +// stored timestamp is scanned and formatted to the exact key bytes (the +// stored value is UTC by convention, and a naive timestamp carries no offset +// whose parsing could diverge from the per-row expression). +func (r *Repository) messageCursorKey(ctx context.Context, id string) (string, error) { + drv := r.ro.DriverName() + query := fmt.Sprintf( + "SELECT %s FROM task_session_messages WHERE id = ?", + dialect.NormalizedMicrosecond(drv, "created_at"), + ) + if dialect.IsPostgres(drv) { + var ts time.Time + if err := r.ro.QueryRowContext(ctx, r.ro.Rebind(query), id).Scan(&ts); err != nil { + return "", err + } + return formatPromptKey(ts), nil + } + var key string + if err := r.ro.QueryRowContext(ctx, r.ro.Rebind(query), id).Scan(&key); err != nil { + return "", err + } + return key, nil } // ListMessagesForPlugin returns messages matching the plugin Host data API @@ -271,20 +317,39 @@ func (r *Repository) resolveMessageCursor(ctx context.Context, sessionID string, return nil, nil } -func buildListMessagesQuery(sessionID string, opts models.ListMessagesOptions, cursor *models.Message, sortDir string, limit int) (string, []interface{}) { +// buildListMessagesQuery assembles the paginated message list query and bound arguments, ordering rows by the normalized-microsecond key with cursor and limit bounds. +func buildListMessagesQuery(driverName, sessionID string, opts models.ListMessagesOptions, cursor *models.Message, cursorKey string, sortDir string, limit int) (string, []interface{}) { + nm := dialect.NormalizedMicrosecond(driverName, "created_at") + // The cursor bound is the normalized key literal `YYYY-MM-DD HH:MM:SS.ffffff` + // (space separator, zero-padded 6-digit fraction, no `Z`, UTC). PostgreSQL + // casts it to `timestamp` so it compares against its native per-row key; + // SQLite compares the text key directly. + bound := "?" + if dialect.IsPostgres(driverName) { + bound = "CAST(? AS timestamp)" + } + // prompt_index is the durable per-session ordinal persisted at creation + // (prompt_seq), so it is a plain column: absolute rather than + // page-relative by construction, no window pass needed. The cursor + // predicate, ORDER BY, and LIMIT (limit+1) use the same normalized key as + // the rest of the package, so every page is contiguous in ordinal order + // and the sentinel `promptNumber === 1` stop can never skip a + // higher-ordinal prompt sharing a microsecond. query := ` - SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at, updated_at - FROM task_session_messages WHERE task_session_id = ?` + SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at, updated_at, + CASE WHEN author_type = 'user' THEN prompt_seq ELSE 0 END AS prompt_index + FROM task_session_messages + WHERE task_session_id = ?` args := []interface{}{sessionID} if cursor != nil { if opts.Before != "" { - query += " AND (created_at < ? OR (created_at = ? AND id < ?))" + query += fmt.Sprintf(" AND (%s < %s OR (%s = %s AND id < ?))", nm, bound, nm, bound) } else if opts.After != "" { - query += " AND (created_at > ? OR (created_at = ? AND id > ?))" + query += fmt.Sprintf(" AND (%s > %s OR (%s = %s AND id > ?))", nm, bound, nm, bound) } - args = append(args, cursor.CreatedAt, cursor.CreatedAt, cursor.ID) + args = append(args, cursorKey, cursorKey, cursor.ID) } - query += fmt.Sprintf(" ORDER BY created_at %s, id %s", sortDir, sortDir) + query += fmt.Sprintf(" ORDER BY %s %s, id %s", nm, sortDir, sortDir) if limit > 0 { query += sqlLimitClause args = append(args, limit+1) @@ -292,6 +357,41 @@ func buildListMessagesQuery(sessionID string, opts models.ListMessagesOptions, c return query, args } +// scanPromptIndexedMessageRows scans the 13-column indexed message projection +// (the 12 legacy columns plus the computed prompt_index), returning the +// page-truncated slice and whether more rows remain, like scanMessageRows. +func scanPromptIndexedMessageRows(rows interface { + Next() bool + Scan(dest ...any) error + Err() error +}, limit int) ([]*models.Message, bool, error) { + var result []*models.Message + for rows.Next() { + message := &models.Message{} + var requestsInput int + var messageType string + var metadataJSON string + if err := rows.Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt, &message.UpdatedAt, &message.PromptIndex); err != nil { + return nil, false, err + } + message.RequestsInput = requestsInput == 1 + message.Type = models.MessageType(messageType) + if metadataJSON != "" && metadataJSON != "{}" { + if err := json.Unmarshal([]byte(metadataJSON), &message.Metadata); err != nil { + return nil, false, fmt.Errorf("failed to deserialize message metadata: %w", err) + } + } + result = append(result, message) + } + if err := rows.Err(); err != nil { + return nil, false, err + } + if limit > 0 && len(result) > limit { + return result[:limit], true, nil + } + return result, false, nil +} + func scanMessageRows(rows interface { Next() bool Scan(dest ...any) error diff --git a/apps/backend/internal/task/repository/sqlite/message_prompt_index.go b/apps/backend/internal/task/repository/sqlite/message_prompt_index.go new file mode 100644 index 0000000000..e972783048 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/message_prompt_index.go @@ -0,0 +1,263 @@ +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/kandev/kandev/internal/db/dialect" + "github.com/kandev/kandev/internal/task/models" +) + +// promptKeyLayout is the exact byte form of the normalized-microsecond +// ordering key: `YYYY-MM-DD HH:MM:SS.ffffff` (space separator, zero-padded +// 6-digit fraction, no zone suffix, UTC). SQLite rows compare against this +// text; the same literal is cast to `timestamp` on PostgreSQL. +const promptKeyLayout = "2006-01-02 15:04:05.000000" + +// promptIndexLeadWindow bounded how far a live create could advance its +// ordering timestamp; it was removed when prompt ordinals moved to a durable +// per-session sequence that no longer depends on timestamp comparisons. See +// the migration history for the derived-ordinal predecessor design. + +// ErrMessageTimestampNotAfterNewest is returned when an explicit user-message +// timestamp is not strictly after the session's newest user message. Explicit +// imports preserve only valid strictly-after-max timestamps; reorderings of +// an existing session's history are rejected. +var ErrMessageTimestampNotAfterNewest = errors.New("explicit message timestamp is not strictly after the session's newest user message") + +// messageBoundaryExecer is the subset of *sqlx.DB / *sqlx.Tx the per-session +// user-message create boundary needs: reads and the insert under one +// transaction. +type messageBoundaryExecer interface { + ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) + QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row + Rebind(query string) string +} + +// formatPromptKey renders a UTC time in the normalized prompt-order key layout used by the per-session ordering boundary and pagination cursors. +func formatPromptKey(t time.Time) string { + return t.UTC().Format(promptKeyLayout) +} + +// parsePromptKey parses a normalized prompt-order key back into a UTC time. +func parsePromptKey(s string) (time.Time, error) { + return time.Parse(promptKeyLayout, s) +} + +// resolvePromptOrderingTime applies the create-boundary timestamp rules for a +// user message. Live creates (zero timestamp) keep the session's ordering +// monotonic: a colliding or backward key is clamped forward by one microsecond +// tick. Clamping is unbounded on purpose — the prompt ordinal comes from the +// durable per-session sequence, not this timestamp, so a backward clock +// correction can never block new prompts; the timestamp only keeps pagination +// order stable. Explicit timestamps are preserved only when strictly after +// the session's newest user message. maxKey is the session's newest +// user-message normalized key (NULL when the session has no user rows). +func resolvePromptOrderingTime(live bool, created time.Time, maxKey sql.NullString) (time.Time, error) { + if !maxKey.Valid || maxKey.String == "" { + return created, nil + } + newKey := formatPromptKey(created) + if live { + if newKey > maxKey.String { + return created, nil + } + advanced, err := parsePromptKey(maxKey.String) + if err != nil { + return time.Time{}, fmt.Errorf("parse session max user-message key %q: %w", maxKey.String, err) + } + return advanced.Add(time.Microsecond), nil + } + if newKey <= maxKey.String { + return time.Time{}, fmt.Errorf( + "%w: %s is not strictly after %s", + ErrMessageTimestampNotAfterNewest, formatPromptKey(created), maxKey.String, + ) + } + return created, nil +} + +// allocatePromptSeq advances the session's durable prompt-sequence counter by +// one and returns the new ordinal. It runs inside the per-session create +// boundary, which serializes all user-message creates for the session (the +// advisory session lock on PostgreSQL, the single-writer pool on SQLite), so +// the read-increment-write is atomic. Deletion never decrements the counter: +// published ordinals stay stable, and a deleted prompt's ordinal is never +// reused. +func (r *Repository) allocatePromptSeq(ctx context.Context, execer messageBoundaryExecer, sessionID string) (int, error) { + var lastSeq int + err := execer.QueryRowContext(ctx, execer.Rebind( + `SELECT last_seq FROM task_session_prompt_seq WHERE task_session_id = ?`, + ), sessionID).Scan(&lastSeq) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return 0, fmt.Errorf("read session prompt sequence: %w", err) + } + seq := lastSeq + 1 + if _, err := execer.ExecContext(ctx, execer.Rebind(` + INSERT INTO task_session_prompt_seq (task_session_id, last_seq) VALUES (?, ?) + ON CONFLICT(task_session_id) DO UPDATE SET last_seq = excluded.last_seq + `), sessionID, seq); err != nil { + return 0, fmt.Errorf("advance session prompt sequence: %w", err) + } + return seq, nil +} + +// readSessionMaxUserKey returns the session's newest user-message normalized +// key in the exact key layout, and whether any user row exists. On SQLite the +// key expression yields the text directly; on PostgreSQL the native TIMESTAMP +// is scanned and formatted (scanning a timestamp into a string yields RFC3339, +// which the key parse and lexicographic comparison do not understand). +func (r *Repository) readSessionMaxUserKey( + ctx context.Context, + execer messageBoundaryExecer, + driver, nm, sessionID string, +) (string, bool, error) { + query := fmt.Sprintf( + "SELECT max(%s) FROM task_session_messages WHERE task_session_id = ? AND author_type = ?", + nm, + ) + args := []interface{}{sessionID, string(models.MessageAuthorUser)} + if dialect.IsPostgres(driver) { + var maxKey sql.NullTime + if err := execer.QueryRowContext(ctx, execer.Rebind(query), args...).Scan(&maxKey); err != nil { + return "", false, fmt.Errorf("read session max user-message key: %w", err) + } + if !maxKey.Valid { + return "", false, nil + } + return formatPromptKey(maxKey.Time), true, nil + } + var maxKey sql.NullString + if err := execer.QueryRowContext(ctx, execer.Rebind(query), args...).Scan(&maxKey); err != nil { + return "", false, fmt.Errorf("read session max user-message key: %w", err) + } + return maxKey.String, maxKey.Valid && maxKey.String != "", nil +} + +// createUserMessageWithBoundary persists a user message with its +// prompt-ordering timestamp and durable prompt ordinal assigned inside the +// per-session write boundary (a transaction on both dialects; PostgreSQL +// additionally takes the session turn-write advisory lock, and SQLite's +// single-writer pool provides the same serialization). The caller-visible +// CreatedAt and PromptIndex fields are only committed on success, so a failed +// attempt (rejected explicit timestamp, busy retry, concurrent winner) leaves +// the retried model untouched. +func (r *Repository) createUserMessageWithBoundary( + ctx context.Context, + message *models.Message, + requestsInput int, + messageType, metadataJSON string, +) error { + driver := r.db.DriverName() + nm := dialect.NormalizedMicrosecond(driver, "created_at") + return r.executeBoundaryTransaction(ctx, message, requestsInput, messageType, metadataJSON, driver, nm) +} + +// executeBoundaryTransaction runs one per-session write boundary: begin a +// transaction, take the session turn-write lock (advisory on PostgreSQL, no-op +// on SQLite), resolve the prompt-ordering timestamp and ordinal, and insert +// the user message row. Any failure restores the message's caller-visible +// CreatedAt/UpdatedAt/PromptIndex so a retry re-resolves from scratch. +func (r *Repository) executeBoundaryTransaction( + ctx context.Context, + message *models.Message, + requestsInput int, + messageType, metadataJSON, driver, nm string, +) error { + origCreatedAt := message.CreatedAt + origUpdatedAt := message.UpdatedAt + origPromptIndex := message.PromptIndex + restore := func() { + message.CreatedAt = origCreatedAt + message.UpdatedAt = origUpdatedAt + message.PromptIndex = origPromptIndex + } + + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("begin user message creation: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err := lockSessionTurnWrites(ctx, tx, driver, message.TaskSessionID); err != nil { + return err + } + + now := r.nowUTC() + created := message.CreatedAt + if created.IsZero() { + created = now + } + + maxKeyStr, maxKeyValid, err := r.readSessionMaxUserKey(ctx, tx, driver, nm, message.TaskSessionID) + if err != nil { + return err + } + + created, err = resolvePromptOrderingTime(message.CreatedAt.IsZero(), created, sql.NullString{String: maxKeyStr, Valid: maxKeyValid}) + if err != nil { + return err + } + + // The ordinal comes from the session's durable sequence counter (inside + // this serialized boundary), never from the timestamp or the remaining + // rows: deletion of an earlier prompt cannot renumber this row, and a + // backward clock correction cannot block allocation. + seq, err := r.allocatePromptSeq(ctx, tx, message.TaskSessionID) + if err != nil { + return err + } + + message.CreatedAt = created + message.PromptIndex = seq + if message.UpdatedAt.IsZero() { + message.UpdatedAt = created + } + if err := r.insertMessageRow(ctx, tx, message, requestsInput, messageType, metadataJSON); err != nil { + restore() + return err + } + if err := tx.Commit(); err != nil { + restore() + return fmt.Errorf("commit user message creation: %w", err) + } + return nil +} + +// GetMessageWithPromptIndex retrieves a message by ID with its prompt_index +// read from the durable per-session prompt sequence (prompt_seq), which is +// allocated at creation inside the write boundary and survives message +// deletion. Non-user rows return zero. This is the 13-column read used by the +// idempotent WS replay/response path and user update-event publication; +// hot-path GetMessage stays on the 12-column scan. +func (r *Repository) GetMessageWithPromptIndex(ctx context.Context, id string) (*models.Message, error) { + message := &models.Message{} + var requestsInput int + var messageType string + var metadataJSON string + query := ` + SELECT id, task_session_id, task_id, turn_id, author_type, author_id, + content, requests_input, type, metadata, created_at, updated_at, + CASE WHEN author_type = 'user' THEN prompt_seq ELSE 0 END AS prompt_index + FROM task_session_messages + WHERE id = ?` + err := r.ro.QueryRowContext(ctx, r.ro.Rebind(query), id).Scan( + &message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, + &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt, &message.UpdatedAt, + &message.PromptIndex, + ) + if err != nil { + return nil, err + } + message.RequestsInput = requestsInput == 1 + message.Type = models.MessageType(messageType) + if metadataJSON != "" && metadataJSON != "{}" { + if err := json.Unmarshal([]byte(metadataJSON), &message.Metadata); err != nil { + return nil, fmt.Errorf("failed to deserialize message metadata: %w", err) + } + } + return message, nil +} diff --git a/apps/backend/internal/task/repository/sqlite/message_prompt_index_postgres_test.go b/apps/backend/internal/task/repository/sqlite/message_prompt_index_postgres_test.go new file mode 100644 index 0000000000..d3a3a6ffbb --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/message_prompt_index_postgres_test.go @@ -0,0 +1,280 @@ +package sqlite + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/testutil" +) + +// openPostgresRepo opens an isolated Postgres schema with the task repository +// initialized (schema + prompt-order index applied). +func openPostgresRepo(t *testing.T) *Repository { + t.Helper() + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("new postgres repo: %v", err) + } + return repo +} + +// seedPostgresSession creates a task session and user message rows in an isolated PostgreSQL schema for concurrency tests. +func seedPostgresSession(t *testing.T, repo *Repository, taskID, sessionID, turnID string, base time.Time) { + t.Helper() + ctx := context.Background() + if err := repo.CreateTask(ctx, &models.Task{ID: taskID, Title: taskID}); err != nil { + t.Fatalf("CreateTask(%s): %v", taskID, err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: sessionID, TaskID: taskID, State: models.TaskSessionStateWaitingForInput, + }); err != nil { + t.Fatalf("CreateTaskSession(%s): %v", sessionID, err) + } + if err := repo.CreateTurn(ctx, &models.Turn{ + ID: turnID, TaskSessionID: sessionID, TaskID: taskID, + StartedAt: base, CreatedAt: base, UpdatedAt: base, + }); err != nil { + t.Fatalf("CreateTurn(%s): %v", turnID, err) + } +} + +// TestPostgresPromptIndexReadsAndPagination repeats the indexed-read, +// tie-by-id, non-user, cross-session, and pagination assertions on the +// PostgreSQL dialect (persisted prompt_seq must agree across reads). +func TestPostgresPromptIndexReadsAndPagination(t *testing.T) { + repo := openPostgresRepo(t) + ctx := context.Background() + base := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC) + seedPostgresSession(t, repo, "task-pg-a", "sess-pg-a", "turn-pg-a", base) + + insertPromptRow(t, repo, "pg-a-1", "sess-pg-a", "turn-pg-a", "user", "p1", base) + // Identical microsecond, reversed id order: ties break by id ascending. + insertPromptRow(t, repo, "pg-a-zzz", "sess-pg-a", "turn-pg-a", "user", "p2", base) + insertPromptRow(t, repo, "pg-a-3", "sess-pg-a", "turn-pg-a", "user", "p3", base.Add(time.Second)) + insertPromptRow(t, repo, "pg-a-agent", "sess-pg-a", "turn-pg-a", "agent", "reply", base.Add(2*time.Second)) + + seedPostgresSession(t, repo, "task-pg-b", "sess-pg-b", "turn-pg-b", base.Add(time.Hour)) + insertPromptRow(t, repo, "pg-b-1", "sess-pg-b", "turn-pg-b", "user", "p1", base.Add(time.Hour)) + + // Fixture rows bypass the create boundary (the legacy-row shape), so + // assign ordinals with the production backfill like a migrated database. + backfillPromptSeqForTest(t, repo) + + wantA := map[string]int{ + "pg-a-1": 1, "pg-a-zzz": 2, "pg-a-3": 3, "pg-a-agent": 0, + } + assertPromptOrdinals(t, ctx, repo, "sess-pg-a", wantA) + + wantB := map[string]int{"pg-b-1": 1} + assertPromptOrdinals(t, ctx, repo, "sess-pg-b", wantB) + + // Non-first pages return absolute ordinals: paginate to the oldest page. + pages := paginateAll(t, ctx, repo, "sess-pg-a", 1, "") + if len(pages) != 4 { + t.Fatalf("pages = %d, want 4", len(pages)) + } + oldest := pages[len(pages)-1][0] + if oldest.ID != "pg-a-1" || oldest.PromptIndex != 1 { + t.Errorf("oldest page = %s (index %d), want pg-a-1 with absolute index 1", oldest.ID, oldest.PromptIndex) + } +} + +// TestPostgresPromptIndexCreateBoundary covers the atomic per-session create +// boundary on Postgres: fixed-clock/reverse-ID advance, the +// explicit-timestamp-preserve branch, and backward-clock clamping. +func TestPostgresPromptIndexCreateBoundary(t *testing.T) { + repo := openPostgresRepo(t) + ctx := context.Background() + base := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) + seedPostgresSession(t, repo, "task-pg-bound", "sess-pg-bound", "turn-pg-bound", base) + repo.clockNow = func() time.Time { return base } + + first := &models.Message{ID: "pg-live-z", TaskSessionID: "sess-pg-bound", TurnID: "turn-pg-bound", AuthorType: models.MessageAuthorUser, Content: "first"} + if err := repo.CreateMessage(ctx, first); err != nil { + t.Fatalf("first live create: %v", err) + } + second := &models.Message{ID: "pg-live-a", TaskSessionID: "sess-pg-bound", TurnID: "turn-pg-bound", AuthorType: models.MessageAuthorUser, Content: "second"} + if err := repo.CreateMessage(ctx, second); err != nil { + t.Fatalf("second live create: %v", err) + } + if first.PromptIndex != 1 || second.PromptIndex != 2 { + t.Fatalf("live ordinals = (%d, %d), want (1, 2)", first.PromptIndex, second.PromptIndex) + } + if formatPromptKey(second.CreatedAt) != formatPromptKey(first.CreatedAt.Add(time.Microsecond)) { + t.Errorf("key advance = %q, want one microsecond tick", formatPromptKey(second.CreatedAt)) + } + + // Explicit-timestamp-preserve branch: strictly-after-max preserved; at/before rejected. + explicit := &models.Message{ + ID: "pg-explicit", TaskSessionID: "sess-pg-bound", TurnID: "turn-pg-bound", + AuthorType: models.MessageAuthorUser, Content: "explicit", + CreatedAt: base.Add(time.Minute), + } + if err := repo.CreateMessage(ctx, explicit); err != nil { + t.Fatalf("explicit create: %v", err) + } + if explicit.PromptIndex != 3 || !explicit.CreatedAt.Equal(base.Add(time.Minute)) { + t.Errorf("explicit create = index %d at %v, want 3 at %v", explicit.PromptIndex, explicit.CreatedAt, base.Add(time.Minute)) + } + + invalid := &models.Message{ + ID: "pg-invalid", TaskSessionID: "sess-pg-bound", TurnID: "turn-pg-bound", + AuthorType: models.MessageAuthorUser, Content: "invalid", + CreatedAt: base.Add(time.Minute), + } + if err := repo.CreateMessage(ctx, invalid); !errors.Is(err, ErrMessageTimestampNotAfterNewest) { + t.Fatalf("equal-timestamp create error = %v, want ErrMessageTimestampNotAfterNewest", err) + } + + // Backward clock: a live create far behind the newest user message must + // not block — the ordinal comes from the durable sequence, and the + // ordering timestamp clamps forward by one tick. + repo.clockNow = func() time.Time { return base.Add(-3 * time.Minute) } + skewed := &models.Message{ID: "pg-skewed", TaskSessionID: "sess-pg-bound", TurnID: "turn-pg-bound", AuthorType: models.MessageAuthorUser, Content: "skewed"} + if err := repo.CreateMessage(ctx, skewed); err != nil { + t.Fatalf("backward-clock create must not block: %v", err) + } + if skewed.PromptIndex != 4 { + t.Errorf("backward-clock ordinal = %d, want 4 (durable sequence, no reuse)", skewed.PromptIndex) + } + if !skewed.CreatedAt.After(explicit.CreatedAt) { + t.Errorf("clamped timestamp %v must stay after newest %v", skewed.CreatedAt, explicit.CreatedAt) + } + if got := formatPromptKey(skewed.CreatedAt); got != formatPromptKey(explicit.CreatedAt.Add(time.Microsecond)) { + t.Errorf("clamped key = %q, want one tick after explicit %q", got, formatPromptKey(explicit.CreatedAt.Add(time.Microsecond))) + } +} + +// TestPostgresPromptIndexConcurrentDeleteAndCreate covers the concurrent +// delete/create regression: a user prompt deleted from another connection +// while creates are in flight must not renumber the survivors or cause the +// sequence to reuse the deleted ordinal. +func TestPostgresPromptIndexConcurrentDeleteAndCreate(t *testing.T) { + db := openIsolatedPostgresMultiConn(t, testutil.PostgresDSNFromEnv(t), 2) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("new postgres repo: %v", err) + } + ctx := context.Background() + base := time.Date(2026, 8, 19, 15, 0, 0, 0, time.UTC) + seedPostgresSession(t, repo, "task-pg-del", "sess-pg-del", "turn-pg-del", base) + + early := &models.Message{ID: "pg-del-early", TaskSessionID: "sess-pg-del", TurnID: "turn-pg-del", AuthorType: models.MessageAuthorUser, Content: "early"} + if err := repo.CreateMessage(ctx, early); err != nil { + t.Fatalf("create early: %v", err) + } + if early.PromptIndex != 1 { + t.Fatalf("early ordinal = %d, want 1", early.PromptIndex) + } + + // Two concurrent creates race a delete of the early prompt on the second + // connection. Whatever the interleaving, survivors keep their ordinals + // and the new prompts get fresh distinct ordinals (never reusing 1). + messages := make([]*models.Message, 2) + errs := make([]error, 2) + var wg sync.WaitGroup + for i := 0; i < 2; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + msg := &models.Message{ + ID: "pg-del-new-" + string(rune('a'+i)), + TaskSessionID: "sess-pg-del", + TurnID: "turn-pg-del", + AuthorType: models.MessageAuthorUser, + Content: "concurrent prompt", + } + errs[i] = repo.CreateMessage(ctx, msg) + messages[i] = msg + }(i) + } + if err := repo.DeleteMessage(ctx, early.ID); err != nil { + t.Fatalf("concurrent delete of early prompt: %v", err) + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("concurrent create %d: %v", i, err) + } + } + ordinals := map[int]bool{messages[0].PromptIndex: true, messages[1].PromptIndex: true} + if len(ordinals) != 2 || ordinals[1] { + t.Fatalf("concurrent ordinals = %d/%d, want exactly {2, 3} (deleted ordinal 1 never reused)", messages[0].PromptIndex, messages[1].PromptIndex) + } + if !ordinals[2] || !ordinals[3] { + t.Fatalf("concurrent ordinals = %d/%d, want exactly {2, 3}", messages[0].PromptIndex, messages[1].PromptIndex) + } + // The deleted prompt is gone and survivors keep their durable ordinals. + if _, err := repo.GetMessageWithPromptIndex(ctx, early.ID); err == nil { + t.Errorf("deleted early prompt still readable") + } + for _, msg := range messages { + stored, err := repo.GetMessageWithPromptIndex(ctx, msg.ID) + if err != nil { + t.Fatalf("GetMessageWithPromptIndex(%s): %v", msg.ID, err) + } + if stored.PromptIndex != msg.PromptIndex { + t.Errorf("reread ordinal %d != model ordinal %d for %s", stored.PromptIndex, msg.PromptIndex, msg.ID) + } + } +} + +// TestPostgresPromptIndexConcurrentCreatesDistinct uses a real multi-connection +// pool to prove the per-session advisory lock serializes concurrent user +// creates: both final ordinals are distinct (1 and 2) and the correlated +// read agrees with each model. +func TestPostgresPromptIndexConcurrentCreatesDistinct(t *testing.T) { + db := openIsolatedPostgresMultiConn(t, testutil.PostgresDSNFromEnv(t), 2) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("new postgres repo: %v", err) + } + ctx := context.Background() + base := time.Date(2026, 8, 19, 14, 0, 0, 0, time.UTC) + seedPostgresSession(t, repo, "task-pg-conc", "sess-pg-conc", "turn-pg-conc", base) + + const goroutines = 2 + messages := make([]*models.Message, goroutines) + errs := make([]error, goroutines) + var wg sync.WaitGroup + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + msg := &models.Message{ + ID: "pg-conc-" + string(rune('a'+i)), + TaskSessionID: "sess-pg-conc", + TurnID: "turn-pg-conc", + AuthorType: models.MessageAuthorUser, + Content: "concurrent prompt", + } + errs[i] = repo.CreateMessage(ctx, msg) + messages[i] = msg + }(i) + } + wg.Wait() + for i, err := range errs { + if err != nil { + t.Fatalf("concurrent create %d: %v", i, err) + } + } + ordinals := map[int]bool{messages[0].PromptIndex: true, messages[1].PromptIndex: true} + if len(ordinals) != 2 || !ordinals[1] || !ordinals[2] { + t.Fatalf("concurrent final ordinals = %d/%d, want exactly {1, 2}", messages[0].PromptIndex, messages[1].PromptIndex) + } + for _, msg := range messages { + stored, err := repo.GetMessageWithPromptIndex(ctx, msg.ID) + if err != nil { + t.Fatalf("GetMessageWithPromptIndex(%s): %v", msg.ID, err) + } + if stored.PromptIndex != msg.PromptIndex { + t.Errorf("reread ordinal %d != model ordinal %d for %s", stored.PromptIndex, msg.PromptIndex, msg.ID) + } + } +} diff --git a/apps/backend/internal/task/repository/sqlite/prompt_index_create_test.go b/apps/backend/internal/task/repository/sqlite/prompt_index_create_test.go new file mode 100644 index 0000000000..d25c02d03b --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/prompt_index_create_test.go @@ -0,0 +1,269 @@ +package sqlite + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +// TestLiveCreateAdvancesCollidingTimestamps covers the atomic per-session +// create boundary for live user messages: with a fixed clock, every create +// starts with the same timestamp, and the boundary advances each new message +// by one microsecond tick so reread order stays distinct and consistent. The +// one-tick advance stays within the bounded lead window. +func TestLiveCreateAdvancesCollidingTimestamps(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-LIVE", "sess-LIVE", "turn-LIVE") + + fixed := time.Date(2026, 8, 19, 10, 0, 0, 123456789, time.UTC) + repo.clockNow = func() time.Time { return fixed } + + // Reverse-ordered IDs: the later-created message has the smaller id. + first := &models.Message{ID: "live-zzz", TaskSessionID: "sess-LIVE", TurnID: "turn-LIVE", AuthorType: models.MessageAuthorUser, Content: "first"} + if err := repo.CreateMessage(ctx, first); err != nil { + t.Fatalf("first create: %v", err) + } + second := &models.Message{ID: "live-aaa", TaskSessionID: "sess-LIVE", TurnID: "turn-LIVE", AuthorType: models.MessageAuthorUser, Content: "second"} + if err := repo.CreateMessage(ctx, second); err != nil { + t.Fatalf("second create: %v", err) + } + + if first.PromptIndex != 1 || second.PromptIndex != 2 { + t.Fatalf("ordinals = (%d, %d), want (1, 2)", first.PromptIndex, second.PromptIndex) + } + // The boundary advanced the second message by one ordering-key tick (the + // first row retains its full nanosecond precision, so the raw gap may be + // less than a microsecond; the normalized keys differ by exactly one + // microsecond) and the timestamps stay strictly ordered. + if !second.CreatedAt.After(first.CreatedAt) { + t.Errorf("second.CreatedAt = %v, want strictly after first %v", second.CreatedAt, first.CreatedAt) + } + if got := formatPromptKey(second.CreatedAt); got != formatPromptKey(first.CreatedAt.Add(time.Microsecond)) { + t.Errorf("key advance = %q, want %q (one microsecond tick)", got, formatPromptKey(first.CreatedAt.Add(time.Microsecond))) + } + + // Reread order and indexed reads agree with the assigned ordinals. + got1, err := repo.GetMessageWithPromptIndex(ctx, "live-zzz") + if err != nil { + t.Fatalf("reread first: %v", err) + } + got2, err := repo.GetMessageWithPromptIndex(ctx, "live-aaa") + if err != nil { + t.Fatalf("reread second: %v", err) + } + if got1.PromptIndex != 1 || got2.PromptIndex != 2 { + t.Errorf("reread ordinals = (%d, %d), want (1, 2)", got1.PromptIndex, got2.PromptIndex) + } +} + +// TestLiveCreateBackwardClockClampsInsteadOfBlocking pins the durable-ordinal +// regression: when the host clock is behind the session's newest user message +// (here by more than the old one-minute lead window), a live create must +// still succeed. The ordering timestamp clamps forward by one tick — the +// ordinal comes from the session's durable sequence counter, never from the +// timestamp — so a backward clock correction cannot block new prompts. +func TestLiveCreateBackwardClockClampsInsteadOfBlocking(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-SKEW", "sess-SKEW", "turn-SKEW") + + fixed := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC) + repo.clockNow = func() time.Time { return fixed } + + // An explicit seed two minutes in the future (relative to the fixed + // clock) is valid on its own: it is strictly after the empty session max. + seed := &models.Message{ + ID: "skew-seed", TaskSessionID: "sess-SKEW", TurnID: "turn-SKEW", + AuthorType: models.MessageAuthorUser, Content: "future seed", + CreatedAt: fixed.Add(2 * time.Minute), + } + if err := repo.CreateMessage(ctx, seed); err != nil { + t.Fatalf("seed: %v", err) + } + if seed.PromptIndex != 1 { + t.Fatalf("seed ordinal = %d, want 1", seed.PromptIndex) + } + + // Backward clock correction: a live create at a clock ten minutes behind + // the seed must not be blocked; its ordering timestamp clamps to one tick + // after the seed and it receives the next durable ordinal. + repo.clockNow = func() time.Time { return fixed.Add(-10 * time.Minute) } + live := &models.Message{ + ID: "skew-live", TaskSessionID: "sess-SKEW", TurnID: "turn-SKEW", + AuthorType: models.MessageAuthorUser, Content: "live", + } + if err := repo.CreateMessage(ctx, live); err != nil { + t.Fatalf("backward-clock create must not block: %v", err) + } + if live.PromptIndex != 2 { + t.Errorf("backward-clock ordinal = %d, want 2", live.PromptIndex) + } + if got := formatPromptKey(live.CreatedAt); got != formatPromptKey(seed.CreatedAt.Add(time.Microsecond)) { + t.Errorf("clamped key = %q, want %q (one tick after seed)", got, formatPromptKey(seed.CreatedAt.Add(time.Microsecond))) + } + got, err := repo.GetMessageWithPromptIndex(ctx, live.ID) + if err != nil { + t.Fatalf("reread live: %v", err) + } + if got.PromptIndex != 2 { + t.Errorf("reread ordinal = %d, want 2", got.PromptIndex) + } +} + +// TestPromptIndexDurableAcrossDeletion covers the delete-after-publish +// regression: ordinals already delivered to clients must not change when an +// earlier prompt is deleted, and the session's durable sequence must never +// reuse a deleted prompt's ordinal. +func TestPromptIndexDurableAcrossDeletion(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-DEL", "sess-DEL", "turn-DEL") + repo.clockNow = func() time.Time { return time.Date(2026, 8, 19, 11, 0, 0, 0, time.UTC) } + + a := &models.Message{ID: "del-a", TaskSessionID: "sess-DEL", TurnID: "turn-DEL", AuthorType: models.MessageAuthorUser, Content: "A"} + if err := repo.CreateMessage(ctx, a); err != nil { + t.Fatalf("create A: %v", err) + } + b := &models.Message{ID: "del-b", TaskSessionID: "sess-DEL", TurnID: "turn-DEL", AuthorType: models.MessageAuthorUser, Content: "B"} + if err := repo.CreateMessage(ctx, b); err != nil { + t.Fatalf("create B: %v", err) + } + if a.PromptIndex != 1 || b.PromptIndex != 2 { + t.Fatalf("initial ordinals = (%d, %d), want (1, 2)", a.PromptIndex, b.PromptIndex) + } + + // Delete the earlier prompt A after B (#2) was published. + if err := repo.DeleteMessage(ctx, a.ID); err != nil { + t.Fatalf("delete A: %v", err) + } + gotB, err := repo.GetMessageWithPromptIndex(ctx, b.ID) + if err != nil { + t.Fatalf("reread B: %v", err) + } + if gotB.PromptIndex != 2 { + t.Errorf("B ordinal after deleting A = %d, want 2 (published ordinal must not renumber)", gotB.PromptIndex) + } + + // A new prompt must continue the sequence, never reuse #1. + c := &models.Message{ID: "del-c", TaskSessionID: "sess-DEL", TurnID: "turn-DEL", AuthorType: models.MessageAuthorUser, Content: "C"} + if err := repo.CreateMessage(ctx, c); err != nil { + t.Fatalf("create C: %v", err) + } + if c.PromptIndex != 3 { + t.Errorf("C ordinal = %d, want 3 (no reuse after delete)", c.PromptIndex) + } + gotC, err := repo.GetMessageWithPromptIndex(ctx, c.ID) + if err != nil { + t.Fatalf("reread C: %v", err) + } + if gotC.PromptIndex != 3 { + t.Errorf("reread C ordinal = %d, want 3", gotC.PromptIndex) + } +} + +// TestExplicitUserTimestampMustBeStrictlyAfterMax covers the zero-vs-explicit +// CreatedAt branch: explicit imports preserve only valid strictly-after-max +// timestamps; an explicit timestamp at or before the session's newest user +// message is rejected and leaves the model untouched. +func TestExplicitUserTimestampMustBeStrictlyAfterMax(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-EXP", "sess-EXP", "turn-EXP") + + base := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) + first := &models.Message{ + ID: "exp-1", TaskSessionID: "sess-EXP", TurnID: "turn-EXP", + AuthorType: models.MessageAuthorUser, Content: "first", CreatedAt: base, + } + if err := repo.CreateMessage(ctx, first); err != nil { + t.Fatalf("first create: %v", err) + } + if first.PromptIndex != 1 { + t.Fatalf("first ordinal = %d, want 1", first.PromptIndex) + } + + // Equal timestamp: not strictly after max → rejected. + dup := &models.Message{ + ID: "exp-dup", TaskSessionID: "sess-EXP", TurnID: "turn-EXP", + AuthorType: models.MessageAuthorUser, Content: "duplicate", CreatedAt: base, + } + err := repo.CreateMessage(ctx, dup) + if !errors.Is(err, ErrMessageTimestampNotAfterNewest) { + t.Fatalf("equal-timestamp create error = %v, want ErrMessageTimestampNotAfterNewest", err) + } + if !dup.CreatedAt.Equal(base) || dup.PromptIndex != 0 { + t.Errorf("rejected create mutated model: CreatedAt=%v PromptIndex=%d", dup.CreatedAt, dup.PromptIndex) + } + + // Backward timestamp: also rejected. + older := &models.Message{ + ID: "exp-older", TaskSessionID: "sess-EXP", TurnID: "turn-EXP", + AuthorType: models.MessageAuthorUser, Content: "older", CreatedAt: base.Add(-time.Second), + } + if err := repo.CreateMessage(ctx, older); !errors.Is(err, ErrMessageTimestampNotAfterNewest) { + t.Fatalf("older-timestamp create error = %v, want ErrMessageTimestampNotAfterNewest", err) + } + + // Strictly-after timestamp: preserved verbatim with the next ordinal. + second := &models.Message{ + ID: "exp-2", TaskSessionID: "sess-EXP", TurnID: "turn-EXP", + AuthorType: models.MessageAuthorUser, Content: "second", CreatedAt: base.Add(time.Second), + } + if err := repo.CreateMessage(ctx, second); err != nil { + t.Fatalf("second create: %v", err) + } + if second.PromptIndex != 2 { + t.Fatalf("second ordinal = %d, want 2", second.PromptIndex) + } + if !second.CreatedAt.Equal(base.Add(time.Second)) { + t.Errorf("explicit timestamp not preserved: %v", second.CreatedAt) + } +} + +// TestFailedCreateAttemptKeepsModelClean proves a failed repository attempt +// (here: a duplicate primary key insert after the boundary computed the +// ordinal) does not leave CreatedAt/PromptIndex on the retried model. +func TestFailedCreateAttemptKeepsModelClean(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-RETRY", "sess-RETRY", "turn-RETRY") + + winner := &models.Message{ + ID: "retry-id", TaskSessionID: "sess-RETRY", TurnID: "turn-RETRY", + AuthorType: models.MessageAuthorUser, Content: "winner", + } + if err := repo.CreateMessage(ctx, winner); err != nil { + t.Fatalf("winner create: %v", err) + } + if winner.PromptIndex != 1 { + t.Fatalf("winner ordinal = %d, want 1", winner.PromptIndex) + } + + // Same ID: the boundary computes an ordinal, then the insert fails on the + // primary key. The caller-visible fields must be restored so the retry + // sees a clean model. + retry := &models.Message{ + ID: "retry-id", TaskSessionID: "sess-RETRY", TurnID: "turn-RETRY", + AuthorType: models.MessageAuthorUser, Content: "retry", + } + if err := repo.CreateMessage(ctx, retry); err == nil { + t.Fatal("duplicate create succeeded, want failure") + } + if !retry.CreatedAt.IsZero() || retry.PromptIndex != 0 || !retry.UpdatedAt.IsZero() { + t.Errorf("failed attempt mutated model: CreatedAt=%v UpdatedAt=%v PromptIndex=%d", + retry.CreatedAt, retry.UpdatedAt, retry.PromptIndex) + } + + // The winning row's ordinal is stable. + got, err := repo.GetMessageWithPromptIndex(ctx, "retry-id") + if err != nil { + t.Fatalf("reread winner: %v", err) + } + if got.PromptIndex != 1 { + t.Errorf("winner ordinal after failed retry = %d, want 1", got.PromptIndex) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/prompt_index_test.go b/apps/backend/internal/task/repository/sqlite/prompt_index_test.go new file mode 100644 index 0000000000..9337d80cdc --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/prompt_index_test.go @@ -0,0 +1,301 @@ +package sqlite + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/kandev/kandev/internal/db/dialect" + "github.com/kandev/kandev/internal/task/models" +) + +// insertPromptRow inserts a raw message row with a driver-serialized timestamp, +// bypassing the create boundary — the legacy-row shape the indexed readers +// must still order correctly. authorType must be "user" or "agent". +func insertPromptRow(t *testing.T, repo *Repository, id, sessionID, turnID, authorType, content string, ts time.Time) { + t.Helper() + insertAgentMsg(t, repo, id, sessionID, turnID, authorType, content, ts) +} + +// insertPromptRowWithRawTimestamp inserts a message row whose created_at is a +// raw TEXT value (e.g. carrying a non-UTC offset), bypassing the driver's +// time.Time serialization — the legacy/harness-seed shape whose offset the +// normalized-microsecond helper must convert to UTC. +func insertPromptRowWithRawTimestamp(t *testing.T, repo *Repository, id, sessionID, turnID, authorType, content, rawCreatedAt string) { + t.Helper() + _, err := repo.db.Exec(repo.db.Rebind(` + INSERT INTO task_session_messages + (id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at) + VALUES (?, ?, '', ?, ?, '', ?, 0, 'message', '{}', ?) + `), id, sessionID, turnID, authorType, content, rawCreatedAt) + if err != nil { + t.Fatalf("insert message %s: %v", id, err) + } +} + +// backfillPromptSeqForTest assigns fixture-inserted legacy rows their derived +// ordinals exactly as the production migration does (see backfillPromptSeq), +// so the fixture tests exercise the same upgrade path the indexed readers +// serve after migration. +func backfillPromptSeqForTest(t *testing.T, repo *Repository) { + t.Helper() + if err := repo.backfillPromptSeq(); err != nil { + t.Fatalf("backfill prompt_seq: %v", err) + } +} + +// assertPromptOrdinals asserts the indexed read (persisted prompt_seq) and +// the paginated read agree on every user row's ordinal and that both return +// zero for agent rows. +func assertPromptOrdinals(t *testing.T, ctx context.Context, repo *Repository, sessionID string, want map[string]int) { + t.Helper() + for id, wantIndex := range want { + got, err := repo.GetMessageWithPromptIndex(ctx, id) + if err != nil { + t.Fatalf("GetMessageWithPromptIndex(%s): %v", id, err) + } + if got.PromptIndex != wantIndex { + t.Errorf("GetMessageWithPromptIndex(%s).PromptIndex = %d, want %d", id, got.PromptIndex, wantIndex) + } + } + // Full-session window read: every page in ordinal order must agree. + pages := paginateAll(t, ctx, repo, sessionID, 2, "") + byID := map[string]int{} + for _, page := range pages { + for _, m := range page { + byID[m.ID] = m.PromptIndex + } + } + for id, wantIndex := range want { + if byID[id] != wantIndex { + t.Errorf("ListMessagesPaginated(%s).PromptIndex = %d, want %d", id, byID[id], wantIndex) + } + } +} + +// paginateAll walks the session from the newest page backward in pages of +// `limit`, returning every loaded page. stopBeforeID, when non-empty, stops +// once a page contains it (inclusive) — used to assert the page that carries +// a lower-ordinal prompt never skips a higher-ordinal one. +func paginateAll(t *testing.T, ctx context.Context, repo *Repository, sessionID string, limit int, stopBeforeID string) [][]*models.Message { + t.Helper() + var pages [][]*models.Message + cursor := "" + for { + opts := models.ListMessagesOptions{Limit: limit, Sort: "desc"} + if cursor != "" { + opts.Before = cursor + } + msgs, hasMore, err := repo.ListMessagesPaginated(ctx, sessionID, opts) + if err != nil { + t.Fatalf("ListMessagesPaginated: %v", err) + } + pages = append(pages, msgs) + if len(msgs) > 0 && stopBeforeID != "" { + for _, m := range msgs { + if m.ID == stopBeforeID { + return pages + } + } + } + if !hasMore || len(msgs) == 0 { + return pages + } + cursor = msgs[len(msgs)-1].ID + } +} + +// frontendMicrosecondKey reproduces the frontend's `epochNanoseconds / 1000n` +// floor division for a parsed RFC3339 timestamp, formatted to the exact +// normalized key bytes the backend produces. +func frontendMicrosecondKey(t *testing.T, rfc3339 string) string { + t.Helper() + parsed, err := time.Parse(time.RFC3339Nano, rfc3339) + if err != nil { + t.Fatalf("parse %q: %v", rfc3339, err) + } + return formatPromptKey(time.Unix(0, (parsed.UnixNano()/1000)*1000)) +} + +// TestPromptIndexReadsAndPagination verifies derived prompt ordinals across live creation, indexed reads, and paginated lists. +func TestPromptIndexReadsAndPagination(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + now := time.Now().UTC() + + // Two sessions with interleaved user/agent rows, including two user + // messages sharing an identical created_at (legacy raw rows; the create + // boundary only applies to rows inserted through CreateMessage). + seedForMsgTest(t, repo, "task-PA", "sess-PA", "turn-PA") + insertPromptRow(t, repo, "pa-agent-1", "sess-PA", "turn-PA", "agent", "reply 1", now) + insertPromptRow(t, repo, "pa-user-1", "sess-PA", "turn-PA", "user", "prompt 1", now) + insertPromptRow(t, repo, "pa-user-2", "sess-PA", "turn-PA", "user", "prompt 2", now.Add(time.Second)) + insertPromptRow(t, repo, "pa-agent-2", "sess-PA", "turn-PA", "agent", "reply 2", now.Add(2*time.Second)) + insertPromptRow(t, repo, "pa-user-3", "sess-PA", "turn-PA", "user", "prompt 3", now.Add(2*time.Second)) + insertPromptRow(t, repo, "pa-user-tie", "sess-PA", "turn-PA", "user", "prompt tie", now) + + seedForMsgTest(t, repo, "task-PB", "sess-PB", "turn-PB") + insertPromptRow(t, repo, "pb-user-1", "sess-PB", "turn-PB", "user", "prompt 1", now.Add(time.Minute)) + insertPromptRow(t, repo, "pb-user-2", "sess-PB", "turn-PB", "user", "prompt 2", now.Add(2*time.Minute)) + + // The fixture rows bypass the create boundary (the legacy-row shape), so + // assign ordinals with the production backfill like a migrated database. + backfillPromptSeqForTest(t, repo) + + // Ordinary hot paths stay legacy: GetMessage and full ListMessages return + // zero PromptIndex even for user rows. + legacy, err := repo.GetMessage(ctx, "pa-user-1") + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if legacy.PromptIndex != 0 { + t.Errorf("GetMessage(user).PromptIndex = %d, want 0 (legacy hot path)", legacy.PromptIndex) + } + full, err := repo.ListMessages(ctx, "sess-PA") + if err != nil { + t.Fatalf("ListMessages: %v", err) + } + for _, m := range full { + if m.PromptIndex != 0 { + t.Errorf("ListMessages(%s).PromptIndex = %d, want 0 (legacy hot path)", m.ID, m.PromptIndex) + } + } + + // User ordinals: (created_at, id) ascending, ties broken by id. The two + // identical-timestamp user rows (pa-user-1 and pa-user-tie) order by id. + wantA := map[string]int{ + "pa-user-1": 1, + "pa-user-tie": 2, // same microsecond as pa-user-1, later id + "pa-user-2": 3, + "pa-user-3": 4, + "pa-agent-1": 0, + "pa-agent-2": 0, + } + assertPromptOrdinals(t, ctx, repo, "sess-PA", wantA) + + // Cross-session isolation: session B's numbering restarts at 1. + wantB := map[string]int{"pb-user-1": 1, "pb-user-2": 2} + assertPromptOrdinals(t, ctx, repo, "sess-PB", wantB) +} + +// TestPromptIndexTiedMicrosecondPagesContiguous verifies that pages split within a tied-microsecond burst keep contiguous ordinals. +func TestPromptIndexTiedMicrosecondPagesContiguous(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-TIE", "sess-TIE", "turn-TIE") + + // Nanosecond-distinct raw timestamps sharing one microsecond, with + // REVERSED id order: the later instant has the smaller id. Ordering must + // be by (microsecond, id), so the smaller id comes first. + tsLater := time.Date(2026, 8, 18, 10, 0, 0, 123456789, time.UTC) + tsEarlier := time.Date(2026, 8, 18, 10, 0, 0, 123456001, time.UTC) + insertPromptRow(t, repo, "tie-zzz", "sess-TIE", "turn-TIE", "user", "later instant, later id", tsLater) + insertPromptRow(t, repo, "tie-aaa", "sess-TIE", "turn-TIE", "user", "earlier instant, earlier id", tsEarlier) + insertPromptRow(t, repo, "tie-mmm", "sess-TIE", "turn-TIE", "user", "middle id, same microsecond", tsLater) + + backfillPromptSeqForTest(t, repo) + + want := map[string]int{"tie-aaa": 1, "tie-mmm": 2, "tie-zzz": 3} + assertPromptOrdinals(t, ctx, repo, "sess-TIE", want) + + // The SQLite normalized key equals the frontend epochNanoseconds/1000n + // floor division for the same rows. + nm := dialect.NormalizedMicrosecond(dialect.SQLite3, "created_at") + var got string + if err := repo.ro.QueryRowContext(ctx, repo.ro.Rebind("SELECT "+nm+" FROM task_session_messages WHERE id = ?"), "tie-zzz").Scan(&got); err != nil { + t.Fatalf("query normalized key: %v", err) + } + if wantKey := frontendMicrosecondKey(t, "2026-08-18T10:00:00.123456789Z"); got != wantKey { + t.Errorf("normalized key = %q, want frontend floor key %q", got, wantKey) + } + + // Paginate backward with a small page; the page containing prompt #1 must + // never skip a higher-ordinal prompt. All three prompts must appear. + pages := paginateAll(t, ctx, repo, "sess-TIE", 2, "tie-aaa") + var seen []string + for _, page := range pages { + for _, m := range page { + seen = append(seen, m.ID) + } + } + wantOrder := []string{"tie-zzz", "tie-mmm", "tie-aaa"} + if strings.Join(seen, ",") != strings.Join(wantOrder, ",") { + t.Errorf("pagination order = %v, want %v (contiguous, no dup/skip)", seen, wantOrder) + } + + // Non-first pages return absolute (session-wide) prompt_index values. + var lastPageOrdinals []int + for _, m := range pages[len(pages)-1] { + lastPageOrdinals = append(lastPageOrdinals, m.PromptIndex) + } + if fmt.Sprint(lastPageOrdinals) != "[1]" { + t.Errorf("oldest page ordinals = %v, want absolute [1]", lastPageOrdinals) + } +} + +// TestPromptIndexOffsetRowsMatchFrontendUTCKey verifies the offset-style key matches the frontend UTC normalized key for the same rows. +func TestPromptIndexOffsetRowsMatchFrontendUTCKey(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-OFF", "sess-OFF", "turn-OFF") + + // A seed row carrying a +02:00 offset: its UTC instant is 08:00:00, and + // the frontend's UTC key must match the backend's normalized key. + insertPromptRowWithRawTimestamp(t, repo, "off-user-1", "sess-OFF", "turn-OFF", "user", "offset prompt 1", "2026-08-18 10:00:00.123456+02:00") + insertPromptRowWithRawTimestamp(t, repo, "off-user-2", "sess-OFF", "turn-OFF", "user", "offset prompt 2", "2026-08-18 10:00:01.000000+02:00") + insertPromptRowWithRawTimestamp(t, repo, "off-agent", "sess-OFF", "turn-OFF", "agent", "offset reply", "2026-08-18 10:00:02.000000+02:00") + + backfillPromptSeqForTest(t, repo) + + want := map[string]int{"off-user-1": 1, "off-user-2": 2, "off-agent": 0} + assertPromptOrdinals(t, ctx, repo, "sess-OFF", want) + + // The SQL normalized key equals the frontend UTC floor key for the row. + nm := dialect.NormalizedMicrosecond(dialect.SQLite3, "created_at") + var got string + if err := repo.ro.QueryRowContext(ctx, repo.ro.Rebind("SELECT "+nm+" FROM task_session_messages WHERE id = ?"), "off-user-1").Scan(&got); err != nil { + t.Fatalf("query normalized key: %v", err) + } + if wantKey := frontendMicrosecondKey(t, "2026-08-18T10:00:00.123456+02:00"); got != wantKey { + t.Errorf("normalized key = %q, want frontend UTC key %q", got, wantKey) + } + + // Paginate with the offset-carrying row as the cursor: pages must be + // contiguous in ordinal order (normalized string bound, not the raw + // offset string), with no duplicated or skipped rows. The agent row at + // 10:00:02+02:00 (08:00:02 UTC) is the newest row overall. + order := []string{} + cursor := "" + for { + msgs, hasMore, err := repo.ListMessagesPaginated(ctx, "sess-OFF", models.ListMessagesOptions{Limit: 1, Sort: "desc", Before: cursor}) + if err != nil { + t.Fatalf("paginate: %v", err) + } + if len(msgs) == 1 { + order = append(order, msgs[0].ID) + } + if !hasMore || len(msgs) == 0 { + break + } + cursor = msgs[0].ID + } + wantOrder := []string{"off-agent", "off-user-2", "off-user-1"} + if strings.Join(order, ",") != strings.Join(wantOrder, ",") { + t.Errorf("pagination order = %v, want %v", order, wantOrder) + } + + // The cursor-key derivation SELECT returns the UTC-normalized key, never a + // driver-parsed RFC3339Nano serialization. + cursorKey, err := repo.messageCursorKey(ctx, "off-user-1") + if err != nil { + t.Fatalf("messageCursorKey: %v", err) + } + if cursorKey != frontendMicrosecondKey(t, "2026-08-18T10:00:00.123456+02:00") { + t.Errorf("cursor key = %q, want normalized UTC key %q", cursorKey, frontendMicrosecondKey(t, "2026-08-18T10:00:00.123456+02:00")) + } + if strings.Contains(cursorKey, "T") || strings.HasSuffix(cursorKey, "Z") { + t.Errorf("cursor key %q must be the space-separated UTC form, never RFC3339Nano", cursorKey) + } +} diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index 6bbb6f398e..e21a99a101 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -925,6 +925,7 @@ func messageEventChangesPendingAction(eventType string, message *models.Message) } } +// newMessageEvent builds a bus event for a message lifecycle change, embedding the message's prompt index when present. func newMessageEvent(eventType string, message *models.Message) *bus.Event { messageType := string(message.Type) @@ -950,6 +951,12 @@ func newMessageEvent(eventType string, message *models.Message) *bus.Event { "updated_at": message.UpdatedAt.Format(time.RFC3339Nano), } + // User messages carry their stable prompt ordinal so WS consumers can + // render the panel label without an extra fetch; agent rows omit it. + if message.PromptIndex > 0 { + data["prompt_index"] = message.PromptIndex + } + if hasHidden { data["raw_content"] = message.Content } diff --git a/apps/backend/internal/task/service/service_messages.go b/apps/backend/internal/task/service/service_messages.go index 5bb0a90f36..abeefa91ef 100644 --- a/apps/backend/internal/task/service/service_messages.go +++ b/apps/backend/internal/task/service/service_messages.go @@ -86,7 +86,11 @@ func (s *Service) CreateMessage(ctx context.Context, req *CreateMessageRequest) Type: messageType, Metadata: req.Metadata, RequestsInput: req.RequestsInput, - CreatedAt: time.Now().UTC(), + // CreatedAt deliberately left zero: the repository assigns it inside + // the atomic per-session create boundary, which treats a zero + // timestamp as a LIVE create (advancing a colliding or backward key + // by one tick). Pre-populating it here would misclassify the message + // as an explicit import and reject same-microsecond creates. } if err := s.messages.CreateMessage(ctx, message); err != nil { @@ -110,13 +114,14 @@ func (s *Service) CreateMessage(ctx context.Context, req *CreateMessageRequest) // after the database commit, so retrying must not create a second user turn. // The handler performs the fast preflight before session-state side effects; // this method also closes the concurrent two-request race at the repository -// primary-key boundary. +// primary-key boundary. Replay reads go through GetMessageWithPromptIndex so +// the returned row carries its stable prompt ordinal. func (s *Service) CreateMessageIdempotent(ctx context.Context, id string, req *CreateMessageRequest) (*models.Message, error) { if id == "" { return nil, errors.New("message id is required for idempotent creation") } - existing, err := s.messages.GetMessage(ctx, id) + existing, err := s.messages.GetMessageWithPromptIndex(ctx, id) if err == nil && existing != nil { return existing, nil } @@ -131,7 +136,7 @@ func (s *Service) CreateMessageIdempotent(ctx context.Context, id string, req *C // Another request may have won the insert while this request was building // its turn. Read the committed row and treat that duplicate as success. - existing, lookupErr := s.messages.GetMessage(ctx, id) + existing, lookupErr := s.messages.GetMessageWithPromptIndex(ctx, id) if lookupErr == nil && existing != nil { return existing, nil } @@ -268,7 +273,8 @@ func (s *Service) buildMessage(ctx context.Context, id string, req *CreateMessag Type: messageType, Metadata: req.Metadata, RequestsInput: req.RequestsInput, - CreatedAt: time.Now().UTC(), + // CreatedAt deliberately left zero: the repository's atomic per-session + // create boundary assigns it (live creates advance a colliding key). }, nil } @@ -316,6 +322,22 @@ func (s *Service) GetMessage(ctx context.Context, id string) (*models.Message, e return message, nil } +// GetMessageWithPromptIndex retrieves a message by ID with its computed +// prompt ordinal, scoped like GetMessage. Used by the idempotent WS +// replay/response path so a retried prompt answers with its stable index. +func (s *Service) GetMessageWithPromptIndex(ctx context.Context, id string) (*models.Message, error) { + message, err := s.messages.GetMessageWithPromptIndex(ctx, id) + if err != nil { + return nil, err + } + if message.TaskSessionID != "" { + if err := s.AuthorizeSessionAccess(ctx, message.TaskSessionID); err != nil { + return nil, err + } + } + return message, nil +} + // ListMessages returns all messages for a session. func (s *Service) ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) { if err := s.AuthorizeSessionAccess(ctx, sessionID); err != nil { @@ -387,8 +409,24 @@ func (s *Service) UpdateMessage(ctx context.Context, message *models.Message) er return err } - // Publish message.updated event for real-time streaming - s.publishMessageEvent(ctx, events.MessageUpdated, message) + // User rows carry the stable prompt ordinal in message.updated events: + // re-read the affected row through GetMessageWithPromptIndex before + // publishing. Agent messages and streaming agent content/thinking updates + // stay on the hot 12-column loaded model and never carry the field. + published := message + if message.AuthorType == models.MessageAuthorUser { + if indexed, err := s.messages.GetMessageWithPromptIndex(ctx, message.ID); err == nil { + published = indexed + } else { + s.logger.Warn("failed to re-read indexed message for update event", + zap.String("message_id", message.ID), + zap.Error(err)) + } + } + + // Publish message.updated event for real-time streaming. Delivery is best + // effort after the durable write succeeded (see publishMessageEvent). + _ = s.publishMessageEvent(ctx, events.MessageUpdated, published) return nil } diff --git a/apps/backend/internal/task/service/service_prompt_index_test.go b/apps/backend/internal/task/service/service_prompt_index_test.go new file mode 100644 index 0000000000..8eeb4f02f9 --- /dev/null +++ b/apps/backend/internal/task/service/service_prompt_index_test.go @@ -0,0 +1,328 @@ +package service + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// seedPriorUserMessages creates n user messages through the repository's +// atomic create boundary (live timestamps), returning them in creation order. +func seedPriorUserMessages(t *testing.T, repo interface { + CreateMessage(ctx context.Context, message *models.Message) error +}, sessionID, turnID, taskID string, n int) []*models.Message { + t.Helper() + seeded := make([]*models.Message, 0, n) + for i := 0; i < n; i++ { + msg := &models.Message{ + TaskSessionID: sessionID, + TaskID: taskID, + TurnID: turnID, + AuthorType: models.MessageAuthorUser, + Content: "prior prompt", + } + if err := repo.CreateMessage(context.Background(), msg); err != nil { + t.Fatalf("seed prior user message %d: %v", i, err) + } + seeded = append(seeded, msg) + } + return seeded +} + +// eventData extracts the structured data payload from a published bus event. +func eventData(t *testing.T, bus *MockEventBus) map[string]interface{} { + t.Helper() + return singlePublishedEventData(t, bus) +} + +// TestCreateMessageReturnsAndPublishesPromptIndex: after N-1 prior user +// messages, CreateMessage returns PromptIndex == N and the published +// message.added event carries prompt_index: N. +func TestCreateMessageReturnsAndPublishesPromptIndex(t *testing.T) { + svc, bus, repo := newMessageTestService(t) + ctx := context.Background() + prior := seedPriorUserMessages(t, repo, "sess-msg", "turn-msg", "task-msg", 2) + if prior[0].PromptIndex != 1 || prior[1].PromptIndex != 2 { + t.Fatalf("prior ordinals = (%d, %d), want (1, 2)", prior[0].PromptIndex, prior[1].PromptIndex) + } + bus.ClearEvents() + + message, err := svc.CreateMessage(ctx, &CreateMessageRequest{ + TaskSessionID: "sess-msg", + Content: "third prompt", + }) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } + if message.PromptIndex != 3 { + t.Fatalf("returned PromptIndex = %d, want 3", message.PromptIndex) + } + data := eventData(t, bus) + if got, ok := data["prompt_index"]; !ok || got != 3 { + t.Fatalf("message.added prompt_index = %#v, want 3", got) + } +} + +// TestCreateMessageWithIDUserReturnsPromptIndex: a fresh user +// CreateMessageWithID returns its ordinal and publishes it. +func TestCreateMessageWithIDUserReturnsPromptIndex(t *testing.T) { + svc, bus, repo := newMessageTestService(t) + ctx := context.Background() + seedPriorUserMessages(t, repo, "sess-msg", "turn-msg", "task-msg", 1) + bus.ClearEvents() + + message, err := svc.CreateMessageWithID(ctx, "fresh-stream-id", &CreateMessageRequest{ + TaskSessionID: "sess-msg", + Content: "second prompt", + }) + if err != nil { + t.Fatalf("CreateMessageWithID: %v", err) + } + if message.PromptIndex != 2 { + t.Fatalf("returned PromptIndex = %d, want 2", message.PromptIndex) + } + data := eventData(t, bus) + if got, ok := data["prompt_index"]; !ok || got != 2 { + t.Fatalf("message.added prompt_index = %#v, want 2", got) + } +} + +// TestCreateMessageIdempotentReplayReturnsIndexedMessage: replaying a +// caller-owned ID returns the same indexed row without a duplicate add event. +func TestCreateMessageIdempotentReplayReturnsIndexedMessage(t *testing.T) { + svc, bus, repo := newMessageTestService(t) + ctx := context.Background() + + created, err := svc.CreateMessageIdempotent(ctx, "client-id-1", &CreateMessageRequest{ + TaskSessionID: "sess-msg", + Content: "first prompt", + }) + if err != nil { + t.Fatalf("first CreateMessageIdempotent: %v", err) + } + if created.PromptIndex != 1 { + t.Fatalf("created PromptIndex = %d, want 1", created.PromptIndex) + } + bus.ClearEvents() + + replayed, err := svc.CreateMessageIdempotent(ctx, "client-id-1", &CreateMessageRequest{ + TaskSessionID: "sess-msg", + Content: "first prompt", + }) + if err != nil { + t.Fatalf("replay CreateMessageIdempotent: %v", err) + } + if replayed.ID != "client-id-1" || replayed.PromptIndex != 1 { + t.Fatalf("replayed = %s (index %d), want client-id-1 with index 1", replayed.ID, replayed.PromptIndex) + } + if types := eventTypes(bus.GetPublishedEvents()); len(types) != 0 { + t.Fatalf("replay published %v, want no duplicate add event", types) + } + + // The stored row still carries the ordinal. + stored, err := repo.GetMessageWithPromptIndex(ctx, "client-id-1") + if err != nil { + t.Fatalf("GetMessageWithPromptIndex: %v", err) + } + if stored.PromptIndex != 1 { + t.Fatalf("stored PromptIndex = %d, want 1", stored.PromptIndex) + } +} + +// TestUpdateMessageUserPublishesPromptIndex: a user UpdateMessage re-reads +// the affected row through GetMessageWithPromptIndex so message.updated +// carries the stable ordinal. +func TestUpdateMessageUserPublishesPromptIndex(t *testing.T) { + svc, bus, repo := newMessageTestService(t) + ctx := context.Background() + prior := seedPriorUserMessages(t, repo, "sess-msg", "turn-msg", "task-msg", 1) + bus.ClearEvents() + + message := prior[0] + message.Content = "edited prompt" + if err := svc.UpdateMessage(ctx, message); err != nil { + t.Fatalf("UpdateMessage: %v", err) + } + data := eventData(t, bus) + if got, ok := data["prompt_index"]; !ok || got != 1 { + t.Fatalf("message.updated prompt_index = %#v, want 1", got) + } + if types := eventTypes(bus.GetPublishedEvents()); len(types) != 1 || types[0] != events.MessageUpdated { + t.Fatalf("published %v, want exactly one %s", types, events.MessageUpdated) + } +} + +// TestAgentMessagePathsPublishNoPromptIndex: agent creates and streaming +// content/thinking updates stay on the hot 12-column path and never carry +// prompt_index in their events. +func TestAgentMessagePathsPublishNoPromptIndex(t *testing.T) { + svc, bus, _ := newMessageTestService(t) + ctx := context.Background() + + agentMsg, err := svc.CreateMessage(ctx, &CreateMessageRequest{ + TaskSessionID: "sess-msg", + AuthorType: "agent", + Content: "agent reply", + }) + if err != nil { + t.Fatalf("agent CreateMessage: %v", err) + } + if agentMsg.PromptIndex != 0 { + t.Fatalf("agent create PromptIndex = %d, want 0", agentMsg.PromptIndex) + } + data := eventData(t, bus) + if _, ok := data["prompt_index"]; ok { + t.Fatalf("agent message.added carries prompt_index %v", data["prompt_index"]) + } + bus.ClearEvents() + + if err := svc.AppendMessageContent(ctx, agentMsg.ID, " more"); err != nil { + t.Fatalf("AppendMessageContent: %v", err) + } + data = eventData(t, bus) + if _, ok := data["prompt_index"]; ok { + t.Fatalf("agent append event carries prompt_index %v", data["prompt_index"]) + } + bus.ClearEvents() + + if err := svc.AppendThinkingContent(ctx, agentMsg.ID, " reasoning"); err != nil { + t.Fatalf("AppendThinkingContent: %v", err) + } + data = eventData(t, bus) + if _, ok := data["prompt_index"]; ok { + t.Fatalf("agent thinking event carries prompt_index %v", data["prompt_index"]) + } +} + +// TestSequentialLiveCreatesAssignDistinctOrdinals: rapid consecutive live +// user creates (no explicit timestamps, no clock control) always receive +// distinct, consistent ordinals and events — the repository boundary keeps +// the session's prompt ordering monotonic even when creates land in the same +// microsecond. +func TestSequentialLiveCreatesAssignDistinctOrdinals(t *testing.T) { + svc, bus, repo := newMessageTestService(t) + ctx := context.Background() + bus.ClearEvents() + + ids := []string{} + for i := 0; i < 3; i++ { + message, err := svc.CreateMessage(ctx, &CreateMessageRequest{ + TaskSessionID: "sess-msg", + Content: "prompt", + }) + if err != nil { + t.Fatalf("create %d: %v", i, err) + } + ids = append(ids, message.ID) + data := eventData(t, bus) + if got, ok := data["prompt_index"]; !ok || got != i+1 { + t.Fatalf("create %d event prompt_index = %#v, want %d", i, got, i+1) + } + bus.ClearEvents() + } + + // Reread ordinals are distinct and consistent with the events. + seen := map[int]bool{} + for _, id := range ids { + stored, err := repo.GetMessageWithPromptIndex(ctx, id) + if err != nil { + t.Fatalf("GetMessageWithPromptIndex(%s): %v", id, err) + } + if stored.PromptIndex < 1 || stored.PromptIndex > 3 { + t.Fatalf("stored ordinal %d out of range", stored.PromptIndex) + } + if seen[stored.PromptIndex] { + t.Fatalf("duplicate ordinal %d for %s", stored.PromptIndex, id) + } + seen[stored.PromptIndex] = true + } + if len(seen) != 3 { + t.Fatalf("distinct ordinals = %d, want 3", len(seen)) + } +} + +// capturingMessagesRepo wraps the real MessageRepository and records whether +// the service passes a ZERO CreatedAt into CreateMessage — the live/import +// discriminator for the atomic per-session create boundary. A pre-populated +// timestamp would classify the message as an explicit import and reject +// same-microsecond (or backward-clock) live creates instead of advancing. +type capturingMessagesRepo struct { + repository.MessageRepository + createdAtZeroAtRepoEntry []bool +} + +// CreateMessage persists a message in the fake repository, mirroring the repository contract. +func (m *capturingMessagesRepo) CreateMessage(ctx context.Context, message *models.Message) error { + m.createdAtZeroAtRepoEntry = append(m.createdAtZeroAtRepoEntry, message.CreatedAt.IsZero()) + return m.MessageRepository.CreateMessage(ctx, message) +} + +// TestServiceUserCreateLeavesTimestampToBoundary pins the review fix: the +// service must NOT pre-populate CreatedAt for user messages, so the +// repository boundary sees a zero timestamp and runs the LIVE branch (the +// one-tick advance for colliding keys) instead of the explicit-import branch. +func TestServiceUserCreateLeavesTimestampToBoundary(t *testing.T) { + _, _, repo := createTestService(t) + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-bound", Name: "Boundary"}); err != nil { + t.Fatalf("create workspace: %v", err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-bound", WorkspaceID: "ws-bound", Name: "flow"}); err != nil { + t.Fatalf("create workflow: %v", err) + } + if err := repo.CreateTask(ctx, &models.Task{ + ID: "task-bound", WorkspaceID: "ws-bound", WorkflowID: "wf-bound", WorkflowStepID: "step-1", + Title: "Boundary", State: v1.TaskStateCreated, Priority: "medium", + }); err != nil { + t.Fatalf("create task: %v", err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "sess-bound", TaskID: "task-bound", State: models.TaskSessionStateCreated, + }); err != nil { + t.Fatalf("create session: %v", err) + } + seedTurn(t, repo, "turn-bound", "sess-bound", "task-bound") + + wrapped := &capturingMessagesRepo{MessageRepository: repo} + bus := NewMockEventBus() + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"}) + svc := NewService(Repos{ + Workspaces: repo, + Tasks: repo, + TaskRepos: repo, + Workflows: repo, + Messages: wrapped, + Turns: repo, + Sessions: repo, + GitSnapshots: repo, + RepoEntities: repo, + RepositoryCleanup: repo, + Executors: repo, + Environments: repo, + TaskEnvironments: repo, + Reviews: repo, + ResourceCleanups: repo, + }, bus, log, RepositoryDiscoveryConfig{}) + + message, err := svc.CreateMessage(ctx, &CreateMessageRequest{ + TaskSessionID: "sess-bound", + Content: "live prompt", + }) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } + if len(wrapped.createdAtZeroAtRepoEntry) != 1 || !wrapped.createdAtZeroAtRepoEntry[0] { + t.Fatalf("service passed CreatedAt.IsZero()=false to the repository; live boundary would misclassify the message as an explicit import") + } + if message.PromptIndex != 1 { + t.Fatalf("returned PromptIndex = %d, want 1", message.PromptIndex) + } + if message.CreatedAt.IsZero() { + t.Fatal("the repository boundary must assign CreatedAt for live creates") + } +} diff --git a/apps/backend/pkg/api/v1/task.go b/apps/backend/pkg/api/v1/task.go index 3ab040dc6f..6f5bc6073e 100644 --- a/apps/backend/pkg/api/v1/task.go +++ b/apps/backend/pkg/api/v1/task.go @@ -242,6 +242,11 @@ type Message struct { Metadata map[string]interface{} `json:"metadata,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at,omitempty"` // Authoritative per-message change signal + // PromptIndex is the 1-based ordinal of the message among ALL user + // messages of its session (ordered by normalized-microsecond created_at + // ascending, ties by id ascending), present only on user messages + // produced by an indexed server payload; omitted when zero. + PromptIndex int `json:"prompt_index,omitempty"` } // CreateMessageRequest for adding a message to a task session diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index f120495342..572cce89cb 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -103,6 +103,7 @@ async function loadSnapshotState( metaBySession: { [sessionId]: { isLoading: false, + isLoadingMore: false, hasMore: messagesResponse.has_more ?? false, oldestCursor: messages[0]?.id ?? null, }, diff --git a/apps/web/components/task/chat/message-list-native-scroll.ts b/apps/web/components/task/chat/message-list-native-scroll.ts index 9cb17fa6c5..764dda2144 100644 --- a/apps/web/components/task/chat/message-list-native-scroll.ts +++ b/apps/web/components/task/chat/message-list-native-scroll.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { useAppStoreApi } from "@/components/state-provider"; import { getStoredAutoScrollTop } from "@/lib/local-storage"; +import { useLazyLoadSentinel as useSharedLazyLoadSentinel } from "@/hooks/use-lazy-load-sentinel"; import type { Message } from "@/lib/types/http"; import type { RenderItem } from "@/hooks/use-processed-messages"; import { getItemKey, shouldAutoScrollToBottom } from "./message-list-shared"; @@ -73,64 +74,19 @@ function useScrollPositionOnPrepend( /** * Observes a sentinel element at the top of the list to trigger lazy loading. - * Uses a callback ref so the observer reconnects when the sentinel remounts. - * - * Handles the timing issue where the sentinel DOM node mounts (callback ref fires) - * before the useEffect creates the IntersectionObserver. The sentinelNodeRef bridges - * the gap: the callback ref stores the node, and the effect observes it if present. + * Delegates to the shared useLazyLoadSentinel hook with the transcript's + * defaults (no automatic re-arm, no join), so the transcript's behavior is + * unchanged: the callback-ref bridging, stateRef, observer lifecycle, and + * no-automatic-re-arm semantics are the shared hook's defaults. */ function useLazyLoadSentinel( scrollRef: React.RefObject, hasMore: boolean, + blocked: boolean, isLoadingMore: boolean, loadMore: () => Promise, ) { - const stateRef = useRef({ hasMore, isLoadingMore }); - useEffect(() => { - stateRef.current = { hasMore, isLoadingMore }; - }, [hasMore, isLoadingMore]); - - const observerRef = useRef(null); - const sentinelNodeRef = useRef(null); - - // Create/destroy observer when scroll container changes - useEffect(() => { - const root = scrollRef.current; - if (!root) return; - const observer = new IntersectionObserver( - (entries) => { - const { hasMore, isLoadingMore } = stateRef.current; - const isIntersecting = entries[0]?.isIntersecting; - if (isIntersecting && hasMore && !isLoadingMore) { - loadMore(); - } - }, - { root, rootMargin: "200px 0px 0px 0px" }, - ); - observerRef.current = observer; - // If sentinel already mounted before this effect ran, observe it now - if (sentinelNodeRef.current) { - observer.observe(sentinelNodeRef.current); - } - return () => { - observer.disconnect(); - observerRef.current = null; - }; - }, [scrollRef, loadMore]); - - // Callback ref — stores node and observes if observer already exists - const sentinelRef = useCallback((node: HTMLDivElement | null) => { - sentinelNodeRef.current = node; - const observer = observerRef.current; - if (observer) { - observer.disconnect(); - if (node) { - observer.observe(node); - } - } - }, []); - - return sentinelRef; + return useSharedLazyLoadSentinel(scrollRef, hasMore, blocked, isLoadingMore, loadMore); } /** Duration a programmatic scroll's guard stays held if the browser never @@ -574,6 +530,8 @@ export function useNativeScrollManagement(params: { sessionId: string | null; enabled: boolean; hasUnreadDivider: boolean; + /** Initial/refetch loading: the sentinel's hard block (never fires, never joins). */ + messagesLoading: boolean; hasMore: boolean; isLoadingMore: boolean; loadMore: () => Promise; @@ -586,6 +544,7 @@ export function useNativeScrollManagement(params: { sessionId, enabled, hasUnreadDivider, + messagesLoading, hasMore, isLoadingMore, loadMore, @@ -608,7 +567,13 @@ export function useNativeScrollManagement(params: { ); const handleScrollToMessage = useScrollToMessage(scrollRef, runGuardedScroll); useScrollPositionOnPrepend(scrollRef, items, isProgrammaticScrollLocked); - const sentinelRef = useLazyLoadSentinel(scrollRef, hasMore, isLoadingMore, loadMore); + const { sentinelRef } = useLazyLoadSentinel( + scrollRef, + hasMore, + messagesLoading, + isLoadingMore, + loadMore, + ); useInitialScrollPosition(scrollRef, items.length, sessionId, enabled, isNearBottomRef); return { handleScrollToMessage, sentinelRef, resyncIsNearBottom, markNotNearBottom }; diff --git a/apps/web/components/task/chat/message-list-native.tsx b/apps/web/components/task/chat/message-list-native.tsx index de42ed1857..d0b72ce2d3 100644 --- a/apps/web/components/task/chat/message-list-native.tsx +++ b/apps/web/components/task/chat/message-list-native.tsx @@ -82,6 +82,8 @@ type NativeMessageListScrollParams = { enabled: boolean; dividerBeforeItemKey?: string | null; anchoredBarHeight?: number; + /** Initial/refetch loading: the sentinel's hard block. */ + messagesLoading: boolean; hasMore: boolean; isLoadingMore: boolean; loadMore: () => Promise; @@ -109,6 +111,7 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) { enabled, dividerBeforeItemKey, anchoredBarHeight, + messagesLoading, hasMore, isLoadingMore, loadMore, @@ -126,6 +129,7 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) { sessionId, enabled, hasUnreadDivider: Boolean(dividerBeforeItemKey), + messagesLoading, hasMore, isLoadingMore, loadMore, @@ -466,7 +470,7 @@ export const NativeMessageList = memo( isWorking, sessionState, }); - const { loadMore, hasMore, isLoading: isLoadingMore } = useLazyLoadMessages(sessionId); + const { loadMore, hasMore, isLoadingMore } = useLazyLoadMessages(sessionId); const { activeTurnId } = useSessionTurn(sessionId); const effectiveActiveTurnId = getEffectiveActiveTurnId(activeTurnId, isWorking); const streamingMessageId = getStreamingAgentMessageId(messages); @@ -482,6 +486,7 @@ export const NativeMessageList = memo( enabled: autoScrollEnabled, dividerBeforeItemKey, anchoredBarHeight, + messagesLoading, hasMore, isLoadingMore, loadMore, diff --git a/apps/web/components/task/chat/message-list-shared.test.tsx b/apps/web/components/task/chat/message-list-shared.test.tsx index e36c1c653b..69b048c19d 100644 --- a/apps/web/components/task/chat/message-list-shared.test.tsx +++ b/apps/web/components/task/chat/message-list-shared.test.tsx @@ -60,7 +60,7 @@ vi.mock("@/hooks/use-lazy-load-messages", () => ({ useLazyLoadMessages: () => ({ loadMore: async () => 0, hasMore: false, - isLoading: false, + isLoadingMore: false, }), })); vi.mock("@/components/task/chat/messages/agent-status", () => ({ diff --git a/apps/web/components/task/chat/use-drain-older-messages.test.ts b/apps/web/components/task/chat/use-drain-older-messages.test.ts index 42d5dfe382..0ab2abd9e9 100644 --- a/apps/web/components/task/chat/use-drain-older-messages.test.ts +++ b/apps/web/components/task/chat/use-drain-older-messages.test.ts @@ -1,32 +1,45 @@ import { useCallback, useState } from "react"; -import { describe, it, expect, vi, afterEach } from "vitest"; +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; import { renderHook, cleanup, waitFor, act } from "@testing-library/react"; +const storeMock = vi.hoisted(() => ({ messagesLoading: false })); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ + messages: { + metaBySession: { s1: { isLoading: storeMock.messagesLoading } }, + }, + }), +})); + // A faithful-enough fake of `useLazyLoadMessages`: real `useState` so calling // its own setters genuinely re-renders consumers (unlike a static mock // return value), letting the reactive drain hook under test observe fresh -// `hasMore`/`isLoading` exactly as it would against the real store-backed hook. +// `hasMore`/`isLoadingMore` exactly as it would against the real store-backed +// hook. `messagesLoading` (initial/refetch) is controlled through the store +// mock separately. let mockPages: number[] = []; let mockLoadMoreCalls = 0; // When set, overrides the page-array-driven default: called with the same // `setState` the fake hook itself uses, so every path (page-array or custom) -// updates `hasMore`/`isLoading` identically and consistently. +// updates `hasMore`/`isLoadingMore` identically and consistently. let mockLoadMoreImpl: - | ((setState: (state: { hasMore: boolean; isLoading: boolean }) => void) => Promise) + | ((setState: (state: { hasMore: boolean; isLoadingMore: boolean }) => void) => Promise) | null = null; function useLazyLoadMessagesFake() { - const [state, setState] = useState({ hasMore: true, isLoading: false }); + const [state, setState] = useState({ hasMore: true, isLoadingMore: false }); const loadMore = useCallback(async () => { mockLoadMoreCalls++; if (mockLoadMoreImpl) return mockLoadMoreImpl(setState); const index = mockLoadMoreCalls - 1; const fetched = mockPages[index] ?? 0; const hasMore = index + 1 < mockPages.length; - setState({ hasMore, isLoading: false }); + setState({ hasMore, isLoadingMore: false }); return fetched; }, []); - return { loadMore, hasMore: state.hasMore, isLoading: state.isLoading }; + return { loadMore, hasMore: state.hasMore, isLoadingMore: state.isLoadingMore }; } vi.mock("@/hooks/use-lazy-load-messages", () => ({ @@ -35,11 +48,16 @@ vi.mock("@/hooks/use-lazy-load-messages", () => ({ import { useDrainOlderMessages } from "./use-drain-older-messages"; +beforeEach(() => { + storeMock.messagesLoading = false; +}); + afterEach(() => { cleanup(); mockPages = []; mockLoadMoreCalls = 0; mockLoadMoreImpl = null; + storeMock.messagesLoading = false; }); describe("useDrainOlderMessages", () => { @@ -65,17 +83,82 @@ describe("useDrainOlderMessages", () => { expect(mockLoadMoreCalls).toBe(3); }); - it("stops at the batch cap when the session never reports exhaustion", async () => { + it("stops at the 1000-message budget when the session never reports exhaustion", async () => { mockPages = new Array(200).fill(20); const { result } = renderHook(() => useDrainOlderMessages("s1", true)); await waitFor(() => expect(result.current.isDraining).toBe(false)); - expect(mockLoadMoreCalls).toBe(50); + expect(mockLoadMoreCalls).toBe(50); // 50 × 20 = 1000 }); + it("stops at the first batch reaching or exceeding 1000 messages (overshoot by at most one batch)", async () => { + // Mixed first-request-wins page sizes: 600 then 600 overshoots by one batch. + mockPages = [600, 600, 600]; + const { result } = renderHook(() => useDrainOlderMessages("s1", true)); + await waitFor(() => expect(result.current.isDraining).toBe(false)); + expect(mockLoadMoreCalls).toBe(2); // 600 + 600 = 1200 >= 1000 + }); +}); + +describe("useDrainOlderMessages — concurrency and lifecycle", () => { + it("stops immediately after a zero-result batch even when hasMore stays true", async () => { + // Retained-cursor zero-row drain termination: the third page must not fire. + mockPages = [20, 0, 20]; + const { result } = renderHook(() => useDrainOlderMessages("s1", true)); + await waitFor(() => expect(result.current.isDraining).toBe(false)); + expect(mockLoadMoreCalls).toBe(2); + }); + + it("resets the cumulative counter on reactivation", async () => { + // First drain: 600 + 0 (zero-row stop). A fresh drain starts from a zero + // cumulative count, so the budget is not pre-consumed by the earlier drain. + let callCount = 0; + mockLoadMoreImpl = (setState) => { + callCount++; + if (callCount === 1 || callCount === 2 || callCount === 3) { + setState({ hasMore: true, isLoadingMore: false }); + return Promise.resolve(600); + } + setState({ hasMore: false, isLoadingMore: false }); + return Promise.resolve(600); + }; + const { result, rerender } = renderHook( + ({ active }: { active: boolean }) => useDrainOlderMessages("s1", active), + { initialProps: { active: true } }, + ); + await waitFor(() => expect(result.current.isDraining).toBe(false)); + expect(callCount).toBe(2); // 600 + 0 (zero-row stop) + + rerender({ active: false }); + rerender({ active: true }); + await waitFor(() => expect(result.current.isDraining).toBe(false)); + expect(callCount).toBe(4); // fresh drain: 600 + 600 = 1200 >= 1000 + }); + + it("blocks batches while an initial/refetch fetch (messagesLoading) is in flight", async () => { + mockPages = [20, 0]; + const { result, rerender } = renderHook( + ({ active }: { active: boolean }) => useDrainOlderMessages("s1", active), + { initialProps: { active: true } }, + ); + expect(mockLoadMoreCalls).toBe(1); + + // A turn-settle refetch starts: the drain must not race it. + storeMock.messagesLoading = true; + rerender({ active: true }); + await act(async () => {}); + expect(mockLoadMoreCalls).toBe(1); + storeMock.messagesLoading = false; + rerender({ active: true }); + await waitFor(() => expect(result.current.isDraining).toBe(false)); + expect(mockLoadMoreCalls).toBe(2); + }); +}); + +describe("useDrainOlderMessages — concurrent loads and teardown", () => { it("waits for a concurrent in-flight load instead of racing it, then resumes draining", async () => { // Simulates another caller (e.g. the last-prompt preload effect) already // holding the load in flight when the drain would otherwise fire: the - // reactive design gates every call on `!isLoading`, so it can never + // reactive design gates every call on `!isLoadingMore`, so it can never // observe an ambiguous "0 fetched" from someone else's no-op — it waits // for that fetch to resolve and re-reads the real `hasMore` it leaves behind. let resolveConcurrent: (value: number) => void = () => {}; @@ -83,16 +166,16 @@ describe("useDrainOlderMessages", () => { mockLoadMoreImpl = (setState) => { callCount++; if (callCount === 1) { - setState({ hasMore: true, isLoading: true }); + setState({ hasMore: true, isLoadingMore: true }); return new Promise((resolve) => { resolveConcurrent = (value) => { - setState({ hasMore: true, isLoading: false }); + setState({ hasMore: true, isLoadingMore: false }); resolve(value); }; }); } const hasMore = callCount < 3; - setState({ hasMore, isLoading: false }); + setState({ hasMore, isLoadingMore: false }); return Promise.resolve(hasMore ? 20 : 0); }; const { result, rerender } = renderHook( @@ -103,7 +186,7 @@ describe("useDrainOlderMessages", () => { expect(callCount).toBe(1); // Concurrent caller's fetch finally resolves (a real page, not empty) — - // the drain hook only sees this via the reactive hasMore/isLoading update. + // the drain hook only sees this via the reactive hasMore/isLoadingMore update. await act(async () => { resolveConcurrent(20); }); @@ -115,10 +198,10 @@ describe("useDrainOlderMessages", () => { it("clears isDraining when active flips to false mid-drain", async () => { let resolveFirst: (value: number) => void = () => {}; mockLoadMoreImpl = (setState) => { - setState({ hasMore: true, isLoading: true }); + setState({ hasMore: true, isLoadingMore: true }); return new Promise((resolve) => { resolveFirst = (value) => { - setState({ hasMore: true, isLoading: false }); + setState({ hasMore: true, isLoadingMore: false }); resolve(value); }; }); diff --git a/apps/web/components/task/chat/use-drain-older-messages.ts b/apps/web/components/task/chat/use-drain-older-messages.ts index f4802d64ca..3828be8da6 100644 --- a/apps/web/components/task/chat/use-drain-older-messages.ts +++ b/apps/web/components/task/chat/use-drain-older-messages.ts @@ -1,58 +1,83 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useLazyLoadMessages } from "@/hooks/use-lazy-load-messages"; +import { useAppStore } from "@/components/state-provider"; -/** Hard cap on background pagination batches when draining older messages so - * a runaway session (or buggy `has_more=true` with empty pages) can't loop - * forever. 50 × 20 messages = 1000-message ceiling. */ -const MAX_DRAIN_BATCHES = 50; +/** Hard cap on background pagination when draining older messages so a + * runaway session (or buggy `has_more=true` with empty pages) can't loop + * forever. 1000 messages is the budget; a final batch may overshoot by at + * most one page (mixed first-request-wins page sizes). */ +export const MAX_DRAIN_MESSAGES = 1000; /** * When `active` flips true, walk the message pagination cursor until the - * server reports no more older messages (or the cap is hit). Used by the - * Ctrl+R reverse-search overlay so the user can fuzzy-search the entire - * session history, not only the pages already loaded by the chat list, and - * by the transcript's scroll-to-start affordance so it lands on the true - * first prompt instead of a partial-page boundary. + * server reports no more older messages (or the message budget is hit). Used + * by the Ctrl+R reverse-search overlay so the user can fuzzy-search the entire + * session history, not only the pages already loaded by the chat list, and by + * the transcript's scroll-to-start affordance so it lands on the true first + * prompt instead of a partial-page boundary. * - * Step-driven, not an imperative loop: each batch only fires once `isLoading` - * (from `useLazyLoadMessages`, reactive) is confirmed false, so this never - * races a concurrent caller sharing the same session (e.g. the transcript's - * own last-prompt preload effect) — it waits for that fetch to actually - * finish and re-reads the resulting `hasMore` instead of guessing from an - * ambiguous "0 fetched" return that could mean either genuine exhaustion or - * a no-op against someone else's in-flight request. + * Step-driven, not an imperative loop: each batch only fires once `isLoadingMore` + * (older-page, from `useLazyLoadMessages`, reactive) is confirmed false AND the + * initial/refetch `messagesLoading` flag is clear, so this never races a + * concurrent caller sharing the same session (e.g. the transcript's own + * last-prompt preload effect) — it waits for that fetch to actually finish and + * re-reads the resulting `hasMore` instead of guessing from an ambiguous + * "0 fetched" return that could mean either genuine exhaustion or a no-op + * against someone else's in-flight request. Cumulative fetched count is + * tracked from `loadMore`'s return value: the drain stops at the first batch + * whose cumulative count reaches or exceeds MAX_DRAIN_MESSAGES, and stops + * immediately after a zero-result batch even when `hasMore` remains true. */ export function useDrainOlderMessages(sessionId: string | null, active: boolean) { - const { loadMore, hasMore, isLoading } = useLazyLoadMessages(sessionId); - const [batchCount, setBatchCount] = useState(0); + const { loadMore, hasMore, isLoadingMore } = useLazyLoadMessages(sessionId); + const messagesLoading = useAppStore((state) => + sessionId ? (state.messages.metaBySession[sessionId]?.isLoading ?? false) : false, + ); + const [fetchedMessageCount, setFetchedMessageCount] = useState(0); + const wasActiveRef = useRef(false); - // Reset the batch counter whenever a fresh drain starts, so a later drain - // isn't pre-capped by an earlier one that ran to completion. + // Reset the cumulative counter whenever a fresh drain starts, so a later + // drain isn't pre-capped by an earlier one that ran to completion. useEffect(() => { - if (active) setBatchCount(0); + if (active && !wasActiveRef.current) { + setFetchedMessageCount(0); + } + wasActiveRef.current = active; }, [active]); - const isDraining = active && Boolean(sessionId) && hasMore && batchCount < MAX_DRAIN_BATCHES; + const isDraining = + active && Boolean(sessionId) && hasMore && fetchedMessageCount < MAX_DRAIN_MESSAGES; useEffect(() => { - if (!isDraining || !sessionId || isLoading) return; + if (!isDraining || !sessionId || messagesLoading || isLoadingMore) return; let cancelled = false; void loadMore() - .then(() => { - if (!cancelled) setBatchCount((count) => count + 1); + .then((fetched) => { + if (cancelled) return; + setFetchedMessageCount((count) => { + const next = count + fetched; + // Cap at the budget so a large final batch cannot push a later + // drain's accounting past the ceiling. + return Math.min(next, MAX_DRAIN_MESSAGES); + }); + if (fetched === 0) { + // No progress: stop immediately even if hasMore remains true + // (retained-cursor zero-row drain termination). + setFetchedMessageCount(MAX_DRAIN_MESSAGES); + } }) .catch((error) => { if (!cancelled) { console.error("[useDrainOlderMessages] drain failed:", error); // Stop retrying this session on a hard failure rather than // hammering it once more every render. - setBatchCount(MAX_DRAIN_BATCHES); + setFetchedMessageCount(MAX_DRAIN_MESSAGES); } }); return () => { cancelled = true; }; - }, [isDraining, sessionId, isLoading, loadMore, batchCount]); + }, [isDraining, sessionId, messagesLoading, isLoadingMore, loadMore, fetchedMessageCount]); return { isDraining }; } diff --git a/apps/web/components/task/prompt-history-panel-content.test.tsx b/apps/web/components/task/prompt-history-panel-content.test.tsx index 5b0ed13d4e..5c9e61fe2d 100644 --- a/apps/web/components/task/prompt-history-panel-content.test.tsx +++ b/apps/web/components/task/prompt-history-panel-content.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- single spec for the prompt-history panel content accumulating row, numbering, and auto-load suites; splitting would orphan the plan's named test file. */ import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import i18n from "i18next"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -15,6 +16,13 @@ const { state, messagesBySession, turnsBySession, turnsHydratedBySession } = vi. turnsHydratedBySession: {} as Record, })); +const pagination = vi.hoisted(() => ({ + hasMore: false, + isLoadingMore: false, + messagesLoading: false, + loadMore: vi.fn(async () => 0), +})); + vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (value: typeof state) => unknown) => selector(state), })); @@ -22,6 +30,15 @@ vi.mock("@/components/state-provider", () => ({ vi.mock("@/hooks/domains/session/use-session-messages", () => ({ useSessionMessages: (sessionId: string | null) => ({ messages: sessionId ? (messagesBySession[sessionId] ?? []) : [], + isLoading: pagination.messagesLoading, + }), +})); + +vi.mock("@/hooks/use-lazy-load-messages", () => ({ + useLazyLoadMessages: () => ({ + loadMore: pagination.loadMore, + hasMore: pagination.hasMore, + isLoadingMore: pagination.isLoadingMore, }), })); @@ -55,10 +72,64 @@ const EXPAND_TEST_ID = "prompt-history-expand-0"; const PANEL_TEST_ID = "prompt-history-panel"; const COLLAPSED_WIDTH = 100; const SPAN_NOT_RENDERED = "text span did not render"; +const BUBBLE_SELECTOR = ".rounded-2xl"; +const LONG_PROMPT_TEXT = "long prompt text"; +const SENTINEL_TEST_ID = "prompt-history-sentinel"; +const LOADING_OLDER_TEST_ID = "prompt-history-loading-older"; +const EMPTY_TEXT = "No prompts yet."; type ObserverEntry = { element: Element; callback: ResizeObserverCallback }; const observerEntries: ObserverEntry[] = []; +type IntersectionRecord = { + callback: IntersectionObserverCallback; + instance: IntersectionObserver; + targets: Element[]; +}; +const intersectionRecords: IntersectionRecord[] = []; + +class MockIntersectionObserver implements IntersectionObserver { + readonly root: Element | Document | null = null; + readonly rootMargin = "0px"; + readonly thresholds: readonly number[] = []; + private readonly record: IntersectionRecord; + + /** Records the observer so tests can fire intersections manually. */ + constructor(callback: IntersectionObserverCallback) { + this.record = { callback, instance: this, targets: [] }; + intersectionRecords.push(this.record); + } + + disconnect() {} + /** Records the observed target; duplicate observe is a no-op like the browser. */ + observe(target: Element) { + if (!this.record.targets.includes(target)) this.record.targets.push(target); + } + takeRecords(): IntersectionObserverEntry[] { + return []; + } + unobserve() {} +} + +/** Fires an intersection callback with the given state for every observer + * currently observing the sentinel target. */ +function fireIntersection(isIntersecting: boolean, target?: Element) { + for (const record of intersectionRecords) { + const sentinelTarget = + target ?? + record.targets.find( + (candidate) => candidate.getAttribute("data-testid") === SENTINEL_TEST_ID, + ); + if (!sentinelTarget) continue; + act(() => + record.callback( + [{ isIntersecting, target: sentinelTarget } as IntersectionObserverEntry], + record.instance, + ), + ); + } +} + class CapturingResizeObserver { private readonly callback: ResizeObserverCallback; @@ -159,7 +230,13 @@ function expandButton(index: number): HTMLElement { beforeEach(() => { vi.clearAllMocks(); observerEntries.length = 0; + intersectionRecords.length = 0; vi.stubGlobal("ResizeObserver", CapturingResizeObserver); + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); + pagination.hasMore = false; + pagination.isLoadingMore = false; + pagination.messagesLoading = false; + pagination.loadMore.mockResolvedValue(0); state.tasks.activeSessionId = SESSION_A; state.taskSessions.items = { [SESSION_A]: { name: "Agent A", is_passthrough: false }, @@ -203,7 +280,7 @@ describe("PromptHistoryPanelContent — rows and test IDs", () => { render(); - const bubble = row(0).querySelector(".rounded-2xl"); + const bubble = row(0).querySelector(BUBBLE_SELECTOR); expect(bubble).toBeTruthy(); expect(bubble?.classList.contains("bg-primary/30")).toBe(true); expect(bubble?.classList.contains("px-3")).toBe(true); @@ -226,7 +303,7 @@ describe("PromptHistoryPanelContent — rows and test IDs", () => { render(); - const bubble = row(0).querySelector(".rounded-2xl"); + const bubble = row(0).querySelector(BUBBLE_SELECTOR); const robot = bubble?.querySelector("svg.tabler-icon-robot"); expect(robot).toBeTruthy(); expect(bubble?.firstElementChild).toBe(robot); @@ -238,7 +315,7 @@ describe("PromptHistoryPanelContent — rows and test IDs", () => { render(); - const bubble = row(0).querySelector(".rounded-2xl"); + const bubble = row(0).querySelector(BUBBLE_SELECTOR); expect(bubble?.classList.contains("bg-yellow-200/50")).toBe(true); expect(bubble?.classList.contains("bg-primary/30")).toBe(false); }); @@ -246,7 +323,7 @@ describe("PromptHistoryPanelContent — rows and test IDs", () => { it("renders the empty state when the session has no user prompts", () => { render(); - expect(screen.getByTestId(PANEL_TEST_ID).textContent).toBe("No prompts yet."); + expect(screen.getByTestId(PANEL_TEST_ID).textContent).toBe(EMPTY_TEXT); expect(screen.queryByTestId(/^prompt-history-row-/)).toBeNull(); }); @@ -258,11 +335,69 @@ describe("PromptHistoryPanelContent — rows and test IDs", () => { render(); - expect(screen.getByTestId(PANEL_TEST_ID).textContent).toBe("No prompts yet."); + expect(screen.getByTestId(PANEL_TEST_ID).textContent).toBe(EMPTY_TEXT); expect(screen.queryByTestId(/^prompt-history-row-/)).toBeNull(); }); }); +describe("PromptHistoryPanelContent — prompt numbering", () => { + it("renders #N newest = highest with the rendered row index test IDs", () => { + messagesBySession[SESSION_A] = [ + message({ id: "oldest", prompt_index: 1, content: "oldest prompt" }), + message({ + id: "middle", + prompt_index: 2, + content: "middle prompt", + created_at: "2026-01-01T12:01:00.000Z", + }), + message({ + id: "newest", + prompt_index: 3, + content: "newest prompt", + created_at: "2026-01-01T12:02:00.000Z", + }), + ]; + + render(); + + // Newest first: row 0 is the highest ordinal. + expect(screen.getByTestId("prompt-history-number-0").textContent).toBe("#3"); + expect(screen.getByTestId("prompt-history-number-1").textContent).toBe("#2"); + expect(screen.getByTestId("prompt-history-number-2").textContent).toBe("#1"); + expect(row(0).querySelector(BUBBLE_SELECTOR)?.textContent).toContain("#3"); + }); + + it("renders no label when prompt_index is absent", () => { + messagesBySession[SESSION_A] = [message({ content: "unnumbered prompt" })]; + + render(); + + expect(screen.queryByTestId(/^prompt-history-number-/)).toBeNull(); + expect(row(0).querySelector(BUBBLE_SELECTOR)?.textContent).not.toContain("#"); + }); + + it("keeps the label visible in the expanded state", () => { + messagesBySession[SESSION_A] = [ + message({ id: "long", prompt_index: 7, content: LONG_PROMPT_TEXT }), + ]; + render(); + + const text = row(0).querySelector("span[data-testid='prompt-history-number-0']"); + expect(text?.textContent).toBe("#7"); + + // Force the chevron via overflow and expand. + const collapsed = row(0).querySelector("span.truncate"); + if (!collapsed) throw new Error(SPAN_NOT_RENDERED); + setGeometry(collapsed, { scrollWidth: OVERFLOW_SCROLL_WIDTH, clientWidth: COLLAPSED_WIDTH }); + fireResize(collapsed); + fireEvent.click(expandButton(0)); + + // The label is a sibling of the text/expanded box, so it survives expansion. + expect(screen.getByTestId("prompt-history-number-0").textContent).toBe("#7"); + expect(expandedBox(0).textContent).toBe(LONG_PROMPT_TEXT); + }); +}); + describe("PromptHistoryPanelContent — navigation seam", () => { it("invokes the injected callback when the prompt row is clicked", () => { messagesBySession[SESSION_A] = [message({ id: "prompt-1" })]; @@ -307,7 +442,7 @@ describe("PromptHistoryPanelContent — active-session switch", () => { state.tasks.activeSessionId = null; rerender(); - expect(screen.getByTestId(PANEL_TEST_ID).textContent).toBe("No prompts yet."); + expect(screen.getByTestId(PANEL_TEST_ID).textContent).toBe(EMPTY_TEXT); }); }); @@ -406,7 +541,7 @@ describe("PromptHistoryPanelContent — time element", () => { describe("PromptHistoryPanelContent — expand/collapse behavior", () => { /** Renders a row whose collapsed text overflows, firing the resize that reveals the chevron. */ function renderOverflowingRow() { - messagesBySession[SESSION_A] = [message({ id: "long", content: "long prompt text" })]; + messagesBySession[SESSION_A] = [message({ id: "long", content: LONG_PROMPT_TEXT })]; render(); const text = row(0).querySelector("span"); if (!text) throw new Error(SPAN_NOT_RENDERED); @@ -461,12 +596,12 @@ describe("PromptHistoryPanelContent — expand/collapse behavior", () => { const box = expandedBox(0); expect(box.className).toContain("overflow-y-auto"); expect(box.className).toContain("whitespace-normal"); - expect(box.textContent).toBe("long prompt text"); + expect(box.textContent).toBe(LONG_PROMPT_TEXT); // The only other copy is the collapsed span, which is hidden while // expanded — jsdom does not apply stylesheet classes, so filter by the // component's own `hidden` class instead of computed layout. const nonHidden = screen - .getAllByText("long prompt text") + .getAllByText(LONG_PROMPT_TEXT) .filter((el) => !(el as HTMLElement).className.includes("hidden")); expect(nonHidden).toHaveLength(1); expect(nonHidden[0]).toBe(box); @@ -537,3 +672,193 @@ describe("PromptHistoryPanelContent — expand/collapse behavior", () => { expect(expandButton(1).getAttribute("aria-expanded")).toBe("true"); }); }); + +describe("PromptHistoryPanelContent — auto-load sentinel", () => { + it("keeps the sentinel while hasMore and no entry is prompt #1, and loads on intersection", async () => { + pagination.hasMore = true; + messagesBySession[SESSION_A] = [ + message({ id: "oldest", prompt_index: 3, content: "older prompt" }), + message({ id: "newest", prompt_index: 4, content: "newest prompt", created_at: LATER_TIME }), + ]; + + render(); + + expect(screen.getByTestId(SENTINEL_TEST_ID)).toBeTruthy(); + expect(screen.queryByTestId(LOADING_OLDER_TEST_ID)).toBeNull(); + + fireIntersection(true); + await act(async () => {}); + expect(pagination.loadMore).toHaveBeenCalledTimes(1); + }); + + it("removes the sentinel once prompt #1 is loaded, with no button", () => { + pagination.hasMore = true; + messagesBySession[SESSION_A] = [ + message({ id: "first", prompt_index: 1, content: "first prompt" }), + message({ id: "newest", prompt_index: 2, content: "newest prompt", created_at: LATER_TIME }), + ]; + + render(); + + expect(screen.queryByTestId(SENTINEL_TEST_ID)).toBeNull(); + expect(screen.queryByTestId(LOADING_OLDER_TEST_ID)).toBeNull(); + expect(screen.queryByTestId("load-older-messages")).toBeNull(); + }); + + it("shows the older-page loading row only while shouldPaginate && isLoadingMore", () => { + pagination.hasMore = true; + messagesBySession[SESSION_A] = [message({ id: "m1", prompt_index: 2, content: "prompt" })]; + + const { rerender } = render(); + expect(screen.queryByTestId(LOADING_OLDER_TEST_ID)).toBeNull(); + + pagination.isLoadingMore = true; + rerender(); + expect(screen.getByTestId(LOADING_OLDER_TEST_ID).textContent).toBe("Loading older messages..."); + + // Loading row disappears when the load settles. + pagination.isLoadingMore = false; + rerender(); + expect(screen.queryByTestId(LOADING_OLDER_TEST_ID)).toBeNull(); + }); + + it("fires the shared loader while isLoadingMore (joinInFlightWhileLoading) but never while messagesLoading", async () => { + pagination.hasMore = true; + messagesBySession[SESSION_A] = [message({ id: "m1", prompt_index: 2, content: "prompt" })]; + + const { rerender } = render(); + + // A transcript-owned older-page request is in flight; the panel joins it. + pagination.isLoadingMore = true; + rerender(); + fireIntersection(true); + await act(async () => {}); + expect(pagination.loadMore).toHaveBeenCalledTimes(1); + + // A turn-settle refetch (messagesLoading) is a hard block: never fires, + // never joins. + pagination.isLoadingMore = false; + pagination.messagesLoading = true; + pagination.loadMore.mockClear(); + rerender(); + fireIntersection(true); + await act(async () => {}); + expect(pagination.loadMore).not.toHaveBeenCalled(); + + pagination.messagesLoading = false; + rerender(); + }); + + it("re-arms after a positive result while still intersecting", async () => { + pagination.hasMore = true; + pagination.loadMore.mockResolvedValue(20); + messagesBySession[SESSION_A] = [message({ id: "m1", prompt_index: 2, content: "prompt" })]; + + render(); + + fireIntersection(true); + await act(async () => {}); + expect(pagination.loadMore).toHaveBeenCalledTimes(1); + + // Positive progress re-arms: a further intersection fires the next page. + fireIntersection(true); + await act(async () => {}); + expect(pagination.loadMore).toHaveBeenCalledTimes(2); + }); + + it("does not loop after a zero-result load and retries on scroll-out/scroll-back or wheel", async () => { + pagination.hasMore = true; + pagination.loadMore.mockResolvedValue(0); + messagesBySession[SESSION_A] = [message({ id: "m1", prompt_index: 2, content: "prompt" })]; + + render(); + + fireIntersection(true); + await act(async () => {}); + expect(pagination.loadMore).toHaveBeenCalledTimes(1); + + // Zero result disarms: a still-intersecting fire must not retry. + fireIntersection(true); + await act(async () => {}); + expect(pagination.loadMore).toHaveBeenCalledTimes(1); + + // Scroll away and back re-arms. + fireIntersection(false); + fireIntersection(true); + await act(async () => {}); + expect(pagination.loadMore).toHaveBeenCalledTimes(2); + + // Wheel/touch retry path while disarmed and intersecting. + pagination.loadMore.mockClear(); + fireIntersection(true); + await act(async () => {}); + fireEvent.wheel(screen.getByTestId(PANEL_TEST_ID)); + await act(async () => {}); + expect(pagination.loadMore).toHaveBeenCalledTimes(1); + }); + + it("keeps an empty hasMore=true session paginatable with a sentinel, not empty", () => { + pagination.hasMore = true; + render(); + + expect(screen.getByTestId(SENTINEL_TEST_ID)).toBeTruthy(); + expect(screen.getByTestId(PANEL_TEST_ID).textContent).not.toBe(EMPTY_TEXT); + }); +}); + +describe("PromptHistoryPanelContent — auto-load loading states", () => { + it("shows neutral loading, not empty, when messagesLoading is true with no entries", () => { + pagination.messagesLoading = true; + pagination.hasMore = false; + render(); + + expect(screen.getByTestId(PANEL_TEST_ID).textContent).toBe("Loading..."); + expect(screen.queryByTestId(SENTINEL_TEST_ID)).toBeNull(); + }); + + it("shows only the older-page row when both loading flags are true", () => { + pagination.messagesLoading = true; + pagination.isLoadingMore = true; + pagination.hasMore = true; + render(); + + expect(screen.getByTestId(PANEL_TEST_ID).textContent).toBe("Loading older messages..."); + expect(screen.getByTestId(SENTINEL_TEST_ID)).toBeTruthy(); + }); + + it("renders the definitive empty state only when entries are empty, hasMore is false, and loading ended", () => { + pagination.hasMore = false; + render(); + expect(screen.getByTestId(PANEL_TEST_ID).textContent).toBe(EMPTY_TEXT); + }); + + it("keeps passthrough an unconditional no-controls state even with entries and hasMore", () => { + pagination.hasMore = true; + messagesBySession[PASSTHROUGH] = [ + message({ session_id: PASSTHROUGH as Message["session_id"], prompt_index: 1 }), + ]; + state.tasks.activeSessionId = PASSTHROUGH; + + render(); + + expect(screen.getByTestId(PANEL_TEST_ID).textContent).toBe(EMPTY_TEXT); + expect(screen.queryByTestId(/^prompt-history-row-/)).toBeNull(); + expect(screen.queryByTestId(SENTINEL_TEST_ID)).toBeNull(); + expect(screen.queryByTestId(LOADING_OLDER_TEST_ID)).toBeNull(); + }); + + it("attaches wheel/touch retry for an empty short panel via onUserGesture", async () => { + pagination.hasMore = true; + pagination.loadMore.mockResolvedValue(0); + render(); + + fireIntersection(true); + await act(async () => {}); + expect(pagination.loadMore).toHaveBeenCalledTimes(1); + + // Disarmed and still intersecting: a wheel gesture retries. + fireEvent.wheel(screen.getByTestId(PANEL_TEST_ID)); + await act(async () => {}); + expect(pagination.loadMore).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/components/task/prompt-history-panel-content.tsx b/apps/web/components/task/prompt-history-panel-content.tsx index c32482aae5..da1068899a 100644 --- a/apps/web/components/task/prompt-history-panel-content.tsx +++ b/apps/web/components/task/prompt-history-panel-content.tsx @@ -10,6 +10,8 @@ import { } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; import { useSessionMessages } from "@/hooks/domains/session/use-session-messages"; +import { useLazyLoadMessages } from "@/hooks/use-lazy-load-messages"; +import { useLazyLoadSentinel } from "@/hooks/use-lazy-load-sentinel"; import { useSessionTurnsState } from "@/hooks/domains/session/use-session-turns"; import { useMessageFavorite } from "@/hooks/domains/session/use-message-favorite"; import { formatDateTime, formatRelativeCompact } from "@/lib/i18n/formats"; @@ -23,15 +25,22 @@ import { PanelRoot } from "./panel-primitives"; type PromptHistoryPanelContentProps = { onNavigateToPrompt?: (messageId: string) => void }; +const SENTINEL_TEST_ID = "prompt-history-sentinel"; +const LOADING_OLDER_TEST_ID = "prompt-history-loading-older"; + /** The prompt-history panel: builds entries from the session's messages and - * turns and renders one expandable row per prompt; shows an empty-state - * message for passthrough sessions or when there are no entries. */ + * turns and renders one expandable row per prompt. Older pages auto-load via + * the shared lazy-load sentinel (like the transcript's scroll-up sentinel), + * with no load-more button. Passthrough sessions are an unconditional + * empty-state with NO controls (rows, arrows, sentinel, loading indicators): + * the transcript the arrow would jump to does not exist. */ export function PromptHistoryPanelContent({ onNavigateToPrompt }: PromptHistoryPanelContentProps) { const { t } = useTranslation(); const rootRef = useRef(null); const sessionId = useAppStore((state) => state.tasks.activeSessionId); const session = useAppStore((state) => (sessionId ? state.taskSessions.items[sessionId] : null)); - const { messages } = useSessionMessages(sessionId); + const { messages, isLoading: messagesLoading } = useSessionMessages(sessionId); + const { loadMore, hasMore, isLoadingMore } = useLazyLoadMessages(sessionId); const { turns, isHydrated: turnsHydrated } = useSessionTurnsState(sessionId); const entries = useMemo(() => { const derived = buildPromptHistoryEntries(messages, turns); @@ -41,7 +50,27 @@ export function PromptHistoryPanelContent({ onNavigateToPrompt }: PromptHistoryP const [expanded, setExpanded] = useState(null); const maxHeight = usePanelRowMaxHeight(rootRef); - if (session?.is_passthrough || entries.length === 0) { + // Pagination continues while the server reports older messages and no loaded + // entry is the session's first prompt (#1). If payloads omit ordinals, the + // hasMore term alone drives exhaustion. + const shouldPaginate = hasMore && !entries.some((entry) => entry.promptNumber === 1); + const { sentinelRef, onUserGesture } = useLazyLoadSentinel( + rootRef, + shouldPaginate, + messagesLoading, + isLoadingMore, + loadMore, + { + rootMargin: "0px 0px 200px 0px", + rearmWhileIntersecting: true, + joinInFlightWhileLoading: true, + }, + ); + + // Passthrough sessions render a toolbar instead of a transcript: the panel + // is an unconditional no-controls empty state, even when entries/hasMore + // exist (a restored layout can leave the tab present after a session switch). + if (session?.is_passthrough) { return ( + {spec.content} + + ); + } + return ( - + {entries.map((entry, index) => ( ))} + {shouldPaginate && ( + <> + {isLoadingMore && ( +
+ {t("task:loadingOlderMessages")} +
+ )} +