Files
kandev/apps/backend/pkg/pluginsdk/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

372 lines
14 KiB
Go

package pluginsdk
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// dataRecordingHost is a fake Go-native Host implementation whose Host data
// API (ADR 0043) accessors are backed by in-memory fixtures, used to prove
// grpcHostServer -> grpcHostClient reachability for the read RPCs added in
// task-03. State/secrets/emit are inherited (unimplemented) from
// UnimplementedHostData since these tests only exercise the data accessors.
type dataRecordingHost struct {
UnimplementedHostData
tasks map[string]Task
taskList []Task
taskPageInfo *PageInfo
sessions []Session
sessionPage *PageInfo
codeStats []SessionCodeStats
codeStatsPage *PageInfo
workspaces []Workspace
workflows []Workflow
workflowSteps []WorkflowStep
agentProfiles []AgentProfile
repositories []Repository
messages []Message
messagePage *PageInfo
utilityText string
lastTaskFilter TaskFilter
lastSessionFilter SessionFilter
lastMessageFilter MessageFilter
lastWorkspaceID string
lastWorkflowID string
lastUtilityPrompt string
createdTask Task
lastCreateInput CreateTaskInput
updatedTask Task
lastUpdateInput UpdateTaskInput
messageDispatch MessageDispatch
lastSendTask string
lastSendSession string
lastSendText string
}
func (h *dataRecordingHost) GetState(context.Context, string, string, string) (map[string]any, bool, error) {
return nil, false, nil
}
func (h *dataRecordingHost) SetState(context.Context, string, string, string, map[string]any) error {
return nil
}
func (h *dataRecordingHost) DeleteState(context.Context, string, string, string) error { return nil }
func (h *dataRecordingHost) ListState(context.Context, string, string) ([]StateEntry, error) {
return nil, nil
}
func (h *dataRecordingHost) GetConfig(context.Context) (map[string]any, error) { return nil, nil }
func (h *dataRecordingHost) GetSecret(context.Context, string) (string, bool, error) {
return "", false, nil
}
func (h *dataRecordingHost) SetSecret(context.Context, string, string) error { return nil }
func (h *dataRecordingHost) DeleteSecret(context.Context, string) error { return nil }
func (h *dataRecordingHost) RevealSecret(context.Context, string) (string, error) { return "", nil }
func (h *dataRecordingHost) EmitEvent(context.Context, string, map[string]any) error {
return nil
}
func (h *dataRecordingHost) Tasks() TaskReader { return dataRecordingTaskReader{h} }
func (h *dataRecordingHost) Sessions() SessionReader { return dataRecordingSessionReader{h} }
func (h *dataRecordingHost) Workspaces() WorkspaceReader { return dataRecordingWorkspaceReader{h} }
func (h *dataRecordingHost) Workflows() WorkflowReader { return dataRecordingWorkflowReader{h} }
func (h *dataRecordingHost) AgentProfiles() AgentProfileReader {
return dataRecordingAgentProfileReader{h}
}
func (h *dataRecordingHost) Repositories() RepositoryReader {
return dataRecordingRepositoryReader{h}
}
func (h *dataRecordingHost) Messages() MessageReader { return dataRecordingMessageReader{h} }
func (h *dataRecordingHost) InvokeUtilityAgent(_ context.Context, prompt string) (string, error) {
h.lastUtilityPrompt = prompt
return h.utilityText, nil
}
type dataRecordingTaskReader struct{ h *dataRecordingHost }
func (r dataRecordingTaskReader) List(_ context.Context, filter TaskFilter, _ Page) ([]Task, *PageInfo, error) {
r.h.lastTaskFilter = filter
return r.h.taskList, r.h.taskPageInfo, nil
}
func (r dataRecordingTaskReader) Get(_ context.Context, id string) (*Task, error) {
task, ok := r.h.tasks[id]
if !ok {
return nil, status.Errorf(codes.NotFound, "task %q not found", id)
}
return &task, nil
}
func (r dataRecordingTaskReader) Create(_ context.Context, in CreateTaskInput) (*Task, error) {
r.h.lastCreateInput = in
task := r.h.createdTask
return &task, nil
}
func (r dataRecordingTaskReader) Update(_ context.Context, in UpdateTaskInput) (*Task, error) {
r.h.lastUpdateInput = in
task := r.h.updatedTask
return &task, nil
}
type dataRecordingSessionReader struct{ h *dataRecordingHost }
func (r dataRecordingSessionReader) List(_ context.Context, filter SessionFilter, _ Page) ([]Session, *PageInfo, error) {
r.h.lastSessionFilter = filter
return r.h.sessions, r.h.sessionPage, nil
}
func (r dataRecordingSessionReader) CodeStats(_ context.Context, filter SessionFilter, _ Page) ([]SessionCodeStats, *PageInfo, error) {
r.h.lastSessionFilter = filter
return r.h.codeStats, r.h.codeStatsPage, nil
}
type dataRecordingWorkspaceReader struct{ h *dataRecordingHost }
func (r dataRecordingWorkspaceReader) List(context.Context, Page) ([]Workspace, *PageInfo, error) {
return r.h.workspaces, nil, nil
}
type dataRecordingWorkflowReader struct{ h *dataRecordingHost }
func (r dataRecordingWorkflowReader) List(_ context.Context, workspaceID string, _ Page) ([]Workflow, *PageInfo, error) {
r.h.lastWorkspaceID = workspaceID
return r.h.workflows, nil, nil
}
func (r dataRecordingWorkflowReader) ListSteps(_ context.Context, workflowID string) ([]WorkflowStep, error) {
r.h.lastWorkflowID = workflowID
return r.h.workflowSteps, nil
}
type dataRecordingAgentProfileReader struct{ h *dataRecordingHost }
func (r dataRecordingAgentProfileReader) List(context.Context, Page) ([]AgentProfile, *PageInfo, error) {
return r.h.agentProfiles, nil, nil
}
type dataRecordingRepositoryReader struct{ h *dataRecordingHost }
func (r dataRecordingRepositoryReader) List(_ context.Context, workspaceID string, _ Page) ([]Repository, *PageInfo, error) {
r.h.lastWorkspaceID = workspaceID
return r.h.repositories, nil, nil
}
type dataRecordingMessageReader struct{ h *dataRecordingHost }
func (r dataRecordingMessageReader) List(_ context.Context, filter MessageFilter, _ Page) ([]Message, *PageInfo, error) {
r.h.lastMessageFilter = filter
return r.h.messages, r.h.messagePage, nil
}
func (r dataRecordingMessageReader) Send(_ context.Context, taskID, sessionID, text string) (*MessageDispatch, error) {
r.h.lastSendTask = taskID
r.h.lastSendSession = sessionID
r.h.lastSendText = text
dispatch := r.h.messageDispatch
return &dispatch, nil
}
func TestHostData_TasksListAndGet(t *testing.T) {
impl := &dataRecordingHost{
tasks: map[string]Task{
"task-1": {ID: "task-1", Title: "Fix the bug", State: "todo"},
},
taskList: []Task{{ID: "task-1", Title: "Fix the bug", State: "todo"}},
taskPageInfo: &PageInfo{NextCursor: "next-1", HasMore: true},
}
host := dialHostOverBufconn(t, impl)
tasks, pageInfo, err := host.Tasks().List(context.Background(), TaskFilter{States: []string{"todo"}}, Page{Limit: 10})
require.NoError(t, err)
require.Equal(t, []Task{{ID: "task-1", Title: "Fix the bug", State: "todo"}}, tasks)
require.Equal(t, &PageInfo{NextCursor: "next-1", HasMore: true}, pageInfo)
require.Equal(t, TaskFilter{States: []string{"todo"}}, impl.lastTaskFilter)
task, err := host.Tasks().Get(context.Background(), "task-1")
require.NoError(t, err)
require.Equal(t, "Fix the bug", task.Title)
_, err = host.Tasks().Get(context.Background(), "missing")
require.Error(t, err)
require.Equal(t, codes.NotFound, status.Code(err))
}
// TestHostData_TaskWritesAndMessage proves the write RPCs (CreateTask,
// UpdateTask, SendMessage) round-trip inputs and results through
// grpcHostClient -> proto -> grpcHostServer, including the optional-pointer
// fields on the update mask.
func TestHostData_TaskWritesAndMessage(t *testing.T) {
impl := &dataRecordingHost{
createdTask: Task{ID: "task-new", Title: "Investigate crash", State: "TODO"},
updatedTask: Task{ID: "task-1", Title: "Renamed", State: "IN_PROGRESS"},
messageDispatch: MessageDispatch{SessionID: "session-1", Status: "queued"},
}
host := dialHostOverBufconn(t, impl)
stepID := "step-2"
created, err := host.Tasks().Create(context.Background(), CreateTaskInput{
WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: &stepID,
Title: "Investigate crash", Description: "stack trace attached", StartAgent: true,
})
require.NoError(t, err)
require.Equal(t, "task-new", created.ID)
require.Equal(t, "ws-1", impl.lastCreateInput.WorkspaceID)
require.NotNil(t, impl.lastCreateInput.WorkflowStepID)
require.Equal(t, "step-2", *impl.lastCreateInput.WorkflowStepID)
require.True(t, impl.lastCreateInput.StartAgent)
title := "Renamed"
state := "IN_PROGRESS"
updated, err := host.Tasks().Update(context.Background(), UpdateTaskInput{ID: "task-1", Title: &title, State: &state})
require.NoError(t, err)
require.Equal(t, "IN_PROGRESS", updated.State)
require.Equal(t, "task-1", impl.lastUpdateInput.ID)
require.NotNil(t, impl.lastUpdateInput.Title)
require.Equal(t, "Renamed", *impl.lastUpdateInput.Title)
require.Nil(t, impl.lastUpdateInput.Description, "an unset field must stay nil across the wire")
dispatch, err := host.Messages().Send(context.Background(), "task-1", "session-1", "rerun the tests")
require.NoError(t, err)
require.Equal(t, "session-1", dispatch.SessionID)
require.Equal(t, "queued", dispatch.Status)
require.Equal(t, "task-1", impl.lastSendTask)
require.Equal(t, "session-1", impl.lastSendSession)
require.Equal(t, "rerun the tests", impl.lastSendText)
}
func TestHostData_SessionsListAndCodeStats(t *testing.T) {
impl := &dataRecordingHost{
sessions: []Session{{ID: "session-1", TaskID: "task-1", State: "running"}},
codeStats: []SessionCodeStats{{SessionID: "session-1", LinesAddedCommitted: 10}},
}
host := dialHostOverBufconn(t, impl)
sessions, _, err := host.Sessions().List(context.Background(), SessionFilter{TaskIDs: []string{"task-1"}}, Page{})
require.NoError(t, err)
require.Equal(t, impl.sessions, sessions)
require.Equal(t, SessionFilter{TaskIDs: []string{"task-1"}}, impl.lastSessionFilter)
stats, _, err := host.Sessions().CodeStats(context.Background(), SessionFilter{TaskIDs: []string{"task-1"}}, Page{})
require.NoError(t, err)
require.Equal(t, impl.codeStats, stats)
}
func TestHostData_Workspaces(t *testing.T) {
impl := &dataRecordingHost{workspaces: []Workspace{{ID: "ws-1", Name: "Acme"}}}
host := dialHostOverBufconn(t, impl)
workspaces, _, err := host.Workspaces().List(context.Background(), Page{})
require.NoError(t, err)
require.Equal(t, impl.workspaces, workspaces)
}
func TestHostData_InvokeUtilityAgent(t *testing.T) {
impl := &dataRecordingHost{utilityText: "the completion"}
host := dialHostOverBufconn(t, impl)
text, err := host.InvokeUtilityAgent(context.Background(), "do the thing")
require.NoError(t, err)
require.Equal(t, "the completion", text)
require.Equal(t, "do the thing", impl.lastUtilityPrompt)
}
func TestHostData_Messages(t *testing.T) {
impl := &dataRecordingHost{
messages: []Message{{
ID: "m1", SessionID: "s1", TaskID: "t1", TurnID: "turn1",
AuthorType: "user", Content: "hello", Type: "message", CreatedAt: "2026-07-20T09:00:00Z",
}},
messagePage: &PageInfo{NextCursor: "2", HasMore: true},
}
host := dialHostOverBufconn(t, impl)
since := "2026-07-20T00:00:00Z"
filter := MessageFilter{SessionIDs: []string{"s1"}, TaskIDs: []string{"t1"}, Since: &since, Types: []string{"message"}}
messages, pageInfo, err := host.Messages().List(context.Background(), filter, Page{Limit: 10})
require.NoError(t, err)
require.Equal(t, impl.messages, messages)
require.Equal(t, &PageInfo{NextCursor: "2", HasMore: true}, pageInfo)
// Filter (including the optional Since pointer) round-trips through proto.
require.Equal(t, filter, impl.lastMessageFilter)
require.NotNil(t, impl.lastMessageFilter.Since)
require.Equal(t, since, *impl.lastMessageFilter.Since)
}
func TestHostData_WorkflowsAndSteps(t *testing.T) {
impl := &dataRecordingHost{
workflows: []Workflow{{ID: "wf-1", WorkspaceID: "ws-1", Name: "Default"}},
workflowSteps: []WorkflowStep{{ID: "step-1", WorkflowID: "wf-1", Name: "Review"}},
}
host := dialHostOverBufconn(t, impl)
workflows, _, err := host.Workflows().List(context.Background(), "ws-1", Page{})
require.NoError(t, err)
require.Equal(t, impl.workflows, workflows)
require.Equal(t, "ws-1", impl.lastWorkspaceID)
steps, err := host.Workflows().ListSteps(context.Background(), "wf-1")
require.NoError(t, err)
require.Equal(t, impl.workflowSteps, steps)
require.Equal(t, "wf-1", impl.lastWorkflowID)
}
func TestHostData_AgentProfiles(t *testing.T) {
impl := &dataRecordingHost{agentProfiles: []AgentProfile{{ID: "profile-1", DisplayName: "Claude"}}}
host := dialHostOverBufconn(t, impl)
profiles, _, err := host.AgentProfiles().List(context.Background(), Page{})
require.NoError(t, err)
require.Equal(t, impl.agentProfiles, profiles)
}
func TestHostData_Repositories(t *testing.T) {
impl := &dataRecordingHost{repositories: []Repository{{ID: "repo-1", WorkspaceID: "ws-1", Name: "kdlbs/kandev"}}}
host := dialHostOverBufconn(t, impl)
repos, _, err := host.Repositories().List(context.Background(), "ws-1", Page{})
require.NoError(t, err)
require.Equal(t, impl.repositories, repos)
require.Equal(t, "ws-1", impl.lastWorkspaceID)
}
// TestHostData_UnimplementedHostData_ReturnsUnimplemented proves that a Host
// implementation embedding UnimplementedHostData (task-03's default for
// task-04 to override) compiles against the Host interface and, over the
// wire, returns a gRPC Unimplemented status for every Host data API RPC —
// so a plugin author calling an unwired accessor gets a clear error rather
// than a panic or a zero-value success.
func TestHostData_UnimplementedHostData_ReturnsUnimplemented(t *testing.T) {
impl := &recordingHost{}
host := dialHostOverBufconn(t, impl)
_, _, err := host.Tasks().List(context.Background(), TaskFilter{}, Page{})
require.Error(t, err)
require.Equal(t, codes.Unimplemented, status.Code(err))
_, err = host.Tasks().Get(context.Background(), "task-1")
require.Error(t, err)
require.Equal(t, codes.Unimplemented, status.Code(err))
_, _, err = host.Sessions().CodeStats(context.Background(), SessionFilter{}, Page{})
require.Error(t, err)
require.Equal(t, codes.Unimplemented, status.Code(err))
_, err = host.Tasks().Create(context.Background(), CreateTaskInput{Title: "x"})
require.Error(t, err)
require.Equal(t, codes.Unimplemented, status.Code(err))
_, err = host.Tasks().Update(context.Background(), UpdateTaskInput{ID: "task-1"})
require.Error(t, err)
require.Equal(t, codes.Unimplemented, status.Code(err))
_, err = host.Messages().Send(context.Background(), "task-1", "", "body")
require.Error(t, err)
require.Equal(t, codes.Unimplemented, status.Code(err))
}