From 2f12c680b8cd6bdf835058198548fc146cb02c83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 2 Jun 2026 16:46:50 +0100 Subject: [PATCH] feat(mcp): include associated PRs in task-listing tools (#1236) --- apps/backend/cmd/kandev/helpers.go | 45 +++++ .../backend/internal/mcp/handlers/handlers.go | 5 + .../internal/mcp/handlers/handoff_handlers.go | 1 + .../internal/mcp/handlers/task_pr_enrich.go | 129 +++++++++++++ .../mcp/handlers/task_pr_enrich_test.go | 179 ++++++++++++++++++ .../internal/mcp/server/config_handlers.go | 2 +- .../internal/mcp/server/handoff_handlers.go | 5 +- apps/backend/internal/mcp/server/server.go | 2 +- apps/backend/internal/task/dto/dto.go | 6 + .../internal/task/service/handoff_service.go | 18 +- apps/backend/pkg/api/v1/task.go | 12 ++ 11 files changed, 392 insertions(+), 12 deletions(-) create mode 100644 apps/backend/internal/mcp/handlers/task_pr_enrich.go create mode 100644 apps/backend/internal/mcp/handlers/task_pr_enrich_test.go diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 3a476d8f30..fb507fcc6f 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -895,6 +895,45 @@ func (a githubRateLimitAdapter) ExhaustedRateLimits() []health.GitHubRateLimitSt return out } +// mcpTaskPRListerAdapter adapts *github.Service to the MCP handlers' +// TaskPRLister interface so list_tasks responses can carry per-task PR +// summaries. Returns an empty map when the github service is nil. +type mcpTaskPRListerAdapter struct { + gh *github.Service +} + +func (a mcpTaskPRListerAdapter) ListTaskPRsByTaskIDs( + ctx context.Context, taskIDs []string, +) (map[string][]mcphandlers.TaskPRInfo, error) { + out := make(map[string][]mcphandlers.TaskPRInfo) + if a.gh == nil || len(taskIDs) == 0 { + return out, nil + } + prs, err := a.gh.ListTaskPRsByTaskIDs(ctx, taskIDs) + if err != nil { + return nil, err + } + for taskID, list := range prs { + infos := make([]mcphandlers.TaskPRInfo, 0, len(list)) + for _, pr := range list { + if pr == nil { + continue + } + infos = append(infos, mcphandlers.TaskPRInfo{ + Number: pr.PRNumber, + URL: pr.PRURL, + Title: pr.PRTitle, + State: pr.State, + MergedAt: pr.MergedAt, + }) + } + if len(infos) > 0 { + out[taskID] = infos + } + } + return out, nil +} + // registerMCPAndDebugRoutes registers MCP and debug routes and wires the MCP handler. func registerMCPAndDebugRoutes( p routeParams, @@ -911,6 +950,12 @@ func registerMCPAndDebugRoutes( // Wire config-mode dependencies for agent-native configuration mcpHandlers.SetConfigDeps(p.services.Workflow, p.agentSettingsController, p.mcpConfigSvc) + // Enrich list_tasks responses with associated GitHub PRs (link, title, + // number, state) when the github service is available. + if p.services.GitHub != nil { + mcpHandlers.SetTaskPRLister(mcpTaskPRListerAdapter{gh: p.services.GitHub}) + } + // Reuse the cross-task handoff service constructed in registerRoutes — // the same instance backs the MCP path and the HTTP Kanban path so // workspace-group state stays consistent across both surfaces. diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 2e9c6fd7d9..29ceed23fa 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -110,6 +110,10 @@ type Handlers struct { // Wires the list_related_tasks_kandev / *_task_document_kandev // MCP tools introduced in office task handoffs phase 2. handoffSvc *service.HandoffService + + // Optional PR lister (set via SetTaskPRLister) used to enrich + // task-listing responses with associated pull requests. + taskPRLister TaskPRLister } // NewHandlers creates new MCP handlers. @@ -352,6 +356,7 @@ func (h *Handlers) handleListTasks(ctx context.Context, msg *ws.Message) (*ws.Me for _, t := range tasks { dtos = append(dtos, dto.FromTask(t)) } + h.enrichTasksWithPRs(ctx, dtos) return dto.ListTasksResponse{Tasks: dtos, Total: len(dtos)}, nil }) } diff --git a/apps/backend/internal/mcp/handlers/handoff_handlers.go b/apps/backend/internal/mcp/handlers/handoff_handlers.go index 585798a07c..8c11d42c08 100644 --- a/apps/backend/internal/mcp/handlers/handoff_handlers.go +++ b/apps/backend/internal/mcp/handlers/handoff_handlers.go @@ -40,6 +40,7 @@ func (h *Handlers) handleListRelatedTasks(ctx context.Context, msg *ws.Message) h.logger.Error("list related tasks", zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) } + h.enrichRelatedTasksWithPRs(ctx, related) return ws.NewResponse(msg.ID, msg.Action, related) } diff --git a/apps/backend/internal/mcp/handlers/task_pr_enrich.go b/apps/backend/internal/mcp/handlers/task_pr_enrich.go new file mode 100644 index 0000000000..1437b177fa --- /dev/null +++ b/apps/backend/internal/mcp/handlers/task_pr_enrich.go @@ -0,0 +1,129 @@ +package handlers + +import ( + "context" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/service" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// TaskPRInfo is the decoupled view of a task↔PR association consumed by the +// MCP task-listing handlers. The cmd wiring adapts *github.Service.TaskPR into +// this shape so this package does not depend on internal/github. +type TaskPRInfo struct { + Number int + URL string + Title string + State string // open, closed, merged + MergedAt *time.Time +} + +// TaskPRLister returns PR associations grouped by task ID. Backed by +// *github.Service in production; left nil in contexts without GitHub, in which +// case PRs are simply omitted from task responses. +type TaskPRLister interface { + ListTaskPRsByTaskIDs(ctx context.Context, taskIDs []string) (map[string][]TaskPRInfo, error) +} + +// SetTaskPRLister wires the optional PR lister used to enrich task-listing +// responses with associated pull requests. +func (h *Handlers) SetTaskPRLister(l TaskPRLister) { + h.taskPRLister = l +} + +// prsByTaskID looks up PR associations for the given task IDs via the wired +// lister, mapping each to the shared v1.TaskPRSummary shape. Returns nil when +// the lister is unset, there are no IDs, or the lookup fails (logged) — callers +// treat a nil map as "no PRs to attach". +func (h *Handlers) prsByTaskID(ctx context.Context, taskIDs []string) map[string][]v1.TaskPRSummary { + if h.taskPRLister == nil || len(taskIDs) == 0 { + return nil + } + byTask, err := h.taskPRLister.ListTaskPRsByTaskIDs(ctx, taskIDs) + if err != nil { + h.logger.Warn("failed to list task PRs for MCP response", zap.Error(err)) + return nil + } + out := make(map[string][]v1.TaskPRSummary, len(byTask)) + for taskID, prs := range byTask { + if len(prs) == 0 { + continue + } + summaries := make([]v1.TaskPRSummary, 0, len(prs)) + for _, pr := range prs { + summaries = append(summaries, v1.TaskPRSummary{ + Number: pr.Number, + URL: pr.URL, + Title: pr.Title, + State: pr.State, + MergedAt: pr.MergedAt, + }) + } + out[taskID] = summaries + } + return out +} + +// enrichTasksWithPRs populates dto.TaskDTO.PRs for each task from the wired +// TaskPRLister. No-op when the lister is unset or there are no tasks. +func (h *Handlers) enrichTasksWithPRs(ctx context.Context, tasks []dto.TaskDTO) { + if h.taskPRLister == nil || len(tasks) == 0 { + return + } + ids := make([]string, len(tasks)) + for i := range tasks { + ids[i] = tasks[i].ID + } + byTask := h.prsByTaskID(ctx, ids) + for i := range tasks { + if prs := byTask[tasks[i].ID]; len(prs) > 0 { + tasks[i].PRs = prs + } + } +} + +// enrichRelatedTasksWithPRs populates RelatedTask.PRs across every relation +// surface (the task itself, parent, children, siblings, blockers, blocked-by) +// in a single batched lookup. No-op when the lister is unset. +func (h *Handlers) enrichRelatedTasksWithPRs(ctx context.Context, related *service.RelatedTasks) { + if h.taskPRLister == nil || related == nil { + return + } + nodes := []*service.RelatedTask{&related.Task, related.Parent} + nodes = append(nodes, related.Children...) + nodes = append(nodes, related.Siblings...) + nodes = append(nodes, related.Blockers...) + nodes = append(nodes, related.BlockedBy...) + + // A task can appear in more than one relation group (e.g. both a sibling + // and a blocker), so dedup IDs before the lookup to avoid sending the same + // value twice to the lister's WHERE id IN (...) query. + ids := make([]string, 0, len(nodes)) + seen := make(map[string]struct{}, len(nodes)) + for _, n := range nodes { + if n == nil { + continue + } + if _, dup := seen[n.ID]; dup { + continue + } + seen[n.ID] = struct{}{} + ids = append(ids, n.ID) + } + byTask := h.prsByTaskID(ctx, ids) + if byTask == nil { + return + } + for _, n := range nodes { + if n == nil { + continue + } + if prs := byTask[n.ID]; len(prs) > 0 { + n.PRs = prs + } + } +} diff --git a/apps/backend/internal/mcp/handlers/task_pr_enrich_test.go b/apps/backend/internal/mcp/handlers/task_pr_enrich_test.go new file mode 100644 index 0000000000..6f940bd897 --- /dev/null +++ b/apps/backend/internal/mcp/handlers/task_pr_enrich_test.go @@ -0,0 +1,179 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/service" + v1 "github.com/kandev/kandev/pkg/api/v1" + ws "github.com/kandev/kandev/pkg/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeTaskPRLister struct { + byTask map[string][]TaskPRInfo + err error + gotIDs []string +} + +func (f *fakeTaskPRLister) ListTaskPRsByTaskIDs(_ context.Context, taskIDs []string) (map[string][]TaskPRInfo, error) { + f.gotIDs = taskIDs + if f.err != nil { + return nil, f.err + } + return f.byTask, nil +} + +func TestEnrichTasksWithPRs(t *testing.T) { + lister := &fakeTaskPRLister{ + byTask: map[string][]TaskPRInfo{ + "task-1": { + {Number: 42, URL: "https://github.com/o/r/pull/42", Title: "Fix bug", State: "merged"}, + {Number: 43, URL: "https://github.com/o/r/pull/43", Title: "Add feature", State: "open"}, + }, + }, + } + h := &Handlers{taskPRLister: lister, logger: testLogger(t).WithFields()} + + dtos := []dto.TaskDTO{{ID: "task-1"}, {ID: "task-2"}} + h.enrichTasksWithPRs(context.Background(), dtos) + + assert.ElementsMatch(t, []string{"task-1", "task-2"}, lister.gotIDs) + require.Len(t, dtos[0].PRs, 2) + assert.Equal(t, v1.TaskPRSummary{ + Number: 42, URL: "https://github.com/o/r/pull/42", Title: "Fix bug", State: "merged", + }, dtos[0].PRs[0]) + assert.Equal(t, "open", dtos[0].PRs[1].State) + assert.Nil(t, dtos[1].PRs, "tasks without PRs stay nil") +} + +func TestEnrichTasksWithPRs_NilListerIsNoop(t *testing.T) { + h := &Handlers{logger: testLogger(t).WithFields()} + dtos := []dto.TaskDTO{{ID: "task-1"}} + h.enrichTasksWithPRs(context.Background(), dtos) + assert.Nil(t, dtos[0].PRs) +} + +func TestHandleListTasks_IncludesAssociatedPRs(t *testing.T) { + svc, repo := newTestTaskService(t) + ctx := context.Background() + now := time.Now().UTC() + + require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ + ID: "ws-pr", Name: "PR WS", CreatedAt: now, UpdatedAt: now, + })) + require.NoError(t, repo.CreateTask(ctx, &models.Task{ + ID: "task-pr", WorkspaceID: "ws-pr", WorkflowID: "wf-pr", + Title: "Has a PR", State: v1.TaskStateReview, CreatedAt: now, UpdatedAt: now, + })) + + h := &Handlers{ + taskSvc: svc, + logger: testLogger(t).WithFields(), + taskPRLister: &fakeTaskPRLister{byTask: map[string][]TaskPRInfo{ + "task-pr": {{Number: 7, URL: "https://github.com/o/r/pull/7", Title: "Ship it", State: "merged"}}, + }}, + } + + msg := makeWSMessage(t, ws.ActionMCPListTasks, map[string]any{"workflow_id": "wf-pr"}) + resp, err := h.handleListTasks(ctx, msg) + require.NoError(t, err) + + var payload dto.ListTasksResponse + require.NoError(t, json.Unmarshal(resp.Payload, &payload)) + require.Len(t, payload.Tasks, 1) + require.Len(t, payload.Tasks[0].PRs, 1) + assert.Equal(t, "merged", payload.Tasks[0].PRs[0].State) + assert.Equal(t, 7, payload.Tasks[0].PRs[0].Number) + assert.Equal(t, "https://github.com/o/r/pull/7", payload.Tasks[0].PRs[0].URL) +} + +func TestHandleListRelatedTasks_IncludesAssociatedPRs(t *testing.T) { + svc, repo := newTestTaskService(t) + ctx := context.Background() + now := time.Now().UTC() + + require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ + ID: "ws-rel", Name: "Rel WS", CreatedAt: now, UpdatedAt: now, + })) + require.NoError(t, repo.CreateTask(ctx, &models.Task{ + ID: "task-rel", WorkspaceID: "ws-rel", WorkflowID: "wf-rel", + Title: "Self", State: v1.TaskStateReview, CreatedAt: now, UpdatedAt: now, + })) + + merged := now + h := &Handlers{ + taskSvc: svc, + handoffSvc: service.NewHandoffService(repo, nil, nil, nil, nil, testLogger(t)), + logger: testLogger(t).WithFields(), + taskPRLister: &fakeTaskPRLister{byTask: map[string][]TaskPRInfo{ + "task-rel": {{Number: 99, URL: "https://github.com/o/r/pull/99", Title: "Fix", State: "merged", MergedAt: &merged}}, + }}, + } + + msg := makeWSMessage(t, ws.ActionMCPListRelatedTasks, map[string]any{"task_id": "task-rel"}) + resp, err := h.handleListRelatedTasks(ctx, msg) + require.NoError(t, err) + + var payload service.RelatedTasks + require.NoError(t, json.Unmarshal(resp.Payload, &payload)) + require.Len(t, payload.Task.PRs, 1) + assert.Equal(t, 99, payload.Task.PRs[0].Number) + assert.Equal(t, "merged", payload.Task.PRs[0].State) + require.NotNil(t, payload.Task.PRs[0].MergedAt, "merged_at should be surfaced") +} + +func TestEnrichRelatedTasksWithPRs(t *testing.T) { + lister := &fakeTaskPRLister{byTask: map[string][]TaskPRInfo{ + "self": {{Number: 1, URL: "u1", State: "open"}}, + "parent": {{Number: 3, URL: "u3", State: "closed"}}, + "child": {{Number: 2, URL: "u2", State: "merged"}}, + }} + h := &Handlers{taskPRLister: lister, logger: testLogger(t).WithFields()} + + related := &service.RelatedTasks{ + Task: service.RelatedTask{ID: "self"}, + Parent: &service.RelatedTask{ID: "parent"}, + Children: []*service.RelatedTask{{ID: "child"}, {ID: "child-no-pr"}}, + Siblings: []*service.RelatedTask{{ID: "sibling"}}, + BlockedBy: []*service.RelatedTask{{ID: "blocker"}}, + } + h.enrichRelatedTasksWithPRs(context.Background(), related) + + assert.ElementsMatch(t, + []string{"self", "parent", "child", "child-no-pr", "sibling", "blocker"}, + lister.gotIDs) + require.Len(t, related.Task.PRs, 1) + assert.Equal(t, "open", related.Task.PRs[0].State) + require.Len(t, related.Parent.PRs, 1) + assert.Equal(t, "closed", related.Parent.PRs[0].State) + require.Len(t, related.Children[0].PRs, 1) + assert.Equal(t, "merged", related.Children[0].PRs[0].State) + assert.Nil(t, related.Children[1].PRs, "child without PRs stays nil") + assert.Nil(t, related.Siblings[0].PRs) +} + +func TestEnrichRelatedTasksWithPRs_NilSafe(t *testing.T) { + // nil lister: no-op, no panic. + h := &Handlers{logger: testLogger(t).WithFields()} + h.enrichRelatedTasksWithPRs(context.Background(), &service.RelatedTasks{}) + // lister set but nil related: no panic. + h2 := &Handlers{taskPRLister: &fakeTaskPRLister{}, logger: testLogger(t).WithFields()} + h2.enrichRelatedTasksWithPRs(context.Background(), nil) +} + +func TestEnrichTasksWithPRs_ListerErrorIsSwallowed(t *testing.T) { + h := &Handlers{ + taskPRLister: &fakeTaskPRLister{err: errors.New("boom")}, + logger: testLogger(t).WithFields(), + } + dtos := []dto.TaskDTO{{ID: "task-1"}} + h.enrichTasksWithPRs(context.Background(), dtos) + assert.Nil(t, dtos[0].PRs, "PRs left empty when the lister errors") +} diff --git a/apps/backend/internal/mcp/server/config_handlers.go b/apps/backend/internal/mcp/server/config_handlers.go index e4a9af781f..644989f586 100644 --- a/apps/backend/internal/mcp/server/config_handlers.go +++ b/apps/backend/internal/mcp/server/config_handlers.go @@ -255,7 +255,7 @@ func (s *Server) registerConfigExecutorTools() { func (s *Server) registerConfigTaskTools() { s.mcpServer.AddTool( mcp.NewTool("list_tasks_kandev", - mcp.WithDescription("List all tasks in a workflow."), + mcp.WithDescription("List all tasks in a workflow. Each task includes its associated GitHub pull requests (number, url, title, state) under the \"prs\" field when any exist — use the PR state (open/closed/merged) to find tasks whose work has landed."), mcp.WithString("workflow_id", mcp.Required(), mcp.Description("The workflow ID")), ), s.wrapHandler("list_tasks_kandev", s.listTasksHandler()), diff --git a/apps/backend/internal/mcp/server/handoff_handlers.go b/apps/backend/internal/mcp/server/handoff_handlers.go index 528cade664..233252fee1 100644 --- a/apps/backend/internal/mcp/server/handoff_handlers.go +++ b/apps/backend/internal/mcp/server/handoff_handlers.go @@ -18,8 +18,9 @@ func (s *Server) registerRelatedTasksTool() { mcp.NewTool("list_related_tasks_kandev", mcp.WithDescription( `List parent, children, siblings, blockers, and blocked tasks for the current task. -Use this to discover task IDs you can reach via message_task_kandev. In office mode, document keys are -also included so you can fetch documents with get_task_document_kandev. +Use this to discover task IDs you can reach via message_task_kandev. Each related task includes its +associated GitHub pull requests (number, url, title, state) under the "prs" field when any exist. In +office mode, document keys are also included so you can fetch documents with get_task_document_kandev. Pass task_id to inspect a different task in the same workspace.`, ), mcp.WithString("task_id", mcp.Description("Defaults to the current task.")), diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index c0e18bf736..d5a8cceae0 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -392,7 +392,7 @@ func (s *Server) registerKanbanTools() { ) s.mcpServer.AddTool( mcp.NewTool("list_tasks_kandev", - mcp.WithDescription("List all tasks in a workflow."), + mcp.WithDescription("List all tasks in a workflow. Each task includes its associated GitHub pull requests (number, url, title, state) under the \"prs\" field when any exist — use the PR state (open/closed/merged) to find tasks whose work has landed."), mcp.WithString("workflow_id", mcp.Required(), mcp.Description("The workflow ID")), ), s.wrapHandler("list_tasks_kandev", s.listTasksHandler()), diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index e3cd0c646e..c6fba9e7c1 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -157,6 +157,12 @@ type TaskDTO struct { // kanban-board tasks. Use this to gate office-only UI (e.g. the // "Open in office view" topbar link). IsFromOffice bool `json:"is_from_office,omitempty"` + + // PRs lists the GitHub pull requests associated with this task, when the + // github service is wired and any association exists. Surfaced through the + // task-listing MCP tools so agents can reason about PR status (e.g. find + // tasks whose PRs are merged). Omitted when empty. + PRs []v1.TaskPRSummary `json:"prs,omitempty"` } type TaskRepositoryDTO struct { diff --git a/apps/backend/internal/task/service/handoff_service.go b/apps/backend/internal/task/service/handoff_service.go index b363b25500..3914f674bc 100644 --- a/apps/backend/internal/task/service/handoff_service.go +++ b/apps/backend/internal/task/service/handoff_service.go @@ -11,6 +11,7 @@ import ( orchmodels "github.com/kandev/kandev/internal/office/models" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" + v1 "github.com/kandev/kandev/pkg/api/v1" ) // Workspace-mode and ordering constants used across handoff plumbing. @@ -137,14 +138,15 @@ func (p WorkspacePolicy) NeedsAttachment() bool { // list_related_tasks_kandev MCP tool. Document keys are precomputed so an // agent can decide what to fetch without a follow-up list call. type RelatedTask struct { - ID string `json:"id"` - Identifier string `json:"identifier,omitempty"` - Title string `json:"title"` - State string `json:"state"` - WorkspaceID string `json:"workspace_id"` - ParentID string `json:"parent_id,omitempty"` - AssigneeLabel string `json:"assignee_label,omitempty"` - DocumentKeys []string `json:"document_keys,omitempty"` + ID string `json:"id"` + Identifier string `json:"identifier,omitempty"` + Title string `json:"title"` + State string `json:"state"` + WorkspaceID string `json:"workspace_id"` + ParentID string `json:"parent_id,omitempty"` + AssigneeLabel string `json:"assignee_label,omitempty"` + DocumentKeys []string `json:"document_keys,omitempty"` + PRs []v1.TaskPRSummary `json:"prs,omitempty"` } // RelatedTasks bundles every relation surface for a single task. diff --git a/apps/backend/pkg/api/v1/task.go b/apps/backend/pkg/api/v1/task.go index 7ef70952c5..1479ef9669 100644 --- a/apps/backend/pkg/api/v1/task.go +++ b/apps/backend/pkg/api/v1/task.go @@ -18,6 +18,18 @@ const ( TaskStateCancelled TaskState = "CANCELLED" ) +// TaskPRSummary is a compact view of a GitHub pull request associated with a +// task. Surfaced through the task-listing MCP tools so agents can reason about +// PR status. State is one of "open", "closed", "merged"; MergedAt is set only +// when the PR has merged, so agents can report when the work landed. +type TaskPRSummary struct { + Number int `json:"number"` + URL string `json:"url"` + Title string `json:"title,omitempty"` + State string `json:"state"` + MergedAt *time.Time `json:"merged_at,omitempty"` +} + // TaskSessionState represents the state of an agent session. type TaskSessionState string