Files
kandev/apps/backend/internal/task/repository/sqlite/message_test.go
T
João Salavisa 78cae96bb1 feat(plugins): host-side conversation reads + utility-agent invoke (#1852)
* feat(plugins): add capability-gated Host reader for conversation content

Plugins can now read historical user/agent message content over the Host
data API, gated by a new `api_read:messages` capability (ADR 0047). This
unblocks "summarize yesterday"-style plugins that the live `message.added`
bus event alone cannot serve.

- proto: `ListMessages` RPC + `Message`/`MessageFilter` on `service Host`.
- SDK: `Host.Messages() MessageReader`, Go-native `Message`/`MessageFilter`.
- host: `api_read:messages` accessor + denied reader; content sanitized via
  `sysprompt.StripSystemContent` (no raw system prompts); RFC3339 `since`/
  `until` → `InvalidArgument` on bad input.
- data: new `ListMessagesForPlugin` filtered query (session/task ids, type,
  created_at range) through the task service, never the DB file.
- tests: in-process + wire capability gating, content stripping, filter/time
  passthrough, pagination, and the SQLite query.
- docs: ADR 0047, spec, plugins-manifest, plugins-authoring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(plugins): add Host.InvokeUtilityAgent for settings-selectable utility agent

Plugins can now delegate a one-shot LLM step to an operator-configured agent,
gated by a new `agent_invoke` capability (ADR 0048), so a plugin needs no
provider API key of its own.

- setting: new `utility_agent_profile_id` user setting (Settings > System >
  Utility Agent), a profile dropdown cloned from the existing utility-agent
  path; selectable in the UI.
- capability: boolean `agent_invoke`, enforced like `state`/`secrets`.
- proto/SDK: `InvokeUtilityAgent(prompt) -> text` RPC and
  `Host.InvokeUtilityAgent(ctx, prompt)`.
- backend: reuses the sessionless host-utility inference tier (ADR 0002) via a
  thin adapter — no agent loop, no task/session/workspace; resolves the
  configured profile to (agent type, model). Unconfigured / deleted profile ->
  gRPC FailedPrecondition.
- tests: capability gating, completion path (asserts the configured profile is
  used and text returned), no-utility-agent and stale-profile error paths,
  SDK/wire round-trips, and the settings apply/trim behavior.
- docs: ADR 0048, spec, plugins-manifest, plugins-authoring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): resolve utility-agent deps live and bound message filter size

Address CI + review feedback on the host plugin capabilities:

- docs: register the new /settings/system/utility-agent route in
  docs/public/coverage.json (fixes the "Validate public docs" CI failure).
- utility agent (ADR 0048): hostUtilityMgr is wired late in boot — after
  StartActivePlugins has already spawned boot-active plugins — so snapshotting
  the utility deps in hostForPlugin stranded those hosts with nil deps and made
  InvokeUtilityAgent return Unimplemented for their lifetime. Read the deps live
  from the Service (mutex-guarded) at call time instead; add a regression test
  for late wiring reaching an already-spawned host.
- messages (ADR 0047): cap the combined session/task/type filter values at 400
  so an oversized filter fails fast with InvalidArgument instead of hitting
  SQLite's host-parameter limit at query execution; add a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 00:20:09 +01:00

350 lines
13 KiB
Go

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 != "<missing>" {
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, "<missing>", 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, "<missing>", 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
}