Files
kandev/apps/backend/internal/plugins/host_data_test.go
T
João Salavisa 279e15b318 feat(plugins): implement Host data API write RPCs (CreateTask/UpdateTask/SendMessage) (#1970)
* feat(plugins): implement Host data API write RPCs (CreateTask/UpdateTask/CreateComment)

The api_write manifest capability was a stored no-op. Wire the deferred
phase-2 write RPCs (ADR 0043) so closed-loop plugins (Sentry->task,
Slack slash-command task creation, refactor fan-out) can create and
update tasks and post comments.

- SDK: Host.Tasks() gains Create/Update; new Host.Comments() writer;
  CreateTaskInput/UpdateTaskInput/Comment DTOs (pkg/pluginsdk).
- Gating: api_write:tasks gates Create/UpdateTask, api_write:comments
  gates CreateComment; undeclared -> gRPC PermissionDenied, per-method
  and independent of the read caps on the same resource.
- Writes route through the first-party service layer (task service for
  tasks, office service for comments) via backendapp adapters, never a
  repository -- so task.*/comment-created events fire and WS updates.
  Server stamps source="plugin:<id>"; CreateTask resolves workspace/
  workflow defaults and best-effort honors start_agent; UpdateTask uses
  a conservative title/description/state/workflow_step_id mask.
- Fixture plugins exercise a CreateTask+CreateComment round-trip;
  in-process, real-broker wire, and subprocess tests cover the round-trip
  and per-capability denial.
- Docs: spec, GRPC-CONTRACT, HOST-DATA-API.proto, proto comments moved
  writes deferred -> implemented; ADR 0043 amended with the concrete
  service-layer routing decision.

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

* feat(plugins): send a message to a task session via Host SendMessage (replaces CreateComment)

The office TaskComment write was the wrong primitive — it's an office/
dashboard construct, not a way to message the agent working a task, and
it only did anything under office reactivity. Replace the CreateComment
write RPC with SendMessage, which delivers a prompt to a task session
through the orchestrator's real delivery path (the same one message_task
uses).

- Proto: swap CreateComment/Comment for SendMessage(SendMessageRequest)
  returns SendMessageResponse{session_id, status}; regenerated stubs.
- SDK: Host.Comments() removed; Host.Messages().Send(taskID, sessionID,
  text) added (pairs with Messages().List), returning MessageDispatch.
- Gating: api_write:comments dropped; SendMessage gated api_write:messages
  (independent of api_read:messages on the same accessor, like Tasks()).
- Delivery: a new pluginsTaskMessengerAdapter (backendapp) resolves the
  target session (explicit id verified against the task, or the primary
  session), records a user message stamped source="plugin:<id>", then
  dispatches by state — running → queue; idle/completed → prompt (resume
  if the agent process is gone); never-started → launch; failed dispatch
  deletes the recorded message. Wired via SetWriteDeps (live-read).
- Tests: adapter state-machine unit tests (queue/start/prompt/resume/
  delete-on-failure/belongs-to-task/no-primary/terminal); SDK, in-process
  gating, wire, and subprocess round-trips updated to SendMessage.
- Docs: spec, GRPC-CONTRACT, HOST-DATA-API.proto, proto comments, and the
  ADR 0043 amendment updated from comment-create to message-send.

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

* fix(plugins): address PR review — nil guards, detached contexts, state validation

Follow-up fixes from the #1970 review (Claude + CodeRabbit bots):

- grpcHostServer.CreateTask/UpdateTask/SendMessage: nil-guard the returned
  value before toProto() so a custom/test Host returning (nil, nil) yields a
  gRPC Internal error, not a server panic (matches GetTask's pattern).
- host.go: fix stale server doc comment (Comments()/CreateComment →
  Messages()/SendMessage).
- startTaskBestEffort and the post-resume wait+retry now run on
  context.WithoutCancel(ctx): the task/resume is already committed, so a
  plugin's incoming deadline must not silently abort the launch/resume.
- pluginsTaskWriterAdapter.UpdateTask validates the state string against the
  known task-state enum (excluding orchestrator-owned SCHEDULING) and returns
  InvalidArgument, so a plugin typo can't forward a bogus state to the service.
- Tests: add the resume-wait-timeout path (message deleted so a retry can't
  duplicate), and a dedicated pluginsTaskWriterAdapter test covering
  source→metadata mapping and state validation (both adapters now depend on
  narrow interfaces for testability).

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

* fix(plugins): launch start_agent asynchronously so CreateTask doesn't block

Review nit follow-up: startTaskBestEffort called StartTask synchronously, so
with the detached context the plugin's CreateTask RPC blocked on the
orchestrator's full inline launch path (office lookup, state update, workflow
step move, task fetch) with no bound — and the "asynchronous" comment was wrong.

Move the fire-and-forget into pluginsTaskStarterAdapter (backendapp, no goleak),
mirroring MCP's launchAutoStartTask: the launch runs in a goroutine on a
context.WithTimeout(context.WithoutCancel(ctx), AgentLaunchTimeout), and
StartTask returns immediately. The plugin host stays a simple synchronous call
(its taskStarter contract now documents "returns promptly, launches async").

Adds adapters_plugin_starter_test.go: launch invoked, returns before the launch
completes, detaches from a cancelled caller context, and swallows launch errors.

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

* docs(plugins): document write validation + SendMessage rollback; harden starter test cleanup

Addresses the final PR #1970 review nits:
- adapters_plugin_starter_test.go: register the block-channel release via
  t.Cleanup (sync.Once-guarded) before the goroutine can block, per AGENTS.md.
- GRPC-CONTRACT.md: document CreateTask/UpdateTask validation + error rules
  (title required, id required, NotFound on missing task, invalid-state reject).
- spec.md: document that a failed SendMessage dispatch deletes the recorded
  message, plus the required-field/NotFound/terminal-session rules.

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-27 10:54:12 +01:00

990 lines
38 KiB
Go

package plugins
import (
"context"
"errors"
"fmt"
"testing"
"time"
agentsettingsdto "github.com/kandev/kandev/internal/agent/settings/dto"
analyticsmodels "github.com/kandev/kandev/internal/analytics/models"
"github.com/kandev/kandev/internal/plugins/manifest"
taskmodels "github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository/repoerrors"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
"github.com/kandev/kandev/pkg/pluginsdk"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// ── fakes for the narrow Host data API interfaces ───────────────────────
type fakeTaskDataSource struct {
workspaces []*taskmodels.Workspace
tasksByWorkspace map[string][]*taskmodels.Task
tasksByID map[string]*taskmodels.Task
repositories map[string][]*taskmodels.Repository
sessionsByTask map[string][]*taskmodels.TaskSession
executorRunning map[string]*taskmodels.ExecutorRunning
// gotIncludeArchived records the includeArchived flag of every
// ListTasksByWorkspace call, in call order.
gotIncludeArchived []bool
// executorRunningCalls counts GetExecutorRunningBySessionID calls, so
// tests can prove Sessions().List only resolves ACPSessionID for the
// page it actually returns, not every session it fetched.
executorRunningCalls int
// listTasksByWorkspaceCalls counts ListTasksByWorkspace calls, so tests
// can prove a workspace with more tasks than a single page issues
// multiple calls instead of returning a truncated first page.
listTasksByWorkspaceCalls int
}
func (f *fakeTaskDataSource) ListWorkspaces(context.Context) ([]*taskmodels.Workspace, error) {
return f.workspaces, nil
}
// ListTasksByWorkspace mirrors the real repository's page/pageSize semantics
// (1-based page, offset = (page-1)*pageSize, total = the full unpaginated
// count) so tests can prove callers that need every task actually loop
// pagination to completion instead of assuming a single page has it all.
func (f *fakeTaskDataSource) ListTasksByWorkspace(
_ context.Context, workspaceID, _, _, _ string, page, pageSize int, _ string, includeArchived, _, _, _ bool,
) ([]*taskmodels.Task, int, error) {
f.gotIncludeArchived = append(f.gotIncludeArchived, includeArchived)
f.listTasksByWorkspaceCalls++
all := f.tasksByWorkspace[workspaceID]
total := len(all)
offset := (page - 1) * pageSize
if offset < 0 || offset >= total {
return []*taskmodels.Task{}, total, nil
}
end := offset + pageSize
if end > total {
end = total
}
return all[offset:end], total, nil
}
func (f *fakeTaskDataSource) GetTask(_ context.Context, id string) (*taskmodels.Task, error) {
task, ok := f.tasksByID[id]
if !ok {
return nil, repoerrors.ErrTaskNotFound
}
return task, nil
}
func (f *fakeTaskDataSource) ListRepositories(_ context.Context, workspaceID string) ([]*taskmodels.Repository, error) {
return f.repositories[workspaceID], nil
}
func (f *fakeTaskDataSource) ListTaskSessions(_ context.Context, taskID string) ([]*taskmodels.TaskSession, error) {
return f.sessionsByTask[taskID], nil
}
func (f *fakeTaskDataSource) GetExecutorRunningBySessionID(_ context.Context, sessionID string) (*taskmodels.ExecutorRunning, error) {
f.executorRunningCalls++
running, ok := f.executorRunning[sessionID]
if !ok {
return nil, taskmodels.ErrExecutorRunningNotFound
}
return running, nil
}
type fakeWorkflowLister struct {
workflows map[string][]*taskmodels.Workflow
}
func (f *fakeWorkflowLister) ListWorkflows(_ context.Context, workspaceID string, _ bool) ([]*taskmodels.Workflow, error) {
return f.workflows[workspaceID], nil
}
type fakeWorkflowStepLister struct {
steps map[string][]*wfmodels.WorkflowStep
}
func (f *fakeWorkflowStepLister) ListStepsByWorkflow(_ context.Context, workflowID string) ([]*wfmodels.WorkflowStep, error) {
return f.steps[workflowID], nil
}
type fakeAgentProfileDataSource struct {
resp *agentsettingsdto.ListAgentsResponse
}
func (f *fakeAgentProfileDataSource) ListAgents(context.Context) (*agentsettingsdto.ListAgentsResponse, error) {
return f.resp, nil
}
type fakeSessionCodeStatsSource struct {
calls int
lastFilter analyticsmodels.SessionCodeStatsFilter
stats []*analyticsmodels.SessionCodeStats
err error
}
func (f *fakeSessionCodeStatsSource) ListSessionCodeStats(
_ context.Context, filter analyticsmodels.SessionCodeStatsFilter,
) ([]*analyticsmodels.SessionCodeStats, error) {
f.calls++
f.lastFilter = filter
if f.err != nil {
return nil, f.err
}
return f.stats, nil
}
type fakeMessageDataSource struct {
calls int
lastFilter taskmodels.PluginMessageFilter
messages []*taskmodels.Message
err error
}
func (f *fakeMessageDataSource) ListMessagesForPlugin(
_ context.Context, filter taskmodels.PluginMessageFilter,
) ([]*taskmodels.Message, error) {
f.calls++
f.lastFilter = filter
if f.err != nil {
return nil, f.err
}
return f.messages, nil
}
// fakeTaskWriter records CreateTask/UpdateTask inputs and returns canned
// tasks, standing in for the backendapp adapter over internal/task/service.
type fakeTaskWriter struct {
lastCreate TaskCreateInput
lastUpdate TaskUpdateInput
createCalls int
updateCalls int
created *taskmodels.Task
updated *taskmodels.Task
createErr error
updateErr error
}
func (f *fakeTaskWriter) CreateTask(_ context.Context, in TaskCreateInput) (*taskmodels.Task, error) {
f.createCalls++
f.lastCreate = in
if f.createErr != nil {
return nil, f.createErr
}
if f.created != nil {
return f.created, nil
}
return &taskmodels.Task{ID: "task-created", WorkspaceID: in.WorkspaceID, WorkflowID: in.WorkflowID, Title: in.Title}, nil
}
func (f *fakeTaskWriter) UpdateTask(_ context.Context, in TaskUpdateInput) (*taskmodels.Task, error) {
f.updateCalls++
f.lastUpdate = in
if f.updateErr != nil {
return nil, f.updateErr
}
if f.updated != nil {
return f.updated, nil
}
return &taskmodels.Task{ID: in.ID, Title: "updated"}, nil
}
// fakeMessenger records SendMessage calls, standing in for the backendapp
// adapter over the task service + orchestrator delivery path.
type fakeMessenger struct {
calls int
lastTaskID string
lastSession string
lastText string
lastSource string
result PluginMessageResult
err error
}
func (f *fakeMessenger) SendMessage(_ context.Context, taskID, sessionID, text, source string) (PluginMessageResult, error) {
f.calls++
f.lastTaskID, f.lastSession, f.lastText, f.lastSource = taskID, sessionID, text, source
if f.err != nil {
return PluginMessageResult{}, f.err
}
if f.result == (PluginMessageResult{}) {
return PluginMessageResult{SessionID: "session-x", Status: "sent"}, nil
}
return f.result, nil
}
// fakeTaskStarter records StartTask calls behind CreateTask's start_agent.
type fakeTaskStarter struct {
calls int
lastID string
err error
}
func (f *fakeTaskStarter) StartTask(_ context.Context, taskID string) error {
f.calls++
f.lastID = taskID
return f.err
}
// testDataHost bundles a pluginHost with every fake it was wired from, so
// tests can both drive Host calls and assert against the fakes' recorded
// state.
type testDataHost struct {
host *pluginHost
tasks *fakeTaskDataSource
workflows *fakeWorkflowLister
steps *fakeWorkflowStepLister
profiles *fakeAgentProfileDataSource
codeStats *fakeSessionCodeStatsSource
messages *fakeMessageDataSource
utilAgents *fakeUtilityAgentSource
utilRun *fakeUtilityRunner
taskWriter *fakeTaskWriter
messenger *fakeMessenger
starter *fakeTaskStarter
}
// newTestDataHost builds a fully-wired pluginHost (every Host data API
// dependency set, even if a given test's capabilities don't grant every
// resource) so each test only needs to vary caps.
func newTestDataHost(caps manifest.Capabilities) *testDataHost {
d := &testDataHost{
tasks: &fakeTaskDataSource{},
workflows: &fakeWorkflowLister{},
steps: &fakeWorkflowStepLister{},
profiles: &fakeAgentProfileDataSource{resp: &agentsettingsdto.ListAgentsResponse{}},
codeStats: &fakeSessionCodeStatsSource{},
messages: &fakeMessageDataSource{},
utilAgents: &fakeUtilityAgentSource{},
utilRun: &fakeUtilityRunner{text: "ok"},
taskWriter: &fakeTaskWriter{},
messenger: &fakeMessenger{},
starter: &fakeTaskStarter{},
}
d.host = &pluginHost{
pluginID: "p1",
capabilities: caps,
taskData: d.tasks,
workflows: d.workflows,
workflowSteps: d.steps,
agentProfiles: d.profiles,
sessionCodeStats: d.codeStats,
messageData: d.messages,
taskWriter: d.taskWriter,
configs: &fakeConfigReader{configs: map[string]any{utilityAgentConfigKey: "utility-agent-42"}},
utilityDeps: func() (utilityAgentSource, utilityRunner) {
return d.utilAgents, d.utilRun
},
writeDeps: func() (taskMessenger, taskStarter) {
return d.messenger, d.starter
},
}
return d
}
// ── capability gating: denied without api_read:<resource> ──────────────
func TestPluginHost_Tasks_DeniedWithoutCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{})
_, _, err := d.host.Tasks().List(context.Background(), pluginsdk.TaskFilter{}, pluginsdk.Page{})
assertPermissionDenied(t, err, "api_read:tasks")
_, err = d.host.Tasks().Get(context.Background(), "task-1")
assertPermissionDenied(t, err, "api_read:tasks")
}
func TestPluginHost_Sessions_DeniedWithoutCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{})
_, _, err := d.host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{})
assertPermissionDenied(t, err, "api_read:sessions")
_, _, err = d.host.Sessions().CodeStats(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{})
assertPermissionDenied(t, err, "api_read:sessions")
}
func TestPluginHost_Workspaces_DeniedWithoutCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{})
_, _, err := d.host.Workspaces().List(context.Background(), pluginsdk.Page{})
assertPermissionDenied(t, err, "api_read:workspaces")
}
func TestPluginHost_Workflows_DeniedWithoutCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{})
_, _, err := d.host.Workflows().List(context.Background(), "ws-1", pluginsdk.Page{})
assertPermissionDenied(t, err, "api_read:workflows")
_, err = d.host.Workflows().ListSteps(context.Background(), "wf-1")
assertPermissionDenied(t, err, "api_read:workflows")
}
func TestPluginHost_AgentProfiles_DeniedWithoutCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{})
_, _, err := d.host.AgentProfiles().List(context.Background(), pluginsdk.Page{})
assertPermissionDenied(t, err, "api_read:agent_profiles")
}
func TestPluginHost_Repositories_DeniedWithoutCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{})
_, _, err := d.host.Repositories().List(context.Background(), "ws-1", pluginsdk.Page{})
assertPermissionDenied(t, err, "api_read:repositories")
}
func TestPluginHost_Messages_DeniedWithoutCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{})
_, _, err := d.host.Messages().List(context.Background(), pluginsdk.MessageFilter{}, pluginsdk.Page{})
assertPermissionDenied(t, err, "api_read:messages")
}
// ── capability gating: succeeds with api_read:<resource> ───────────────
func TestPluginHost_Tasks_SucceedsWithCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{
"ws-1": {{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", Title: "Task 1", State: v1.TaskStateTODO}},
}
d.tasks.tasksByID = map[string]*taskmodels.Task{"task-1": d.tasks.tasksByWorkspace["ws-1"][0]}
tasks, info, err := d.host.Tasks().List(context.Background(), pluginsdk.TaskFilter{}, pluginsdk.Page{})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(tasks) != 1 || tasks[0].ID != "task-1" {
t.Fatalf("List() = %+v, want one task-1", tasks)
}
if info == nil || info.HasMore {
t.Fatalf("PageInfo = %+v, want HasMore=false", info)
}
got, err := d.host.Tasks().Get(context.Background(), "task-1")
if err != nil {
t.Fatalf("Get() unexpected error: %v", err)
}
if got == nil || got.Title != "Task 1" {
t.Fatalf("Get() = %+v, want Task 1", got)
}
// A missing task returns a gRPC NotFound error directly, matching what a
// real plugin observes over the wire (grpcHostServer.GetTask forwards
// this error as-is) rather than a (nil, nil) success that only the
// in-process caller would ever see.
got, err = d.host.Tasks().Get(context.Background(), "no-such-task")
if got != nil {
t.Fatalf("Get() for missing task = (%+v, %v), want (nil, NotFound)", got, err)
}
if status.Code(err) != codes.NotFound {
t.Fatalf("Get() for missing task error = %v, want codes.NotFound", err)
}
}
func TestPluginHost_Workspaces_SucceedsWithCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"workspaces"}})
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1", Name: "Workspace 1", OwnerID: "user-1"}}
workspaces, _, err := d.host.Workspaces().List(context.Background(), pluginsdk.Page{})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(workspaces) != 1 || workspaces[0].Name != "Workspace 1" {
t.Fatalf("List() = %+v, want one Workspace 1", workspaces)
}
}
func TestPluginHost_Workflows_SucceedsWithCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"workflows"}})
d.workflows.workflows = map[string][]*taskmodels.Workflow{
"ws-1": {{ID: "wf-1", WorkspaceID: "ws-1", Name: "Default"}},
}
d.steps.steps = map[string][]*wfmodels.WorkflowStep{
"wf-1": {{ID: "step-1", WorkflowID: "wf-1", Name: "Todo", Position: 0, StageType: wfmodels.StageType("work")}},
}
workflows, _, err := d.host.Workflows().List(context.Background(), "ws-1", pluginsdk.Page{})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(workflows) != 1 || workflows[0].ID != "wf-1" {
t.Fatalf("List() = %+v, want one wf-1", workflows)
}
steps, err := d.host.Workflows().ListSteps(context.Background(), "wf-1")
if err != nil {
t.Fatalf("ListSteps() unexpected error: %v", err)
}
if len(steps) != 1 || steps[0].StageType != "work" {
t.Fatalf("ListSteps() = %+v, want one step with StageType=work", steps)
}
}
func TestPluginHost_AgentProfiles_SucceedsWithCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"agent_profiles"}})
d.profiles.resp = &agentsettingsdto.ListAgentsResponse{
Agents: []agentsettingsdto.AgentDTO{
{
ID: "agent-1",
Profiles: []agentsettingsdto.AgentProfileDTO{
{ID: "profile-1", AgentID: "agent-1", Name: "Default", AgentDisplayName: "Claude", Model: "claude-x", Mode: "code"},
},
},
},
}
profiles, _, err := d.host.AgentProfiles().List(context.Background(), pluginsdk.Page{})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(profiles) != 1 || profiles[0].DisplayName != "Claude" || profiles[0].Model != "claude-x" {
t.Fatalf("List() = %+v, want one Claude/claude-x profile", profiles)
}
}
func TestPluginHost_Repositories_SucceedsWithCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"repositories"}})
branch := "main"
d.tasks.repositories = map[string][]*taskmodels.Repository{
"ws-1": {{ID: "repo-1", WorkspaceID: "ws-1", Name: "kandev", DefaultBranch: branch}},
}
repos, _, err := d.host.Repositories().List(context.Background(), "ws-1", pluginsdk.Page{})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(repos) != 1 || repos[0].Name != "kandev" || repos[0].DefaultBranch == nil || *repos[0].DefaultBranch != "main" {
t.Fatalf("List() = %+v, want one kandev repo on main", repos)
}
}
// TestFetchTasksForWorkspaces_DoesNotTruncateBeyondPageSize proves a
// workspace with more tasks than a single taskFetchPageSize page is fully
// enumerated, not silently cut off at the first page.
func TestFetchTasksForWorkspaces_DoesNotTruncateBeyondPageSize(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
const total = taskFetchPageSize + 1
tasks := make([]*taskmodels.Task, total)
for i := 0; i < total; i++ {
tasks[i] = &taskmodels.Task{ID: fmt.Sprintf("task-%04d", i), WorkspaceID: "ws-1"}
}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": tasks}
got, err := d.host.fetchTasksForWorkspaces(context.Background(), []string{"ws-1"}, false, false)
if err != nil {
t.Fatalf("fetchTasksForWorkspaces() unexpected error: %v", err)
}
if len(got) != total {
t.Fatalf("fetchTasksForWorkspaces() returned %d tasks, want %d (must not silently truncate at taskFetchPageSize)", len(got), total)
}
if d.tasks.listTasksByWorkspaceCalls < 2 {
t.Fatalf("ListTasksByWorkspace called %d times, want >= 2 (a single %d-task page can't hold %d tasks)",
d.tasks.listTasksByWorkspaceCalls, taskFetchPageSize, total)
}
}
// TestPluginHost_Tasks_List_DoesNotTruncateBeyondPageSize is the
// end-to-end version of the above: Tasks().List's HasMore/pagination must
// stay accurate even when the underlying per-workspace fetch spans more than
// one taskFetchPageSize page.
func TestPluginHost_Tasks_List_DoesNotTruncateBeyondPageSize(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
const total = taskFetchPageSize + 1
tasks := make([]*taskmodels.Task, total)
for i := 0; i < total; i++ {
tasks[i] = &taskmodels.Task{ID: fmt.Sprintf("task-%04d", i), WorkspaceID: "ws-1"}
}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": tasks}
// maxPageLimit caps a single RPC page at 200 items, so walk every page
// via cursor and count the total returned across the whole read.
seen := 0
cursor := ""
for {
page, info, err := d.host.Tasks().List(context.Background(), pluginsdk.TaskFilter{WorkspaceIDs: []string{"ws-1"}}, pluginsdk.Page{Limit: 200, Cursor: cursor})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
seen += len(page)
if info == nil || !info.HasMore {
break
}
cursor = info.NextCursor
}
if seen != total {
t.Fatalf("Tasks().List() across all pages returned %d tasks, want %d (must not silently truncate at taskFetchPageSize)", seen, total)
}
}
// ── Session DTO mapping: acp_session_id sourcing ────────────────────────
func TestPluginHost_Sessions_ACPSessionIDFromMetadata(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}})
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {{ID: "task-1", WorkspaceID: "ws-1"}}}
d.tasks.sessionsByTask = map[string][]*taskmodels.TaskSession{
"task-1": {{
ID: "session-1",
TaskID: "task-1",
State: taskmodels.TaskSessionStateRunning,
StartedAt: time.Now(),
Metadata: map[string]any{"acp": map[string]any{"session_id": "acp-from-metadata"}},
}},
}
// Also seed an executors_running row so the test proves metadata wins
// over the fallback when both are present.
d.tasks.executorRunning = map[string]*taskmodels.ExecutorRunning{
"session-1": {SessionID: "session-1", ResumeToken: "acp-from-fallback"},
}
sessions, _, err := d.host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(sessions) != 1 || sessions[0].ACPSessionID != "acp-from-metadata" {
t.Fatalf("List() = %+v, want ACPSessionID=acp-from-metadata", sessions)
}
}
func TestPluginHost_Sessions_ACPSessionIDFallsBackToExecutorRunning(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}})
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {{ID: "task-1", WorkspaceID: "ws-1"}}}
d.tasks.sessionsByTask = map[string][]*taskmodels.TaskSession{
"task-1": {{ID: "session-1", TaskID: "task-1", State: taskmodels.TaskSessionStateRunning, StartedAt: time.Now()}},
}
d.tasks.executorRunning = map[string]*taskmodels.ExecutorRunning{
"session-1": {SessionID: "session-1", ResumeToken: "acp-from-fallback"},
}
sessions, _, err := d.host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(sessions) != 1 || sessions[0].ACPSessionID != "acp-from-fallback" {
t.Fatalf("List() = %+v, want ACPSessionID=acp-from-fallback", sessions)
}
}
func TestPluginHost_Sessions_ACPSessionIDEmptyWhenNeitherSourceHasIt(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}})
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {{ID: "task-1", WorkspaceID: "ws-1"}}}
d.tasks.sessionsByTask = map[string][]*taskmodels.TaskSession{
"task-1": {{ID: "session-1", TaskID: "task-1", State: taskmodels.TaskSessionStateRunning, StartedAt: time.Now()}},
}
// No executorRunning entry for session-1: GetExecutorRunningBySessionID
// returns ErrExecutorRunningNotFound, which must be swallowed.
sessions, _, err := d.host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(sessions) != 1 || sessions[0].ACPSessionID != "" {
t.Fatalf("List() = %+v, want empty ACPSessionID", sessions)
}
}
// TestPluginHost_Sessions_PaginatesBeforeResolvingACPSessionID proves
// Sessions().List resolves ACPSessionID (a per-item DB call via
// resolveACPSessionID's GetExecutorRunningBySessionID fallback) only for the
// page it returns, not for every session it fetched — pagination must happen
// on the raw, already-sorted sessions BEFORE the per-item DTO conversion, or
// a Page{Limit: 2} read over 5 sessions would issue 5 lookups instead of 2.
func TestPluginHost_Sessions_PaginatesBeforeResolvingACPSessionID(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}})
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {{ID: "task-1", WorkspaceID: "ws-1"}}}
const total = 5
sessions := make([]*taskmodels.TaskSession, total)
base := time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC)
for i := 0; i < total; i++ {
// No Metadata: resolveACPSessionID always falls through to
// GetExecutorRunningBySessionID for every one of these sessions.
sessions[i] = &taskmodels.TaskSession{
ID: fmt.Sprintf("session-%d", i),
TaskID: "task-1",
State: taskmodels.TaskSessionStateRunning,
StartedAt: base.Add(time.Duration(total-i) * time.Hour), // session-0 newest
}
}
d.tasks.sessionsByTask = map[string][]*taskmodels.TaskSession{"task-1": sessions}
const limit = 2
page, info, err := d.host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{Limit: limit})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(page) != limit {
t.Fatalf("List() returned %d sessions, want %d", len(page), limit)
}
if info == nil || !info.HasMore {
t.Fatalf("PageInfo = %+v, want HasMore=true", info)
}
if d.tasks.executorRunningCalls != limit {
t.Fatalf("GetExecutorRunningBySessionID called %d times, want %d (only the returned page, not all %d fetched sessions)",
d.tasks.executorRunningCalls, limit, total)
}
}
// TestPluginHost_SessionsCodeStats_DelegatesToAnalyticsService proves
// Sessions().CodeStats reaches the injected analytics service with the
// filter translated 1:1, and maps its results back unchanged (ADR 0043(b):
// SessionCodeStats is a stable, computed shape returned as-is).
func TestPluginHost_SessionsCodeStats_DelegatesToAnalyticsService(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}})
d.codeStats.stats = []*analyticsmodels.SessionCodeStats{
{SessionID: "session-1", LinesAddedCommitted: 10, LinesDeletedCommitted: 2, LinesAddedPeakPending: 5, LinesDeletedPeakPending: 1},
}
filter := pluginsdk.SessionFilter{TaskIDs: []string{"task-1"}, WorkspaceIDs: []string{"ws-1"}, States: []string{"RUNNING"}}
stats, info, err := d.host.Sessions().CodeStats(context.Background(), filter, pluginsdk.Page{Limit: 10})
if err != nil {
t.Fatalf("CodeStats() unexpected error: %v", err)
}
if d.codeStats.calls != 1 {
t.Fatalf("analytics service called %d times, want 1", d.codeStats.calls)
}
if len(d.codeStats.lastFilter.TaskIDs) != 1 || d.codeStats.lastFilter.TaskIDs[0] != "task-1" {
t.Errorf("filter.TaskIDs = %v, want [task-1]", d.codeStats.lastFilter.TaskIDs)
}
if len(d.codeStats.lastFilter.WorkspaceIDs) != 1 || d.codeStats.lastFilter.WorkspaceIDs[0] != "ws-1" {
t.Errorf("filter.WorkspaceIDs = %v, want [ws-1]", d.codeStats.lastFilter.WorkspaceIDs)
}
if len(d.codeStats.lastFilter.States) != 1 || d.codeStats.lastFilter.States[0] != "RUNNING" {
t.Errorf("filter.States = %v, want [RUNNING]", d.codeStats.lastFilter.States)
}
// Limit is requested as limit+1 (the HasMore probe row) — see
// sessionReader.CodeStats' doc comment.
if d.codeStats.lastFilter.Limit != 11 {
t.Errorf("filter.Limit = %d, want 11 (requested 10 + 1 probe row)", d.codeStats.lastFilter.Limit)
}
if len(stats) != 1 || stats[0].SessionID != "session-1" || stats[0].LinesAddedCommitted != 10 {
t.Fatalf("CodeStats() = %+v, want session-1 passed through unchanged", stats)
}
if info == nil || info.HasMore {
t.Fatalf("PageInfo = %+v, want HasMore=false", info)
}
}
// TestPluginHost_SessionsCodeStats_HasMoreWhenExtraProbeRowReturned proves
// the limit+1 probe-row trick correctly reports HasMore and trims the extra
// row before returning results.
func TestPluginHost_SessionsCodeStats_HasMoreWhenExtraProbeRowReturned(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}})
d.codeStats.stats = []*analyticsmodels.SessionCodeStats{
{SessionID: "session-1"}, {SessionID: "session-2"},
}
stats, info, err := d.host.Sessions().CodeStats(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{Limit: 1})
if err != nil {
t.Fatalf("CodeStats() unexpected error: %v", err)
}
if len(stats) != 1 || stats[0].SessionID != "session-1" {
t.Fatalf("CodeStats() = %+v, want exactly one trimmed result", stats)
}
if info == nil || !info.HasMore || info.NextCursor != "1" {
t.Fatalf("PageInfo = %+v, want HasMore=true NextCursor=1", info)
}
}
func TestPluginHost_SessionsCodeStats_PropagatesAnalyticsError(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}})
wantErr := errors.New("boom")
d.codeStats.err = wantErr
_, _, err := d.host.Sessions().CodeStats(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{})
if !errors.Is(err, wantErr) {
t.Fatalf("CodeStats() error = %v, want %v", err, wantErr)
}
}
// TestPluginHost_Sessions_TaskIDsScopedToWorkspaceIDs proves SessionFilter's
// TaskIDs and WorkspaceIDs are ANDed together: a task id that resolves to a
// task outside the requested workspaces must not leak its sessions, even
// though the id itself was explicitly requested.
func TestPluginHost_Sessions_TaskIDsScopedToWorkspaceIDs(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}})
task1 := &taskmodels.Task{ID: "task-1", WorkspaceID: "ws-1"}
task2 := &taskmodels.Task{ID: "task-2", WorkspaceID: "ws-2"}
d.tasks.tasksByID = map[string]*taskmodels.Task{"task-1": task1, "task-2": task2}
d.tasks.sessionsByTask = map[string][]*taskmodels.TaskSession{
"task-1": {{ID: "session-1", TaskID: "task-1", State: taskmodels.TaskSessionStateRunning, StartedAt: time.Now()}},
"task-2": {{ID: "session-2", TaskID: "task-2", State: taskmodels.TaskSessionStateRunning, StartedAt: time.Now()}},
}
filter := pluginsdk.SessionFilter{TaskIDs: []string{"task-1", "task-2"}, WorkspaceIDs: []string{"ws-1"}}
sessions, _, err := d.host.Sessions().List(context.Background(), filter, pluginsdk.Page{})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(sessions) != 1 || sessions[0].ID != "session-1" {
t.Fatalf("List() = %+v, want only session-1 (task-2 is outside the requested workspace)", sessions)
}
}
// ── Stable sort: deterministic pagination across equal timestamps ───────
// TestSortTasksNewestFirst_TiesBrokenByID proves sortTasksNewestFirst orders
// equal-CreatedAt tasks deterministically by ID, not by whatever order
// sort.Slice's unstable algorithm happens to leave them in — an offset-based
// paginated read of tasks sharing a CreatedAt second (a plausible seed/batch
// scenario) must return the same order on every call, or successive pages
// can skip or duplicate a task.
func TestSortTasksNewestFirst_TiesBrokenByID(t *testing.T) {
same := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
tasks := []*taskmodels.Task{
{ID: "c", CreatedAt: same},
{ID: "a", CreatedAt: same},
{ID: "b", CreatedAt: same},
}
sortTasksNewestFirst(tasks)
got := []string{tasks[0].ID, tasks[1].ID, tasks[2].ID}
want := []string{"a", "b", "c"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("sortTasksNewestFirst() order = %v, want %v (equal CreatedAt must tie-break by ID)", got, want)
}
}
}
// TestSortSessionsNewestFirst_TiesBrokenByID mirrors
// TestSortTasksNewestFirst_TiesBrokenByID for sessions (StartedAt instead of
// CreatedAt).
func TestSortSessionsNewestFirst_TiesBrokenByID(t *testing.T) {
same := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
sessions := []*taskmodels.TaskSession{
{ID: "c", StartedAt: same},
{ID: "a", StartedAt: same},
{ID: "b", StartedAt: same},
}
sortSessionsNewestFirst(sessions)
got := []string{sessions[0].ID, sessions[1].ID, sessions[2].ID}
want := []string{"a", "b", "c"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("sortSessionsNewestFirst() order = %v, want %v (equal StartedAt must tie-break by ID)", got, want)
}
}
}
// ── DTO mapping ──────────────────────────────────────────────────────────
func TestTaskModelToDTO_MapsFields(t *testing.T) {
created := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
task := &taskmodels.Task{
ID: "task-1",
WorkspaceID: "ws-1",
WorkflowID: "wf-1",
Title: "Fix bug",
Description: "details",
State: v1.TaskStateInProgress,
Priority: "high",
Origin: "agent_created",
CreatedAt: created,
UpdatedAt: created,
ParentID: "parent-1",
Identifier: "KAN-1",
IsEphemeral: false,
Repositories: []*taskmodels.TaskRepository{
{ID: "tr-1", RepositoryID: "repo-1", BaseBranch: "main", Position: 0},
},
Metadata: map[string]any{"k": "v"},
}
dto := taskModelToDTO(task)
if dto.ID != "task-1" || dto.State != "IN_PROGRESS" || dto.CreatedBy != "agent_created" {
t.Fatalf("taskModelToDTO() = %+v, unexpected core fields", dto)
}
if dto.CreatedAt != created.Format(time.RFC3339) {
t.Errorf("CreatedAt = %q, want RFC3339 %q", dto.CreatedAt, created.Format(time.RFC3339))
}
if dto.ParentID == nil || *dto.ParentID != "parent-1" {
t.Errorf("ParentID = %v, want parent-1", dto.ParentID)
}
if len(dto.Repositories) != 1 || dto.Repositories[0].RepositoryID != "repo-1" {
t.Errorf("Repositories = %+v, want one repo-1", dto.Repositories)
}
if dto.Metadata["k"] != "v" {
t.Errorf("Metadata = %+v, want k=v", dto.Metadata)
}
}
func TestTaskModelToDTO_EmptyParentIDIsNil(t *testing.T) {
dto := taskModelToDTO(&taskmodels.Task{ID: "task-1"})
if dto.ParentID != nil {
t.Fatalf("ParentID = %v, want nil for a root task", dto.ParentID)
}
}
// ── Archived-task visibility ────────────────────────────────────────────
// Sessions().List must fetch tasks WITH archived included (an archived
// task's sessions are still real sessions), while Tasks().List keeps
// archived tasks out of plugin-visible work items.
func TestPluginHost_ArchivedTaskFetchFlags(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions", "tasks"}})
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {{ID: "task-1", WorkspaceID: "ws-1"}}}
if _, _, err := d.host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{}); err != nil {
t.Fatalf("Sessions().List unexpected error: %v", err)
}
if _, _, err := d.host.Tasks().List(context.Background(), pluginsdk.TaskFilter{}, pluginsdk.Page{}); err != nil {
t.Fatalf("Tasks().List unexpected error: %v", err)
}
want := []bool{true, false} // sessions read first (archived in), tasks read second (archived out)
if len(d.tasks.gotIncludeArchived) != 2 ||
d.tasks.gotIncludeArchived[0] != want[0] || d.tasks.gotIncludeArchived[1] != want[1] {
t.Fatalf("includeArchived per call = %v, want %v", d.tasks.gotIncludeArchived, want)
}
}
// ── Messages reader ─────────────────────────────────────────────────────
func TestPluginHost_Messages_SucceedsAndStripsSystemContent(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"messages"}})
created := time.Date(2026, 7, 20, 9, 30, 0, 0, time.UTC)
d.messages.messages = []*taskmodels.Message{
{
ID: "m1",
TaskSessionID: "s1",
TaskID: "t1",
TurnID: "turn1",
AuthorType: taskmodels.MessageAuthorUser,
Content: "<kandev-system>secret prompt</kandev-system>Please summarize yesterday.",
Type: taskmodels.MessageTypeMessage,
CreatedAt: created,
},
{
ID: "m2",
TaskSessionID: "s1",
TaskID: "t1",
TurnID: "turn1",
AuthorType: taskmodels.MessageAuthorAgent,
Content: "Here is the summary.",
Type: "", // empty type must default to "message"
CreatedAt: created.Add(time.Minute),
},
}
msgs, info, err := d.host.Messages().List(context.Background(), pluginsdk.MessageFilter{
SessionIDs: []string{"s1"},
}, pluginsdk.Page{Limit: 50})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("List() len = %d, want 2", len(msgs))
}
if msgs[0].Content != "Please summarize yesterday." {
t.Fatalf("content[0] = %q, want system block stripped", msgs[0].Content)
}
if msgs[0].AuthorType != "user" || msgs[1].AuthorType != "agent" {
t.Fatalf("author types = %q/%q, want user/agent", msgs[0].AuthorType, msgs[1].AuthorType)
}
if msgs[1].Type != "message" {
t.Fatalf("type[1] = %q, want defaulted to message", msgs[1].Type)
}
if msgs[0].SessionID != "s1" || msgs[0].TaskID != "t1" || msgs[0].TurnID != "turn1" {
t.Fatalf("ids = %+v, want session/task/turn set", msgs[0])
}
if msgs[0].CreatedAt != "2026-07-20T09:30:00Z" {
t.Fatalf("created_at = %q, want RFC3339", msgs[0].CreatedAt)
}
if info == nil || info.HasMore {
t.Fatalf("PageInfo = %+v, want HasMore=false", info)
}
}
func TestPluginHost_Messages_PassesFilterAndTimeRange(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"messages"}})
since := "2026-07-20T00:00:00Z"
until := "2026-07-21T00:00:00Z"
_, _, err := d.host.Messages().List(context.Background(), pluginsdk.MessageFilter{
SessionIDs: []string{"s1", "s2"},
TaskIDs: []string{"t1"},
Types: []string{"message", "content"},
Since: &since,
Until: &until,
}, pluginsdk.Page{Limit: 10})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
got := d.messages.lastFilter
if len(got.SessionIDs) != 2 || len(got.TaskIDs) != 1 || len(got.Types) != 2 {
t.Fatalf("filter passthrough = %+v", got)
}
if got.Since == nil || !got.Since.Equal(time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC)) {
t.Fatalf("since = %v, want parsed 2026-07-20", got.Since)
}
if got.Until == nil || !got.Until.Equal(time.Date(2026, 7, 21, 0, 0, 0, 0, time.UTC)) {
t.Fatalf("until = %v, want parsed 2026-07-21", got.Until)
}
// Reader requests one extra row past the page limit to derive HasMore.
if got.Limit != 11 {
t.Fatalf("data-source Limit = %d, want page-limit+1 (11)", got.Limit)
}
}
func TestPluginHost_Messages_PaginatesWithHasMore(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"messages"}})
base := time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC)
// Data source returns limit+1 rows (3) for a page limit of 2 → HasMore.
for i := 0; i < 3; i++ {
d.messages.messages = append(d.messages.messages, &taskmodels.Message{
ID: fmt.Sprintf("m%d", i),
AuthorType: taskmodels.MessageAuthorUser,
Content: "hi",
CreatedAt: base.Add(time.Duration(i) * time.Minute),
})
}
msgs, info, err := d.host.Messages().List(context.Background(), pluginsdk.MessageFilter{}, pluginsdk.Page{Limit: 2})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("page len = %d, want 2 (trimmed from 3)", len(msgs))
}
if info == nil || !info.HasMore || info.NextCursor != "2" {
t.Fatalf("PageInfo = %+v, want HasMore=true NextCursor=2", info)
}
}
func TestPluginHost_Messages_TooManyFilterValuesIsInvalidArgument(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"messages"}})
sessionIDs := make([]string, maxMessageFilterValues+1)
for i := range sessionIDs {
sessionIDs[i] = fmt.Sprintf("s%d", i)
}
_, _, err := d.host.Messages().List(context.Background(), pluginsdk.MessageFilter{SessionIDs: sessionIDs}, pluginsdk.Page{})
st, ok := status.FromError(err)
if !ok || st.Code() != codes.InvalidArgument {
t.Fatalf("err = %v, want InvalidArgument", err)
}
if d.messages.calls != 0 {
t.Fatalf("data source called %d times, want 0 (rejected before query)", d.messages.calls)
}
}
func TestPluginHost_Messages_InvalidTimeIsInvalidArgument(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"messages"}})
bad := "not-a-timestamp"
_, _, err := d.host.Messages().List(context.Background(), pluginsdk.MessageFilter{Since: &bad}, pluginsdk.Page{})
st, ok := status.FromError(err)
if !ok || st.Code() != codes.InvalidArgument {
t.Fatalf("err = %v, want InvalidArgument", err)
}
if d.messages.calls != 0 {
t.Fatalf("data source called %d times, want 0 (rejected before query)", d.messages.calls)
}
}