Files
kandev/apps/backend/internal/plugins/host_data_wire_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

381 lines
18 KiB
Go

// host_data_wire_test.go proves the Host data API (ADR 0043) end to end over
// the REAL go-plugin gRPC transport: a plugin-side pluginsdk.Host call
// travels through proto -> a real grpc.Server/Client pair with a real
// GRPCBroker (hcplugin.TestPluginGRPCConn — no subprocess, but the same
// transport code production uses) -> this package's real *pluginHost, which
// enforces the api_read:<resource> capability gate and calls the injected
// (fake) data sources.
//
// This complements two narrower test layers that already exist:
// - pkg/pluginsdk/host_data_test.go exercises grpcHostServer/grpcHostClient
// over a bufconn with a fake pluginsdk.Host — proves the SDK's wire
// plumbing, not kandev's real capability gate or DTO mapping.
// - internal/plugins/host_data_test.go calls *pluginHost's methods
// directly (no transport) — proves the gate and DTO mapping in-process.
//
// Only this file drives a real *pluginHost from the plugin side of a real
// broker connection, so it is the one place proving a plugin cannot read
// undeclared resources no matter how it talks to kandev.
//
// Placement: this lives in internal/plugins (not pkg/pluginsdk) because it
// needs to construct a real *pluginHost, which imports pluginsdk — importing
// internal/plugins from a pkg/pluginsdk test would invert that dependency
// direction. internal/plugins already imports pluginsdk and hashicorp/go-plugin
// transitively, so the harness reuse (hcplugin.TestPluginGRPCConn) is a
// same-direction import here.
package plugins
import (
"context"
"testing"
"time"
hcplugin "github.com/hashicorp/go-plugin"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
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/pkg/pluginsdk"
)
// wireAuthorPlugin is a minimal author-facing pluginsdk.Plugin. It only
// exists to receive the Host injected over the broker (via the embedded
// UnimplementedPlugin's HostSetter/Host() accessor — see serve.go's "Host
// injection" doc), so these tests can call host.Sessions()... exactly as a
// real plugin author would from OnEvent/HandleWebhook.
type wireAuthorPlugin struct {
pluginsdk.UnimplementedPlugin
}
// dialPluginHostOverWire serves host (a real, capability-gated *pluginHost)
// as the kandev.plugin.v1 Host service over a real go-plugin gRPC broker —
// hcplugin.TestPluginGRPCConn wires a real unix-socket grpc.Server/Client
// pair with a real GRPCBroker, matching production transport behavior
// (pkg/pluginsdk/serve_test.go uses the identical harness). It returns the
// plugin-side pluginsdk.Host handle, so callers exercise the full
// SDK-call -> proto -> grpc -> broker -> grpcHostServer -> *pluginHost path.
func dialPluginHostOverWire(t *testing.T, host *pluginHost) pluginsdk.Host {
t.Helper()
author := &wireAuthorPlugin{}
gp := &pluginsdk.GRPCPlugin{Impl: author, Host: host, HostDialTimeout: 5 * time.Second}
client, server := hcplugin.TestPluginGRPCConn(t, false, map[string]hcplugin.Plugin{
pluginsdk.PluginMapKey: gp,
})
// Registered in the same order as pkg/pluginsdk/serve_test.go's
// deferred cleanup (server.Stop before client.Close): t.Cleanup runs
// LIFO, so registering Close first means Stop runs first at teardown.
t.Cleanup(func() { _ = client.Close() })
t.Cleanup(server.Stop)
raw, err := client.Dispense(pluginsdk.PluginMapKey)
require.NoError(t, err)
_, ok := raw.(*pluginsdk.RemotePlugin)
require.True(t, ok, "Dispense(%q) should return *pluginsdk.RemotePlugin, got %T", pluginsdk.PluginMapKey, raw)
require.Eventually(t, func() bool {
return author.Host() != nil
}, 5*time.Second, 10*time.Millisecond, "plugin-side Host should be injected via the broker")
return author.Host()
}
// seedSessionsWireFixture wires d's fake task data source with a single
// workspace/task and n canned sessions (newest first: session-0 started
// most recently), plus n matching SessionCodeStats rows in the analytics
// fake, and a distinguishing acp_session_id on the first session so the DTO
// round-trip assertion has something to check besides IDs.
func seedSessionsWireFixture(d *testDataHost, n int) {
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {{ID: "task-1", WorkspaceID: "ws-1"}}}
base := time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC)
sessions := make([]*taskmodels.TaskSession, n)
stats := make([]*analyticsmodels.SessionCodeStats, n)
for i := 0; i < n; i++ {
id := "session-" + string(rune('0'+i))
sessions[i] = &taskmodels.TaskSession{
ID: id,
TaskID: "task-1",
State: taskmodels.TaskSessionStateRunning,
StartedAt: base.Add(time.Duration(n-i) * time.Hour), // session-0 newest
}
if i == 0 {
sessions[i].Metadata = map[string]any{"acp": map[string]any{"session_id": "acp-session-0"}}
}
stats[i] = &analyticsmodels.SessionCodeStats{
SessionID: id,
LinesAddedCommitted: int64(10 * (i + 1)),
}
}
d.tasks.sessionsByTask = map[string][]*taskmodels.TaskSession{"task-1": sessions}
d.codeStats.stats = stats
}
// TestPluginHostData_Wire_GetTaskNotFound proves a missing task surfaces as
// gRPC NotFound over the real broker transport (GetTaskResponse's wrapper
// doesn't change that), matching *pluginHost's in-process taskReader.Get
// contract exactly — see host_data_test.go's
// TestPluginHost_Tasks_SucceedsWithCapability for the in-process half.
func TestPluginHostData_Wire_GetTaskNotFound(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
host := dialPluginHostOverWire(t, d.host)
task, err := host.Tasks().Get(context.Background(), "no-such-task")
require.Nil(t, task)
require.Error(t, err)
st, ok := status.FromError(err)
require.True(t, ok, "expected a gRPC status error, got %v", err)
require.Equal(t, codes.NotFound, st.Code())
}
// TestPluginHostData_Wire_SessionsRoundTrip proves canned Sessions +
// SessionCodeStats data round-trips correctly, over the real broker
// transport, through a *pluginHost whose manifest declares api_read:sessions
// — the concrete "plugin reads kandev data over gRPC" scenario ADR 0043
// exists for.
func TestPluginHostData_Wire_SessionsRoundTrip(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}})
seedSessionsWireFixture(d, 1)
host := dialPluginHostOverWire(t, d.host)
sessions, pageInfo, err := host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{})
require.NoError(t, err)
require.Len(t, sessions, 1)
require.Equal(t, "session-0", sessions[0].ID)
require.Equal(t, "task-1", sessions[0].TaskID)
require.Equal(t, "acp-session-0", sessions[0].ACPSessionID, "acp_session_id should round-trip from TaskSession.Metadata")
require.Equal(t, "RUNNING", sessions[0].State)
require.NotNil(t, pageInfo)
require.False(t, pageInfo.HasMore)
stats, _, err := host.Sessions().CodeStats(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{})
require.NoError(t, err)
require.Len(t, stats, 1)
require.Equal(t, "session-0", stats[0].SessionID)
require.Equal(t, int64(10), stats[0].LinesAddedCommitted)
}
// TestPluginHostData_Wire_PermissionDeniedPerResource is the security
// assertion: a *pluginHost whose manifest does NOT declare api_read:sessions
// returns gRPC PermissionDenied with the exact "capability
// 'api_read:sessions' not declared" wire message when a plugin calls
// Sessions().CodeStats over the real transport — proving the gate is
// enforced host-side, not something a malicious or buggy plugin could route
// around by talking to the wire protocol directly. Run twice, with two
// different capability sets, to prove the gate is per-resource (not a single
// on/off switch): a manifest with only api_read:tasks still denies sessions,
// while its own declared resource (tasks) succeeds over the same connection.
func TestPluginHostData_Wire_PermissionDeniedPerResource(t *testing.T) {
t.Run("NoCapabilitiesDeclared", func(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{})
seedSessionsWireFixture(d, 1)
host := dialPluginHostOverWire(t, d.host)
_, _, err := host.Sessions().CodeStats(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{})
require.Error(t, err)
st, ok := status.FromError(err)
require.True(t, ok, "expected a gRPC status error, got %v", err)
require.Equal(t, codes.PermissionDenied, st.Code())
require.Equal(t, "capability 'api_read:sessions' not declared", st.Message())
})
t.Run("OnlyTasksCapabilityDeclared_TasksWorkSessionsDenied", func(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
seedSessionsWireFixture(d, 1)
// seedSessionsWireFixture wires a bare task-1 fixture (Sessions()
// needs one to hang sessions off of); give it a Title so the Tasks()
// assertion below has something distinguishing to check.
d.tasks.tasksByWorkspace["ws-1"][0].Title = "Fix the bug"
host := dialPluginHostOverWire(t, d.host)
// The declared resource (tasks) succeeds over the wire.
tasks, _, err := host.Tasks().List(context.Background(), pluginsdk.TaskFilter{}, pluginsdk.Page{})
require.NoError(t, err)
require.Len(t, tasks, 1)
require.Equal(t, "Fix the bug", tasks[0].Title)
// The undeclared resource (sessions) is denied, per-resource, on the
// exact same connection/host.
_, _, err = host.Sessions().CodeStats(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{})
require.Error(t, err)
st, ok := status.FromError(err)
require.True(t, ok, "expected a gRPC status error, got %v", err)
require.Equal(t, codes.PermissionDenied, st.Code())
require.Equal(t, "capability 'api_read:sessions' not declared", st.Message())
_, _, err = host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{})
require.Error(t, err)
st, ok = status.FromError(err)
require.True(t, ok, "expected a gRPC status error, got %v", err)
require.Equal(t, codes.PermissionDenied, st.Code())
require.Equal(t, "capability 'api_read:sessions' not declared", st.Message())
})
}
// TestPluginHostData_Wire_Messages proves the api_read:messages gate and the
// content sanitization travel intact over the real transport: an undeclared
// manifest is denied with the exact wire message, and a declared one reads
// back conversation content with <kandev-system> blocks stripped.
func TestPluginHostData_Wire_Messages(t *testing.T) {
t.Run("DeniedWithoutCapability", func(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{})
host := dialPluginHostOverWire(t, d.host)
_, _, err := host.Messages().List(context.Background(), pluginsdk.MessageFilter{}, pluginsdk.Page{})
require.Error(t, err)
st, ok := status.FromError(err)
require.True(t, ok, "expected a gRPC status error, got %v", err)
require.Equal(t, codes.PermissionDenied, st.Code())
require.Equal(t, "capability 'api_read:messages' not declared", st.Message())
})
t.Run("SucceedsAndStripsSystemContent", func(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"messages"}})
d.messages.messages = []*taskmodels.Message{{
ID: "m1",
TaskSessionID: "s1",
TaskID: "t1",
AuthorType: taskmodels.MessageAuthorUser,
Content: "<kandev-system>hidden</kandev-system>Visible text.",
Type: taskmodels.MessageTypeMessage,
CreatedAt: time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC),
}}
host := dialPluginHostOverWire(t, d.host)
msgs, _, err := host.Messages().List(context.Background(), pluginsdk.MessageFilter{SessionIDs: []string{"s1"}}, pluginsdk.Page{Limit: 10})
require.NoError(t, err)
require.Len(t, msgs, 1)
require.Equal(t, "Visible text.", msgs[0].Content)
require.Equal(t, "user", msgs[0].AuthorType)
})
}
// TestPluginHostData_Wire_Writes proves the write RPCs (CreateTask/UpdateTask/
// SendMessage) travel intact over the real broker transport and enforce
// api_write:<resource> host-side, exactly like the reads. A plugin declaring
// api_write:tasks (but not messages) can create/update tasks over the wire, and
// its undeclared message send is denied with the exact wire message on the
// same connection.
func TestPluginHostData_Wire_Writes(t *testing.T) {
t.Run("TasksCreateUpdateSucceed_MessageDenied", func(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}})
d.taskWriter.created = &taskmodels.Task{ID: "task-new", WorkspaceID: "ws-1", WorkflowID: "wf-1", Title: "Investigate"}
d.taskWriter.updated = &taskmodels.Task{ID: "task-1", Title: "Renamed"}
host := dialPluginHostOverWire(t, d.host)
created, err := host.Tasks().Create(context.Background(), pluginsdk.CreateTaskInput{
WorkspaceID: "ws-1", WorkflowID: "wf-1", Title: "Investigate", StartAgent: true,
})
require.NoError(t, err)
require.Equal(t, "task-new", created.ID)
require.Equal(t, "plugin:p1", d.taskWriter.lastCreate.Source)
require.Equal(t, 1, d.starter.calls, "start_agent should reach the wired starter")
title := "Renamed"
updated, err := host.Tasks().Update(context.Background(), pluginsdk.UpdateTaskInput{ID: "task-1", Title: &title})
require.NoError(t, err)
require.Equal(t, "task-1", updated.ID)
// The undeclared message write is denied, per-resource, on the same host.
_, err = host.Messages().Send(context.Background(), "task-1", "", "hi")
require.Error(t, err)
st, ok := status.FromError(err)
require.True(t, ok, "expected a gRPC status error, got %v", err)
require.Equal(t, codes.PermissionDenied, st.Code())
require.Equal(t, "capability 'api_write:messages' not declared", st.Message())
})
t.Run("SendMessageSucceeds", func(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"messages"}})
d.messenger.result = PluginMessageResult{SessionID: "session-2", Status: "queued"}
host := dialPluginHostOverWire(t, d.host)
dispatch, err := host.Messages().Send(context.Background(), "task-1", "session-2", "from the plugin")
require.NoError(t, err)
require.Equal(t, "session-2", dispatch.SessionID)
require.Equal(t, "queued", dispatch.Status)
require.Equal(t, "from the plugin", d.messenger.lastText)
require.Equal(t, "plugin:p1", d.messenger.lastSource)
})
t.Run("TaskCreateDeniedWithoutCapability", func(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{})
host := dialPluginHostOverWire(t, d.host)
_, err := host.Tasks().Create(context.Background(), pluginsdk.CreateTaskInput{Title: "x", WorkspaceID: "ws-1", WorkflowID: "wf-1"})
require.Error(t, err)
st, ok := status.FromError(err)
require.True(t, ok, "expected a gRPC status error, got %v", err)
require.Equal(t, codes.PermissionDenied, st.Code())
require.Equal(t, "capability 'api_write:tasks' not declared", st.Message())
require.Equal(t, 0, d.taskWriter.createCalls, "gate must block before the service call")
})
}
// TestPluginHostData_Wire_InvokeUtilityAgent proves the agent_invoke gate and
// the one-shot completion round-trip over the real transport: an undeclared
// manifest is PermissionDenied, and a declared one resolves the configured
// profile and returns the runner's text.
func TestPluginHostData_Wire_InvokeUtilityAgent(t *testing.T) {
t.Run("DeniedWithoutCapability", func(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{})
host := dialPluginHostOverWire(t, d.host)
_, err := host.InvokeUtilityAgent(context.Background(), "hi")
require.Error(t, err)
st, ok := status.FromError(err)
require.True(t, ok, "expected a gRPC status error, got %v", err)
require.Equal(t, codes.PermissionDenied, st.Code())
require.Equal(t, "capability 'agent_invoke' not declared", st.Message())
})
t.Run("Succeeds", func(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{AgentInvoke: true})
d.utilAgents.agent = &UtilityAgent{Name: "summarizer", AgentID: "claude-acp", Model: "claude-opus-4-8", Enabled: true}
d.utilRun.text = "summary text"
host := dialPluginHostOverWire(t, d.host)
got, err := host.InvokeUtilityAgent(context.Background(), "summarize")
require.NoError(t, err)
require.Equal(t, "summary text", got)
require.Equal(t, "claude-acp", d.utilRun.gotAgentType)
require.Equal(t, "claude-opus-4-8", d.utilRun.gotModel)
})
}
// TestPluginHostData_Wire_SessionsPagination seeds three sessions and drives
// two Sessions().List calls over the real broker connection: Page{Limit: 2}
// must return exactly 2 items plus a non-empty PageInfo.NextCursor, and a
// follow-up call passing that cursor back must return the remaining item
// with HasMore=false — proving the opaque-cursor pagination contract (ADR
// 0042 "Conventions") survives the real proto round trip, not just the
// in-process paginate() helper.
func TestPluginHostData_Wire_SessionsPagination(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}})
seedSessionsWireFixture(d, 3)
host := dialPluginHostOverWire(t, d.host)
firstPage, pageInfo, err := host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{Limit: 2})
require.NoError(t, err)
require.Len(t, firstPage, 2)
require.Equal(t, "session-0", firstPage[0].ID)
require.Equal(t, "session-1", firstPage[1].ID)
require.NotNil(t, pageInfo)
require.True(t, pageInfo.HasMore)
require.NotEmpty(t, pageInfo.NextCursor)
secondPage, secondPageInfo, err := host.Sessions().List(
context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{Limit: 2, Cursor: pageInfo.NextCursor},
)
require.NoError(t, err)
require.Len(t, secondPage, 1)
require.Equal(t, "session-2", secondPage[0].ID)
require.NotNil(t, secondPageInfo)
require.False(t, secondPageInfo.HasMore)
require.Empty(t, secondPageInfo.NextCursor)
}