package sqlite import ( "context" "testing" "time" "github.com/kandev/kandev/internal/task/models" ) // insertMsgWithType inserts a message row with a configurable type column, // so tests can mix tool_call and plain message rows in the same session. func insertMsgWithType(t *testing.T, repo *Repository, id, sessionID, turnID, msgType string, ts time.Time) { 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 (?, ?, '', ?, 'agent', '', '', 0, ?, '{}', ?) `), id, sessionID, turnID, msgType, ts) if err != nil { t.Fatalf("insert message %s: %v", id, err) } } func TestListMessagesByTurnID(t *testing.T) { repo := newRepoForSessionTests(t) ctx := context.Background() now := time.Now().UTC() seedForMsgTest(t, repo, "task-T", "sess-T", "turn-1") seedForMsgTest(t, repo, "task-T2", "sess-T", "turn-2") // Two messages on turn-1 (out of insertion order to check created_at sort) // and one on turn-2 in the same session. insertMsgWithType(t, repo, "m-b", "sess-T", "turn-1", "message", now.Add(2*time.Second)) insertMsgWithType(t, repo, "m-a", "sess-T", "turn-1", "tool_call", now) insertMsgWithType(t, repo, "m-other", "sess-T", "turn-2", "message", now.Add(time.Second)) got, err := repo.ListMessagesByTurnID(ctx, "turn-1") if err != nil { t.Fatalf("ListMessagesByTurnID: %v", err) } if len(got) != 2 { t.Fatalf("expected 2 messages for turn-1, got %d", len(got)) } if got[0].ID != "m-a" || got[1].ID != "m-b" { t.Errorf("expected [m-a, m-b] ordered by created_at, got [%s, %s]", got[0].ID, got[1].ID) } for _, m := range got { if m.TurnID != "turn-1" { t.Errorf("message %s has turn_id %q, want turn-1", m.ID, m.TurnID) } } empty, err := repo.ListMessagesByTurnID(ctx, "turn-missing") if err != nil { t.Fatalf("ListMessagesByTurnID(missing): %v", err) } if len(empty) != 0 { t.Errorf("expected no messages for unknown turn, got %d", len(empty)) } } func TestUpdateMessageBumpsUpdatedAt(t *testing.T) { repo := newRepoForSessionTests(t) ctx := context.Background() seedForMsgTest(t, repo, "task-U", "sess-U", "turn-U") created := time.Now().UTC().Add(-time.Hour) msg := &models.Message{ ID: "m-u", TaskSessionID: "sess-U", TurnID: "turn-U", AuthorType: models.MessageAuthorAgent, Content: "hello", Type: models.MessageTypeMessage, CreatedAt: created, } if err := repo.CreateMessage(ctx, msg); err != nil { t.Fatalf("CreateMessage: %v", err) } // On insert, updated_at defaults to created_at. got, err := repo.GetMessage(ctx, "m-u") if err != nil { t.Fatalf("GetMessage: %v", err) } if !got.UpdatedAt.Equal(got.CreatedAt) { t.Errorf("after create, updated_at = %v, want == created_at %v", got.UpdatedAt, got.CreatedAt) } // Update advances updated_at past created_at. msg.Content = "hello world" if err := repo.UpdateMessage(ctx, msg); err != nil { t.Fatalf("UpdateMessage: %v", err) } got, err = repo.GetMessage(ctx, "m-u") if err != nil { t.Fatalf("GetMessage after update: %v", err) } if !got.UpdatedAt.After(got.CreatedAt) { t.Errorf("after update, updated_at = %v, want after created_at %v", got.UpdatedAt, got.CreatedAt) } } func TestCountToolCallMessagesBySession_Empty(t *testing.T) { repo := newRepoForSessionTests(t) got, err := repo.CountToolCallMessagesBySession(context.Background(), nil) if err != nil { t.Fatalf("count: %v", err) } if len(got) != 0 { t.Fatalf("expected empty map, got %d entries", len(got)) } } func TestCountToolCallMessagesBySession_Single(t *testing.T) { repo := newRepoForSessionTests(t) ctx := context.Background() now := time.Now().UTC() seedForMsgTest(t, repo, "task-A", "sess-A", "turn-A") insertMsgWithType(t, repo, "m1", "sess-A", "turn-A", "tool_call", now) insertMsgWithType(t, repo, "m2", "sess-A", "turn-A", "tool_call", now.Add(time.Second)) insertMsgWithType(t, repo, "m3", "sess-A", "turn-A", "message", now.Add(2*time.Second)) got, err := repo.CountToolCallMessagesBySession(ctx, []string{"sess-A"}) if err != nil { t.Fatalf("count: %v", err) } if got["sess-A"] != 2 { t.Errorf("sess-A count = %d, want 2", got["sess-A"]) } } func TestCountToolCallMessagesBySession_Multi(t *testing.T) { repo := newRepoForSessionTests(t) ctx := context.Background() now := time.Now().UTC() seedForMsgTest(t, repo, "task-1", "s1", "turn-1") seedForMsgTest(t, repo, "task-2", "s2", "turn-2") seedForMsgTest(t, repo, "task-3", "s3", "turn-3") insertMsgWithType(t, repo, "m-s1-a", "s1", "turn-1", "tool_call", now) insertMsgWithType(t, repo, "m-s2-a", "s2", "turn-2", "tool_call", now) insertMsgWithType(t, repo, "m-s2-b", "s2", "turn-2", "tool_call", now.Add(time.Second)) insertMsgWithType(t, repo, "m-s2-c", "s2", "turn-2", "tool_call", now.Add(2*time.Second)) // s3 has only a plain message — must be omitted from the result map. insertMsgWithType(t, repo, "m-s3-a", "s3", "turn-3", "message", now) got, err := repo.CountToolCallMessagesBySession(ctx, []string{"s1", "s2", "s3"}) if err != nil { t.Fatalf("count: %v", err) } if got["s1"] != 1 { t.Errorf("s1 count = %d, want 1", got["s1"]) } if got["s2"] != 3 { t.Errorf("s2 count = %d, want 3", got["s2"]) } if _, ok := got["s3"]; ok { t.Errorf("s3 should be omitted (zero tool_call rows), got %d", got["s3"]) } } func createPendingActionMessage( t *testing.T, repo *Repository, id string, taskID string, sessionID string, turnID string, msgType models.MessageType, status string, createdAt time.Time, ) { t.Helper() metadata := map[string]interface{}{} if status != "" { metadata["status"] = status } if err := repo.CreateMessage(context.Background(), &models.Message{ ID: id, TaskSessionID: sessionID, TaskID: taskID, TurnID: turnID, AuthorType: models.MessageAuthorAgent, Content: id, Type: msgType, Metadata: metadata, CreatedAt: createdAt, }); err != nil { t.Fatalf("CreateMessage(%s): %v", id, err) } } func TestGetPendingActionsBySessionIDs(t *testing.T) { repo := newRepoForSessionTests(t) ctx := context.Background() now := time.Now().UTC() seedForMsgTest(t, repo, "task-clar", "sess-clar", "turn-clar") createPendingActionMessage(t, repo, "perm-clar", "task-clar", "sess-clar", "turn-clar", models.MessageTypePermissionRequest, "", now) createPendingActionMessage(t, repo, "clar-clar", "task-clar", "sess-clar", "turn-clar", models.MessageTypeClarificationRequest, "pending", now.Add(time.Second)) seedForMsgTest(t, repo, "task-resolved", "sess-resolved", "turn-resolved") createPendingActionMessage(t, repo, "perm-old", "task-resolved", "sess-resolved", "turn-resolved", models.MessageTypePermissionRequest, "pending", now) createPendingActionMessage(t, repo, "perm-new", "task-resolved", "sess-resolved", "turn-resolved", models.MessageTypePermissionRequest, "approved", now.Add(time.Second)) seedForMsgTest(t, repo, "task-perm", "sess-perm", "turn-perm") createPendingActionMessage(t, repo, "perm-pending", "task-perm", "sess-perm", "turn-perm", models.MessageTypePermissionRequest, "pending", now) seedForMsgTest(t, repo, "task-perm-tie", "sess-perm-tie", "turn-perm-tie") createPendingActionMessage(t, repo, "z-approved", "task-perm-tie", "sess-perm-tie", "turn-perm-tie", models.MessageTypePermissionRequest, "approved", now) createPendingActionMessage(t, repo, "a-pending", "task-perm-tie", "sess-perm-tie", "turn-perm-tie", models.MessageTypePermissionRequest, "pending", now) seedForMsgTest(t, repo, "task-stale", "sess-stale", "turn-stale") createPendingActionMessage(t, repo, "perm-stale", "task-stale", "sess-stale", "turn-stale", models.MessageTypePermissionRequest, "pending", now) createPendingActionMessage(t, repo, "clar-stale", "task-stale", "sess-stale", "turn-stale", models.MessageTypeClarificationRequest, "pending", now) seedForMsgTest(t, repo, "task-stale", "sess-stale", "turn-current") createPendingActionMessage(t, repo, "message-current", "task-stale", "sess-stale", "turn-current", models.MessageTypeMessage, "", now.Add(time.Second)) got, err := repo.GetPendingActionsBySessionIDs(ctx, []string{ "sess-clar", "sess-resolved", "sess-perm", "sess-perm-tie", "sess-stale", "sess-missing", }) if err != nil { t.Fatalf("GetPendingActionsBySessionIDs: %v", err) } if got["sess-clar"] != models.TaskPendingActionClarification { t.Fatalf("sess-clar action = %q, want clarification", got["sess-clar"]) } if _, ok := got["sess-resolved"]; ok { t.Fatalf("sess-resolved should not have a pending action: %#v", got["sess-resolved"]) } if got["sess-perm"] != models.TaskPendingActionPermission { t.Fatalf("sess-perm action = %q, want permission", got["sess-perm"]) } if got["sess-perm-tie"] != models.TaskPendingActionPermission { t.Fatalf("sess-perm-tie action = %q, want permission from last inserted row", got["sess-perm-tie"]) } if _, ok := got["sess-stale"]; ok { t.Fatalf("sess-stale should not inherit previous turn actions: %#v", got["sess-stale"]) } if _, ok := got["sess-missing"]; ok { t.Fatalf("sess-missing should not have a pending action: %#v", got["sess-missing"]) } } // insertPluginMsg inserts a fully-specified message row (task_id, type, // author, content, created_at all controllable) for ListMessagesForPlugin // filter tests. func insertPluginMsg(t *testing.T, repo *Repository, id, sessionID, taskID, turnID, authorType, msgType, content string, ts time.Time) { 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, ?, '{}', ?) `), id, sessionID, taskID, turnID, authorType, content, msgType, ts) if err != nil { t.Fatalf("insert plugin message %s: %v", id, err) } } func TestListMessagesForPlugin(t *testing.T) { repo := newRepoForSessionTests(t) ctx := context.Background() base := time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC) seedForMsgTest(t, repo, "task-A", "sess-A", "turn-A") seedForMsgTest(t, repo, "task-B", "sess-B", "turn-B") // sess-A / task-A: three messages across three days. insertPluginMsg(t, repo, "a1", "sess-A", "task-A", "turn-A", "user", "message", "day one", base) insertPluginMsg(t, repo, "a2", "sess-A", "task-A", "turn-A", "agent", "message", "day two", base.Add(24*time.Hour)) insertPluginMsg(t, repo, "a3", "sess-A", "task-A", "turn-A", "agent", "thinking", "day three", base.Add(48*time.Hour)) // sess-B / task-B: one message on day one. insertPluginMsg(t, repo, "b1", "sess-B", "task-B", "turn-B", "user", "message", "other session", base) t.Run("by session id", func(t *testing.T) { got, err := repo.ListMessagesForPlugin(ctx, models.PluginMessageFilter{SessionIDs: []string{"sess-A"}}) if err != nil { t.Fatalf("ListMessagesForPlugin: %v", err) } if len(got) != 3 || got[0].ID != "a1" || got[2].ID != "a3" { t.Fatalf("got %d messages ordered %v, want a1,a2,a3", len(got), ids(got)) } }) t.Run("by task id", func(t *testing.T) { got, err := repo.ListMessagesForPlugin(ctx, models.PluginMessageFilter{TaskIDs: []string{"task-B"}}) if err != nil { t.Fatalf("ListMessagesForPlugin: %v", err) } if len(got) != 1 || got[0].ID != "b1" { t.Fatalf("got %v, want [b1]", ids(got)) } }) t.Run("time range excludes out-of-window", func(t *testing.T) { since := base.Add(24 * time.Hour) // inclusive → a2 kept until := base.Add(48 * time.Hour) // exclusive → a3 dropped got, err := repo.ListMessagesForPlugin(ctx, models.PluginMessageFilter{ SessionIDs: []string{"sess-A"}, Since: &since, Until: &until, }) if err != nil { t.Fatalf("ListMessagesForPlugin: %v", err) } if len(got) != 1 || got[0].ID != "a2" { t.Fatalf("got %v, want [a2] (since inclusive, until exclusive)", ids(got)) } }) t.Run("type filter", func(t *testing.T) { got, err := repo.ListMessagesForPlugin(ctx, models.PluginMessageFilter{Types: []string{"thinking"}}) if err != nil { t.Fatalf("ListMessagesForPlugin: %v", err) } if len(got) != 1 || got[0].ID != "a3" { t.Fatalf("got %v, want [a3]", ids(got)) } }) t.Run("limit and offset paginate in order", func(t *testing.T) { page1, err := repo.ListMessagesForPlugin(ctx, models.PluginMessageFilter{SessionIDs: []string{"sess-A"}, Limit: 2, Offset: 0}) if err != nil { t.Fatalf("page1: %v", err) } if len(page1) != 2 || page1[0].ID != "a1" || page1[1].ID != "a2" { t.Fatalf("page1 = %v, want [a1 a2]", ids(page1)) } page2, err := repo.ListMessagesForPlugin(ctx, models.PluginMessageFilter{SessionIDs: []string{"sess-A"}, Limit: 2, Offset: 2}) if err != nil { t.Fatalf("page2: %v", err) } if len(page2) != 1 || page2[0].ID != "a3" { t.Fatalf("page2 = %v, want [a3]", ids(page2)) } }) } func ids(msgs []*models.Message) []string { out := make([]string, len(msgs)) for i, m := range msgs { out[i] = m.ID } return out }