From 635959f56d92e88582344fb59d48864232b23d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Mon, 24 Aug 2026 14:14:38 +0000 Subject: [PATCH] feat(plugins): expose durable pending interactions and response actions (#2922) Co-authored-by: Kandev Agent --- apps/backend/internal/backendapp/helpers.go | 14 + .../plugins_interaction_wiring_test.go | 95 ++ .../backendapp/plugins_interactions.go | 170 ++ .../backendapp/plugins_interactions_test.go | 216 +++ apps/backend/internal/backendapp/services.go | 2 +- apps/backend/internal/plugins/host.go | 10 + apps/backend/internal/plugins/host_data.go | 1 + .../internal/plugins/host_data_mappers.go | 57 + .../internal/plugins/host_data_test.go | 8 + .../internal/plugins/host_interactions.go | 375 +++++ .../plugins/host_interactions_test.go | 520 ++++++ apps/backend/internal/plugins/service.go | 32 + .../task/handlers/process_handlers_test.go | 3 + .../internal/task/models/interactions.go | 132 ++ .../internal/task/repository/interface.go | 4 + .../repository/sqlite/pending_interactions.go | 202 +++ .../pending_interactions_postgres_test.go | 107 ++ .../sqlite/pending_interactions_test.go | 300 ++++ .../task/service/service_interactions.go | 239 +++ .../task/service/service_interactions_test.go | 181 ++ apps/backend/pkg/pluginsdk/host.go | 3 + apps/backend/pkg/pluginsdk/interactions.go | 572 +++++++ .../pkg/pluginsdk/interactions_test.go | 241 +++ .../proto/kandev/plugin/v1/plugin.pb.go | 1463 ++++++++++++++--- .../proto/kandev/plugin/v1/plugin.proto | 128 ++ .../proto/kandev/plugin/v1/plugin_grpc.pb.go | 226 +++ .../0052-plugin-host-interaction-api.md | 141 ++ docs/decisions/INDEX.md | 1 + docs/plans/plugins/GRPC-CONTRACT.md | 33 + docs/public/plugins-authoring.md | 77 +- 30 files changed, 5333 insertions(+), 220 deletions(-) create mode 100644 apps/backend/internal/backendapp/plugins_interaction_wiring_test.go create mode 100644 apps/backend/internal/backendapp/plugins_interactions.go create mode 100644 apps/backend/internal/backendapp/plugins_interactions_test.go create mode 100644 apps/backend/internal/plugins/host_interactions.go create mode 100644 apps/backend/internal/plugins/host_interactions_test.go create mode 100644 apps/backend/internal/task/models/interactions.go create mode 100644 apps/backend/internal/task/repository/sqlite/pending_interactions.go create mode 100644 apps/backend/internal/task/repository/sqlite/pending_interactions_postgres_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/pending_interactions_test.go create mode 100644 apps/backend/internal/task/service/service_interactions.go create mode 100644 apps/backend/internal/task/service/service_interactions_test.go create mode 100644 apps/backend/pkg/pluginsdk/interactions.go create mode 100644 apps/backend/pkg/pluginsdk/interactions_test.go create mode 100644 docs/decisions/0052-plugin-host-interaction-api.md diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 4e86eddbad..94e4fe1484 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -1173,6 +1173,20 @@ func registerSecondaryRoutes( ) p.log.Debug("Registered Clarification handlers (HTTP)") + // Wire the plugin Host interaction write path (ADR 0052) onto the same + // orchestrator permission resolution and the same clarification resolver + // instance the REST route and the MCP handlers use, so a plugin's response + // is indistinguishable from a user's and takes the same durable claim. + // Deliberately here rather than in initOfficeServices: that returns early + // when features.office=false (the production default), while plugins run + // whenever services.Plugins is non-nil. + if p.services.Plugins != nil { + p.services.Plugins.SetInteractionResponder(pluginsInteractionResponderAdapter{ + permissions: p.orchestratorSvc, + clarifications: clarificationResolver, + }) + } + if p.secretsSvc != nil { secrets.RegisterRoutes(p.router, p.gateway.Dispatcher, p.secretsSvc, p.log) p.log.Debug("Registered Secrets handlers (HTTP + WebSocket)") diff --git a/apps/backend/internal/backendapp/plugins_interaction_wiring_test.go b/apps/backend/internal/backendapp/plugins_interaction_wiring_test.go new file mode 100644 index 0000000000..2d55997d9e --- /dev/null +++ b/apps/backend/internal/backendapp/plugins_interaction_wiring_test.go @@ -0,0 +1,95 @@ +package backendapp + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestSetInteractionResponderIsWiredOutsideOfficeGate pins the same +// startup-placement invariant TestSetWriteDepsIsWiredOutsideOfficeGate does, +// for the interaction write path. The failure mode it guards is silent: +// plugins start whenever services.Plugins is non-nil, while initOfficeServices +// returns early when features.office is false (the production default). Wiring +// the responder there would leave every default backend answering +// Unimplemented to RespondToPermission / AnswerClarification / Cancel with no +// error anywhere upstream — "the plugin just doesn't do anything". +// +// Unlike the write-deps guard this scans the whole package rather than +// main.go, because the responder is wired where the clarification handler is +// constructed. +func TestSetInteractionResponderIsWiredOutsideOfficeGate(t *testing.T) { + const wiringCall = "SetInteractionResponder" + const officeGate = "initOfficeServices" + + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("read package dir: %v", err) + } + + var callsInOfficeInit, callsElsewhere int + var foundOfficeInit bool + fset := token.NewFileSet() + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || filepath.Ext(name) != ".go" || strings.HasSuffix(name, "_test.go") { + continue + } + file, err := parser.ParseFile(fset, name, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", name, err) + } + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok { + continue + } + count := countMethodCalls(fn, wiringCall) + if fn.Name.Name == officeGate { + foundOfficeInit = true + callsInOfficeInit += count + continue + } + callsElsewhere += count + } + } + + // Without this the guard degrades silently: rename or move the + // Office-gated function and every assertion below still "passes" while + // guarding nothing. + if !foundOfficeInit { + t.Fatalf("%s not found in package backendapp; re-point this guard at the Office-gated function", officeGate) + } + if callsInOfficeInit > 0 { + t.Errorf( + "%s is called %d time(s) inside %s, which returns early when features.office=false (the "+ + "production default). Plugins start regardless of that flag, so the interaction write path "+ + "would never be wired on a default production backend. Wire it outside the Office gate.", + wiringCall, callsInOfficeInit, officeGate, + ) + } + if callsElsewhere == 0 { + t.Errorf("no %s call found outside %s; the plugin Host interaction writes would never be wired", wiringCall, officeGate) + } +} + +// countMethodCalls reports how many `.(...)` calls the function +// body contains. +func countMethodCalls(fn *ast.FuncDecl, method string) int { + count := 0 + ast.Inspect(fn, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == method { + count++ + } + return true + }) + return count +} diff --git a/apps/backend/internal/backendapp/plugins_interactions.go b/apps/backend/internal/backendapp/plugins_interactions.go new file mode 100644 index 0000000000..6a213c09e2 --- /dev/null +++ b/apps/backend/internal/backendapp/plugins_interactions.go @@ -0,0 +1,170 @@ +// plugins_interactions.go adapts kandev's first-party interaction response +// paths to the narrow interface the plugin Host interaction API needs (ADR +// 0052). It lives in backendapp for the same reason the task-write adapters +// do: internal/plugins cannot import the orchestrator or the clarification +// resolver without an import cycle. +// +// Nothing here re-implements a response. Permissions go through +// orchestrator.ResolveAgentPermission — the claim/deliver/finalize path the +// web and external-MCP surfaces share, which owns the durable claim that keeps +// two responders from both reaching the agent. Clarification bundles go +// through the same clarification.Resolver instance the REST route and the MCP +// handlers use. The adapter's only real work is translating each path's error +// vocabulary into the gRPC codes the plugin contract promises. +package backendapp + +import ( + "context" + "errors" + + "github.com/kandev/kandev/internal/clarification" + "github.com/kandev/kandev/internal/orchestrator" + "github.com/kandev/kandev/internal/plugins" + taskmodels "github.com/kandev/kandev/internal/task/models" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// permissionResolver is the orchestrator slice the permission path needs. +// ResolveAgentPermission is used rather than the RespondToPermission wrapper +// so the plugin's responses are attributed to their own source rather than +// silently recorded as web. +type permissionResolver interface { + ResolveAgentPermission( + ctx context.Context, request orchestrator.ResolveAgentPermissionRequest, + ) (*orchestrator.ResolveAgentPermissionResult, error) + // RespondToPermission is the exported wrapper that owns the cancel path; + // the orchestrator keeps its cancel helper unexported. + RespondToPermission( + ctx context.Context, taskID, sessionID, requestID, pendingID, optionID string, cancelled, rejected bool, + ) error +} + +// clarificationBundleResolver is the clarification-resolver slice the +// question-bundle path needs. +type clarificationBundleResolver interface { + ResolveBundle( + ctx context.Context, pendingID string, outcome clarification.Outcome, + ) (*clarification.Resolution, bool, error) +} + +// pluginsInteractionResponderAdapter satisfies internal/plugins' +// interactionResponder. +type pluginsInteractionResponderAdapter struct { + permissions permissionResolver + clarifications clarificationBundleResolver +} + +func (a pluginsInteractionResponderAdapter) RespondToPermission( + ctx context.Context, in plugins.PluginPermissionResponse, +) error { + if a.permissions == nil { + return status.Error(codes.Unimplemented, "permission responses are unavailable") + } + // A dismissal has no option to select, and the orchestrator keeps its + // cancel helper unexported, so route it through the exported wrapper + // rather than inventing an option id the agent never offered. + if in.Cancelled { + return permissionResolutionStatus(a.permissions.RespondToPermission( + ctx, in.TaskID, in.SessionID, in.RequestID, in.PendingID, "", true, false)) + } + _, err := a.permissions.ResolveAgentPermission(ctx, orchestrator.ResolveAgentPermissionRequest{ + TaskID: in.TaskID, + SessionID: in.SessionID, + RequestID: in.RequestID, + PendingID: in.PendingID, + OptionID: in.OptionID, + Source: taskmodels.PermissionSourceAutomation, + }) + return permissionResolutionStatus(err) +} + +func (a pluginsInteractionResponderAdapter) AnswerClarification( + ctx context.Context, pendingID string, answers []plugins.PluginClarificationAnswer, +) error { + if a.clarifications == nil { + return status.Error(codes.Unimplemented, "clarification responses are unavailable") + } + outcome := clarification.Outcome{Answers: make([]clarification.Answer, len(answers))} + for i, answer := range answers { + outcome.Answers[i] = clarification.Answer{ + QuestionID: answer.QuestionID, + SelectedOptions: answer.SelectedOptions, + CustomText: answer.CustomText, + } + } + return a.resolveBundle(ctx, pendingID, outcome) +} + +// DeclineClarification routes a cancel through the resolver's REJECT outcome +// rather than the operator cancel route. Cancel needs the in-memory pending +// entry, so it only works while the original waiter is still parked; the +// reject outcome is durable-claim based and therefore also settles a bundle +// whose waiter went away in a restart, which is precisely the case a +// reconciling plugin is holding. +func (a pluginsInteractionResponderAdapter) DeclineClarification( + ctx context.Context, pendingID, reason string, +) error { + if a.clarifications == nil { + return status.Error(codes.Unimplemented, "clarification responses are unavailable") + } + return a.resolveBundle(ctx, pendingID, clarification.Outcome{ + Rejected: true, + RejectReason: reason, + }) +} + +// resolveBundle maps the resolver's outcome onto the plugin contract. A +// non-claimed resolution means another responder settled the bundle first, +// which is the same FailedPrecondition the host returns when it detects the +// terminal state itself — so a plugin branches on one code however the race +// was caught. +func (a pluginsInteractionResponderAdapter) resolveBundle( + ctx context.Context, pendingID string, outcome clarification.Outcome, +) error { + _, claimed, err := a.clarifications.ResolveBundle(ctx, pendingID, outcome) + if err != nil { + return clarificationResolutionStatus(err) + } + if !claimed { + return status.Errorf(codes.FailedPrecondition, + "clarification %s was already resolved by another responder", pendingID) + } + return nil +} + +func clarificationResolutionStatus(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, clarification.ErrBundleNotFound): + return status.Error(codes.NotFound, err.Error()) + case clarification.IsValidationError(err): + return status.Error(codes.InvalidArgument, err.Error()) + default: + return status.Error(codes.Internal, err.Error()) + } +} + +// permissionResolutionStatus maps the orchestrator's permission vocabulary +// onto the plugin contract. Everything that means "this interaction can no +// longer take the response you submitted" collapses to FailedPrecondition, +// which is what terminal-once promises; an unknown identity is NotFound, and +// an option the agent never offered is the caller's mistake. +func permissionResolutionStatus(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, orchestrator.ErrPermissionNotFound), + errors.Is(err, orchestrator.ErrPermissionTaskOrSessionNotFound): + return status.Error(codes.NotFound, err.Error()) + case errors.Is(err, orchestrator.ErrPermissionOptionNotOffered): + return status.Error(codes.InvalidArgument, err.Error()) + case errors.Is(err, orchestrator.ErrPermissionAlreadyResolved), + errors.Is(err, orchestrator.ErrPermissionStale), + errors.Is(err, orchestrator.ErrPermissionResolutionInProgress): + return status.Error(codes.FailedPrecondition, err.Error()) + default: + return err + } +} diff --git a/apps/backend/internal/backendapp/plugins_interactions_test.go b/apps/backend/internal/backendapp/plugins_interactions_test.go new file mode 100644 index 0000000000..312abd8974 --- /dev/null +++ b/apps/backend/internal/backendapp/plugins_interactions_test.go @@ -0,0 +1,216 @@ +package backendapp + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/clarification" + "github.com/kandev/kandev/internal/orchestrator" + "github.com/kandev/kandev/internal/plugins" + taskmodels "github.com/kandev/kandev/internal/task/models" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type recordingPermissionResolver struct { + request orchestrator.ResolveAgentPermissionRequest + cancelled bool + err error +} + +func (r *recordingPermissionResolver) ResolveAgentPermission( + _ context.Context, request orchestrator.ResolveAgentPermissionRequest, +) (*orchestrator.ResolveAgentPermissionResult, error) { + r.request = request + if r.err != nil { + return nil, r.err + } + return &orchestrator.ResolveAgentPermissionResult{PendingID: request.PendingID}, nil +} + +func (r *recordingPermissionResolver) RespondToPermission( + _ context.Context, taskID, sessionID, requestID, pendingID, optionID string, cancelled, _ bool, +) error { + r.request = orchestrator.ResolveAgentPermissionRequest{ + TaskID: taskID, SessionID: sessionID, RequestID: requestID, + PendingID: pendingID, OptionID: optionID, + } + r.cancelled = cancelled + return r.err +} + +type recordingBundleResolver struct { + pendingID string + outcome clarification.Outcome + claimed bool + err error +} + +func (r *recordingBundleResolver) ResolveBundle( + _ context.Context, pendingID string, outcome clarification.Outcome, +) (*clarification.Resolution, bool, error) { + r.pendingID, r.outcome = pendingID, outcome + if r.err != nil { + return nil, false, r.err + } + return &clarification.Resolution{}, r.claimed, nil +} + +func TestPluginsInteractionResponderForwardsPermissionIdentity(t *testing.T) { + permissions := &recordingPermissionResolver{} + adapter := pluginsInteractionResponderAdapter{permissions: permissions} + + if err := adapter.RespondToPermission(context.Background(), plugins.PluginPermissionResponse{ + TaskID: "task-1", SessionID: "session-1", RequestID: "request-1", + PendingID: "pending-1", OptionID: "deny", + }); err != nil { + t.Fatalf("RespondToPermission: %v", err) + } + if permissions.request.TaskID != "task-1" || permissions.request.SessionID != "session-1" || + permissions.request.RequestID != "request-1" || permissions.request.PendingID != "pending-1" || + permissions.request.OptionID != "deny" { + t.Fatalf("resolver saw %+v", permissions.request) + } + // A plugin response must not be recorded as a browser one: the audit trail + // distinguishes sources, and mislabelling it would make a plugin's action + // indistinguishable from a person's after the fact. + if permissions.request.Source == taskmodels.PermissionSourceWeb { + t.Fatalf("source = %q, want a non-web source for a plugin response", permissions.request.Source) + } +} + +// TestPluginsInteractionResponderDismissalUsesCancelPath keeps a dismissal off +// the option path: there is no option to select, and inventing one would +// report an outcome the agent never offered. +func TestPluginsInteractionResponderDismissalUsesCancelPath(t *testing.T) { + permissions := &recordingPermissionResolver{} + adapter := pluginsInteractionResponderAdapter{permissions: permissions} + + if err := adapter.RespondToPermission(context.Background(), plugins.PluginPermissionResponse{ + TaskID: "task-1", SessionID: "session-1", RequestID: "request-1", + PendingID: "pending-1", Cancelled: true, + }); err != nil { + t.Fatalf("RespondToPermission: %v", err) + } + if !permissions.cancelled { + t.Fatal("dismissal did not reach the cancel path") + } + if permissions.request.OptionID != "" { + t.Fatalf("option = %q, want empty on a dismissal", permissions.request.OptionID) + } +} + +func TestPluginsInteractionResponderMapsPermissionOutcomes(t *testing.T) { + cases := []struct { + name string + in error + want codes.Code + }{ + {"unknown permission", orchestrator.ErrPermissionNotFound, codes.NotFound}, + {"unknown task or session", orchestrator.ErrPermissionTaskOrSessionNotFound, codes.NotFound}, + {"option never offered", orchestrator.ErrPermissionOptionNotOffered, codes.InvalidArgument}, + // Everything that means "this can no longer take your response" + // collapses to the one code terminal-once promises. + {"already resolved", orchestrator.ErrPermissionAlreadyResolved, codes.FailedPrecondition}, + {"stale", orchestrator.ErrPermissionStale, codes.FailedPrecondition}, + {"another responder mid-flight", orchestrator.ErrPermissionResolutionInProgress, codes.FailedPrecondition}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + adapter := pluginsInteractionResponderAdapter{ + permissions: &recordingPermissionResolver{err: tc.in}, + } + err := adapter.RespondToPermission(context.Background(), plugins.PluginPermissionResponse{ + PendingID: "pending-1", OptionID: "allow", + }) + if got := status.Code(err); got != tc.want { + t.Fatalf("code = %v (%v), want %v", got, err, tc.want) + } + }) + } +} + +func TestPluginsInteractionResponderForwardsClarificationAnswers(t *testing.T) { + resolver := &recordingBundleResolver{claimed: true} + adapter := pluginsInteractionResponderAdapter{clarifications: resolver} + + err := adapter.AnswerClarification(context.Background(), "pending-2", []plugins.PluginClarificationAnswer{ + {QuestionID: "q1", SelectedOptions: []string{"a"}, CustomText: "note"}, + }) + if err != nil { + t.Fatalf("AnswerClarification: %v", err) + } + if resolver.pendingID != "pending-2" || len(resolver.outcome.Answers) != 1 { + t.Fatalf("resolver saw %q / %+v", resolver.pendingID, resolver.outcome) + } + if resolver.outcome.Rejected { + t.Fatal("an answer must not be delivered as a rejection") + } +} + +// TestPluginsInteractionResponderDeclineUsesRejectOutcome pins the deliberate +// routing choice: the operator cancel route needs the in-memory pending entry +// and therefore fails after a restart, while the reject outcome is +// durable-claim based. A plugin reconciling a bundle whose waiter went away is +// exactly the caller that needs the second one. +func TestPluginsInteractionResponderDeclineUsesRejectOutcome(t *testing.T) { + resolver := &recordingBundleResolver{claimed: true} + adapter := pluginsInteractionResponderAdapter{clarifications: resolver} + + if err := adapter.DeclineClarification(context.Background(), "pending-3", "user stepped away"); err != nil { + t.Fatalf("DeclineClarification: %v", err) + } + if !resolver.outcome.Rejected || resolver.outcome.RejectReason != "user stepped away" { + t.Fatalf("decline outcome = %+v", resolver.outcome) + } + if len(resolver.outcome.Answers) != 0 { + t.Fatalf("decline carried answers: %+v", resolver.outcome.Answers) + } +} + +// TestPluginsInteractionResponderLostClaimIsFailedPrecondition covers the race +// the resolver reports as a non-claimed resolution: another responder settled +// the bundle first. It must read as the same code the host returns when it +// catches the terminal state itself. +func TestPluginsInteractionResponderLostClaimIsFailedPrecondition(t *testing.T) { + adapter := pluginsInteractionResponderAdapter{ + clarifications: &recordingBundleResolver{claimed: false}, + } + err := adapter.DeclineClarification(context.Background(), "pending-4", "") + if got := status.Code(err); got != codes.FailedPrecondition { + t.Fatalf("code = %v (%v), want FailedPrecondition", got, err) + } +} + +func TestPluginsInteractionResponderMapsClarificationOutcomes(t *testing.T) { + adapter := pluginsInteractionResponderAdapter{ + clarifications: &recordingBundleResolver{err: clarification.ErrBundleNotFound}, + } + if got := status.Code(adapter.DeclineClarification(context.Background(), "pending-5", "")); got != codes.NotFound { + t.Fatalf("unknown bundle code = %v, want NotFound", got) + } + + sentinel := errors.New("transport exploded") + adapter = pluginsInteractionResponderAdapter{ + clarifications: &recordingBundleResolver{err: sentinel}, + } + if got := status.Code(adapter.DeclineClarification(context.Background(), "pending-6", "")); got != codes.Internal { + t.Fatalf("unexpected failure code = %v, want Internal", got) + } +} + +func TestPluginsInteractionResponderUnwiredIsUnimplemented(t *testing.T) { + adapter := pluginsInteractionResponderAdapter{} + ctx := context.Background() + + if got := status.Code(adapter.RespondToPermission(ctx, plugins.PluginPermissionResponse{})); got != codes.Unimplemented { + t.Fatalf("permission code = %v", got) + } + if got := status.Code(adapter.AnswerClarification(ctx, "p", nil)); got != codes.Unimplemented { + t.Fatalf("answer code = %v", got) + } + if got := status.Code(adapter.DeclineClarification(ctx, "p", "")); got != codes.Unimplemented { + t.Fatalf("decline code = %v", got) + } +} diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index 764e65cb3c..5a24bac9c2 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -222,7 +222,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories // caller; without it the check stays a no-op. An un-stamped local // build passes "dev", which the service treats as "don't enforce". pluginsSvc.SetKandevVersion(version) - pluginsSvc.SetDataSources(taskSvc, taskSvc, workflowSvc, agentSettingsController, analyticsservice.New(repos.Analytics), taskSvc, pluginsTaskWriterAdapter{svc: taskSvc}) + pluginsSvc.SetDataSources(taskSvc, taskSvc, workflowSvc, agentSettingsController, analyticsservice.New(repos.Analytics), taskSvc, taskSvc, pluginsTaskWriterAdapter{svc: taskSvc}) } gitCredentialBroker := newGitCredentialBroker(githubSvc, pluginsSvc, repos.Task, cfg.GitHubCredentialBroker.ReissueSigningKey) if pluginsSvc != nil { diff --git a/apps/backend/internal/plugins/host.go b/apps/backend/internal/plugins/host.go index 8ef91e11d3..323f6417ff 100644 --- a/apps/backend/internal/plugins/host.go +++ b/apps/backend/internal/plugins/host.go @@ -67,6 +67,7 @@ type pluginHost struct { agentProfiles agentProfileDataSource sessionCodeStats sessionCodeStatsSource messageData messageDataSource + interactionData interactionDataSource // taskWriter backs the CreateTask/UpdateTask write RPCs (ADR 0043 // phase 2, capability api_write:tasks). Wired via SetDataSources like the @@ -81,6 +82,15 @@ type pluginHost struct { // utilityDeps. nil on a bare test host. See host_write.go. writeDeps func() (taskMessenger, taskStarter) + // interactionDeps returns the live interaction responder behind the + // api_write:interactions RPCs (ADR 0052). Read live, not snapshotted at + // hostForPlugin time, for the same reason as writeDeps: the orchestrator + // and the clarification handler are both constructed after boot-active + // plugins spawn, so a snapshot would strand those hosts with a nil + // responder for their whole lifetime. nil on a bare test host. + // See host_interactions.go. + interactionDeps func() interactionResponder + // utilityDeps returns the live utility-agent dependencies (ADR 0048) at // call time rather than a spawn-time snapshot. hostUtilityMgr is // constructed late in boot — after StartActivePlugins has already spawned diff --git a/apps/backend/internal/plugins/host_data.go b/apps/backend/internal/plugins/host_data.go index 182b0f45b1..57a0b3787c 100644 --- a/apps/backend/internal/plugins/host_data.go +++ b/apps/backend/internal/plugins/host_data.go @@ -44,6 +44,7 @@ const ( resourceExecutorProfiles = "executor_profiles" resourceRepositories = "repositories" resourceMessages = "messages" + resourceInteractions = "interactions" ) // apiReadCapability formats resource as the api_read: capability diff --git a/apps/backend/internal/plugins/host_data_mappers.go b/apps/backend/internal/plugins/host_data_mappers.go index a117e5cf1a..92600a179d 100644 --- a/apps/backend/internal/plugins/host_data_mappers.go +++ b/apps/backend/internal/plugins/host_data_mappers.go @@ -244,3 +244,60 @@ func sessionCodeStatsModelToDTO(s *analyticsmodels.SessionCodeStats) pluginsdk.S } return dto } + +// interactionModelToDTO converts the assembled durable interaction into the +// public plugin DTO. Timestamps become RFC3339 UTC strings like every other +// Host DTO. The permission title is the agent's prompt text, which the +// message row stores as its content — it is agent-authored, not a +// kandev-injected system block, so it needs no sanitization pass. +func interactionModelToDTO(i *taskmodels.Interaction) pluginsdk.Interaction { + return pluginsdk.Interaction{ + ID: i.ID, + Kind: string(i.Kind), + TaskID: i.TaskID, + SessionID: i.SessionID, + TurnID: i.TurnID, + Status: string(i.Status), + Title: i.Title, + Context: i.Context, + ToolCallID: i.ToolCallID, + ActionType: i.ActionType, + Options: interactionOptionsToDTOs(i.Options), + Questions: interactionQuestionsToDTOs(i.Questions), + AgentDisconnected: i.AgentDisconnected, + CreatedAt: i.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: i.UpdatedAt.UTC().Format(time.RFC3339), + } +} + +func interactionOptionsToDTOs(options []taskmodels.InteractionOption) []pluginsdk.InteractionOption { + if len(options) == 0 { + return nil + } + out := make([]pluginsdk.InteractionOption, len(options)) + for i, option := range options { + out[i] = pluginsdk.InteractionOption{ + OptionID: option.ID, + Label: option.Label, + Kind: option.Kind, + Description: option.Description, + } + } + return out +} + +func interactionQuestionsToDTOs(questions []taskmodels.InteractionQuestion) []pluginsdk.InteractionQuestion { + if len(questions) == 0 { + return nil + } + out := make([]pluginsdk.InteractionQuestion, len(questions)) + for i, question := range questions { + out[i] = pluginsdk.InteractionQuestion{ + ID: question.ID, + Title: question.Title, + Prompt: question.Prompt, + Options: interactionOptionsToDTOs(question.Options), + } + } + return out +} diff --git a/apps/backend/internal/plugins/host_data_test.go b/apps/backend/internal/plugins/host_data_test.go index 2ad79b1764..5334b8ce85 100644 --- a/apps/backend/internal/plugins/host_data_test.go +++ b/apps/backend/internal/plugins/host_data_test.go @@ -275,6 +275,9 @@ type testDataHost struct { taskWriter *fakeTaskWriter messenger *fakeMessenger starter *fakeTaskStarter + + interactions *fakeInteractionDataSource + responder *fakeInteractionResponder } // newTestDataHost builds a fully-wired pluginHost (every Host data API @@ -293,6 +296,9 @@ func newTestDataHost(caps manifest.Capabilities) *testDataHost { taskWriter: &fakeTaskWriter{}, messenger: &fakeMessenger{}, starter: &fakeTaskStarter{}, + + interactions: &fakeInteractionDataSource{}, + responder: &fakeInteractionResponder{}, } d.host = &pluginHost{ pluginID: "p1", @@ -303,6 +309,7 @@ func newTestDataHost(caps manifest.Capabilities) *testDataHost { agentProfiles: d.profiles, sessionCodeStats: d.codeStats, messageData: d.messages, + interactionData: d.interactions, taskWriter: d.taskWriter, configs: &fakeConfigReader{configs: map[string]any{utilityAgentConfigKey: "utility-agent-42"}}, utilityDeps: func() (utilityAgentSource, utilityRunner) { @@ -311,6 +318,7 @@ func newTestDataHost(caps manifest.Capabilities) *testDataHost { writeDeps: func() (taskMessenger, taskStarter) { return d.messenger, d.starter }, + interactionDeps: func() interactionResponder { return d.responder }, } return d } diff --git a/apps/backend/internal/plugins/host_interactions.go b/apps/backend/internal/plugins/host_interactions.go new file mode 100644 index 0000000000..bc8f7c47e9 --- /dev/null +++ b/apps/backend/internal/plugins/host_interactions.go @@ -0,0 +1,375 @@ +// host_interactions.go implements pluginHost's Interactions() accessor — the +// Host interaction API (ADR 0052). Reads are gated on api_read:interactions, +// writes on api_write:interactions; the two gate independently, so an +// attention or inbox plugin can declare read-only and still reconcile. +// +// The gate cannot live at the accessor (like Tasks() and Messages(), this one +// mixes reads and writes), so every method checks its own capability. +// +// Reads come from the durable request record through the task service — the +// same authority kandev's own pending-action projection uses, never a +// repository directly. Writes route through the first-party services the +// native UI drives (the orchestrator for permissions, the clarification +// handler for question bundles), so the agent actually unblocks and every +// surface converges through the normal events. Both dependencies arrive +// through narrow interfaces satisfied by backendapp adapters, because the +// service types cannot be referenced here without an import cycle. +package plugins + +import ( + "context" + "fmt" + "strings" + + taskmodels "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/pkg/pluginsdk" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// interactionDataSource is the narrow slice of internal/task/service.Service +// the interaction reads need. GetInteraction resolves ANY interaction, +// terminal ones included — that is what makes a plugin's event cache +// reconcilable — and returns (nil, nil) for an unknown id. +type interactionDataSource interface { + ListPendingInteractions(ctx context.Context, filter taskmodels.PendingInteractionFilter) ([]*taskmodels.Interaction, error) + GetInteraction(ctx context.Context, pendingID string) (*taskmodels.Interaction, error) +} + +// PluginPermissionResponse is the plugins-local permission response a +// responder adapter translates into the orchestrator's resolution request. +// Kandev's permission resolution is keyed on the full (task, session, request, +// pending) identity, and every field is taken from the durable record rather +// than from the plugin. +type PluginPermissionResponse struct { + TaskID string + SessionID string + RequestID string + PendingID string + OptionID string + Cancelled bool +} + +// PluginClarificationAnswer is the plugins-local answer a responder adapter +// translates into the clarification resolver's outcome. +type PluginClarificationAnswer struct { + QuestionID string + SelectedOptions []string + CustomText string +} + +// interactionResponder is the narrow write path, adapted by backendapp over +// the orchestrator and the clarification handler. Implementations MUST return +// gRPC status errors so the outcome vocabulary a plugin sees (NotFound for an +// unknown id, FailedPrecondition for an already-resolved one, InvalidArgument +// for a malformed answer set) is decided by the layer that knows the +// first-party service's failure modes rather than by string matching here. +type interactionResponder interface { + RespondToPermission(ctx context.Context, in PluginPermissionResponse) error + AnswerClarification(ctx context.Context, pendingID string, answers []PluginClarificationAnswer) error + DeclineClarification(ctx context.Context, pendingID, reason string) error +} + +// Interactions returns the interaction accessor. Like Tasks() and Messages() +// it mixes independently-gated reads and writes, so the capability check lives +// on each method rather than here. +func (h *pluginHost) Interactions() pluginsdk.InteractionAccessor { + return interactionReader{host: h} +} + +var _ pluginsdk.InteractionHost = (*pluginHost)(nil) + +type interactionReader struct{ host *pluginHost } + +func (r interactionReader) ListPending( + ctx context.Context, filter pluginsdk.InteractionFilter, page pluginsdk.Page, +) ([]pluginsdk.Interaction, *pluginsdk.PageInfo, error) { + if !r.host.capabilities.CanRead(resourceInteractions) { + return nil, nil, permissionDenied(apiReadCapability(resourceInteractions)) + } + if r.host.interactionData == nil { + return r.host.UnimplementedHostData.Interactions().ListPending(ctx, filter, page) + } + if err := validateInteractionKinds(filter.Kinds); err != nil { + return nil, nil, err + } + // Each session/task/kind value becomes its own SQL bind parameter; cap the + // combined count for the same reason messageReader.List does, so a large + // filter fails fast instead of hitting the host-parameter limit. + if n := len(filter.SessionIDs) + len(filter.TaskIDs) + len(filter.Kinds); n > maxMessageFilterValues { + return nil, nil, invalidArgument(fmt.Sprintf( + "interaction filter has %d session/task/kind values, max %d", n, maxMessageFilterValues)) + } + interactions, err := r.host.interactionData.ListPendingInteractions(ctx, taskmodels.PendingInteractionFilter{ + SessionIDs: filter.SessionIDs, + TaskIDs: filter.TaskIDs, + Kinds: filter.Kinds, + }) + if err != nil { + return nil, nil, err + } + items, info := paginate(interactionsToDTOs(interactions), page) + return items, info, nil +} + +// Get returns a gRPC NotFound error (not a (nil, nil) success) for an id that +// resolves to nothing, so the in-process contract matches what a plugin +// observes over the wire. +func (r interactionReader) Get(ctx context.Context, id string) (*pluginsdk.Interaction, error) { + if !r.host.capabilities.CanRead(resourceInteractions) { + return nil, permissionDenied(apiReadCapability(resourceInteractions)) + } + if r.host.interactionData == nil { + return r.host.UnimplementedHostData.Interactions().Get(ctx, id) + } + interaction, err := r.host.resolveInteraction(ctx, id) + if err != nil { + return nil, err + } + dto := interactionModelToDTO(interaction) + return &dto, nil +} + +func (r interactionReader) RespondToPermission( + ctx context.Context, in pluginsdk.PermissionResponse, +) (*pluginsdk.Interaction, error) { + responder, err := r.host.interactionWriteTarget() + if err != nil { + return nil, err + } + if responder == nil { + return r.host.UnimplementedHostData.Interactions().RespondToPermission(ctx, in) + } + interaction, err := r.host.answerableInteraction(ctx, in.InteractionID, taskmodels.InteractionKindPermission) + if err != nil { + return nil, err + } + optionID, rejected, err := resolvePermissionChoice(interaction, in) + if err != nil { + return nil, err + } + if err := responder.RespondToPermission(ctx, PluginPermissionResponse{ + TaskID: interaction.TaskID, + SessionID: interaction.SessionID, + RequestID: interaction.RequestID, + PendingID: interaction.ID, + OptionID: optionID, + Cancelled: in.Cancelled, + }); err != nil { + return nil, err + } + // The orchestrator records the same derivation: a cancelled or reject-kind + // response is rejected, anything else approved. + resolved := taskmodels.InteractionStatusApproved + if rejected || in.Cancelled { + resolved = taskmodels.InteractionStatusRejected + } + return r.host.reloadInteraction(ctx, interaction, resolved) +} + +func (r interactionReader) AnswerClarification( + ctx context.Context, in pluginsdk.ClarificationResponse, +) (*pluginsdk.Interaction, error) { + responder, err := r.host.interactionWriteTarget() + if err != nil { + return nil, err + } + if responder == nil { + return r.host.UnimplementedHostData.Interactions().AnswerClarification(ctx, in) + } + interaction, err := r.host.answerableInteraction(ctx, in.InteractionID, taskmodels.InteractionKindClarification) + if err != nil { + return nil, err + } + if len(in.Answers) == 0 { + return nil, invalidArgument("answers is required") + } + answers := make([]PluginClarificationAnswer, len(in.Answers)) + for i, answer := range in.Answers { + if answer.QuestionID == "" { + return nil, invalidArgument(fmt.Sprintf("answer %d is missing question_id", i+1)) + } + answers[i] = PluginClarificationAnswer{ + QuestionID: answer.QuestionID, + SelectedOptions: answer.SelectedOptions, + CustomText: answer.CustomText, + } + } + if err := responder.AnswerClarification(ctx, interaction.ID, answers); err != nil { + return nil, err + } + return r.host.reloadInteraction(ctx, interaction, taskmodels.InteractionStatusAnswered) +} + +func (r interactionReader) CancelClarification( + ctx context.Context, id, reason string, +) (*pluginsdk.Interaction, error) { + responder, err := r.host.interactionWriteTarget() + if err != nil { + return nil, err + } + if responder == nil { + return r.host.UnimplementedHostData.Interactions().CancelClarification(ctx, id, reason) + } + interaction, err := r.host.answerableInteraction(ctx, id, taskmodels.InteractionKindClarification) + if err != nil { + return nil, err + } + if err := responder.DeclineClarification(ctx, interaction.ID, reason); err != nil { + return nil, err + } + // Cancel is delivered as a decline of the bundle, so its terminal status is + // rejected rather than cancelled (see the DeclineClarification adapter). + return r.host.reloadInteraction(ctx, interaction, taskmodels.InteractionStatusRejected) +} + +// interactionWriteTarget performs the checks every write shares: the +// api_write:interactions gate, and both halves of the write path being wired +// (the responder that delivers, and the reader that resolves the target and +// its post-write state). Returns a nil responder with a nil error when the +// host is unwired, so the caller can answer through its OWN Unimplemented +// counterpart rather than borrowing an unrelated method's error. +func (h *pluginHost) interactionWriteTarget() (interactionResponder, error) { + if !h.capabilities.CanWrite(resourceInteractions) { + return nil, permissionDenied(apiWriteCapability(resourceInteractions)) + } + responder := h.interactionResponder() + if responder == nil || h.interactionData == nil { + return nil, nil + } + return responder, nil +} + +func (h *pluginHost) interactionResponder() interactionResponder { + if h.interactionDeps == nil { + return nil + } + return h.interactionDeps() +} + +// answerableInteraction resolves id and enforces the terminal-once contract: +// an unknown id is NotFound, an interaction of the wrong kind or one that +// already carries a resolution is FailedPrecondition. Refusing the second +// response is the point — dispatching it would either fail deep inside the +// agent transport or, worse, answer a DIFFERENT request that reused the +// pending slot. +func (h *pluginHost) answerableInteraction( + ctx context.Context, id string, kind taskmodels.InteractionKind, +) (*taskmodels.Interaction, error) { + interaction, err := h.resolveInteraction(ctx, id) + if err != nil { + return nil, err + } + if interaction.Kind != kind { + return nil, status.Errorf(codes.FailedPrecondition, + "interaction %q is a %s request, not a %s request", id, interaction.Kind, kind) + } + if interaction.Status.IsTerminal() { + return nil, status.Errorf(codes.FailedPrecondition, + "interaction %q is already %s", id, interaction.Status) + } + return interaction, nil +} + +func (h *pluginHost) resolveInteraction(ctx context.Context, id string) (*taskmodels.Interaction, error) { + if id == "" { + return nil, invalidArgument("id is required") + } + interaction, err := h.interactionData.GetInteraction(ctx, id) + if err != nil { + return nil, err + } + if interaction == nil { + return nil, interactionNotFound(id) + } + return interaction, nil +} + +// reloadInteraction returns the interaction's post-write state. The write +// already succeeded, so a failed re-read must not turn a delivered response +// into an error the caller would retry. +// +// The fallback stamps resolved on the pre-write snapshot rather than handing +// it back untouched: the contract says every response method returns the +// interaction in its NEW terminal state, and a snapshot still reading +// "pending" invites exactly the retry of an already-delivered response that +// terminal-once exists to prevent. +func (h *pluginHost) reloadInteraction( + ctx context.Context, before *taskmodels.Interaction, resolved taskmodels.InteractionStatus, +) (*pluginsdk.Interaction, error) { + reloaded, err := h.interactionData.GetInteraction(ctx, before.ID) + if err == nil && reloaded != nil { + dto := interactionModelToDTO(reloaded) + return &dto, nil + } + fallback := *before + fallback.Status = resolved + dto := interactionModelToDTO(&fallback) + return &dto, nil +} + +// resolvePermissionChoice validates the plugin's choice against the options +// the agent actually offered and derives the approve/deny outcome from the +// chosen option's kind. A plugin therefore cannot report an outcome that was +// never on the menu, and kandev never has to trust a plugin-supplied verdict. +func resolvePermissionChoice( + interaction *taskmodels.Interaction, in pluginsdk.PermissionResponse, +) (string, bool, error) { + if in.Cancelled { + if in.OptionID != "" { + return "", false, invalidArgument("option_id must be empty when cancelled is set") + } + // A dismissal is not an explicit deny. The orchestrator already + // records cancelled responses as rejected; passing rejected too would + // conflate the two outcomes the message status deliberately splits on. + return "", false, nil + } + if in.OptionID == "" { + return "", false, invalidArgument("option_id is required when cancelled is not set") + } + for _, option := range interaction.Options { + if option.ID != in.OptionID { + continue + } + return option.ID, strings.HasPrefix(option.Kind, permissionRejectKindPrefix), nil + } + return "", false, invalidArgument(fmt.Sprintf( + "option_id %q is not one of interaction %q's options", in.OptionID, interaction.ID)) +} + +// permissionRejectKindPrefix matches ACP's reject_once / reject_always option +// kinds. Prefix-matching rather than an exact set keeps a future reject_* kind +// denying by default instead of silently approving. +const permissionRejectKindPrefix = "reject" + +func validateInteractionKinds(kinds []string) error { + for _, kind := range kinds { + switch taskmodels.InteractionKind(kind) { + case taskmodels.InteractionKindPermission, taskmodels.InteractionKindClarification: + default: + return invalidArgument(fmt.Sprintf("unknown interaction kind %q", kind)) + } + } + return nil +} + +func interactionNotFound(id string) error { + return status.Errorf(codes.NotFound, "interaction %q not found", id) +} + +// ── Denied reader ─────────────────────────────────────────────────────────── +// +// Unlike the read-only resources there is no denied stub here: reads and +// writes gate independently on the same accessor, so each method returns its +// own denial naming the capability it needed. + +func interactionsToDTOs(interactions []*taskmodels.Interaction) []pluginsdk.Interaction { + out := make([]pluginsdk.Interaction, 0, len(interactions)) + for _, interaction := range interactions { + if interaction == nil { + continue + } + out = append(out, interactionModelToDTO(interaction)) + } + return out +} diff --git a/apps/backend/internal/plugins/host_interactions_test.go b/apps/backend/internal/plugins/host_interactions_test.go new file mode 100644 index 0000000000..abe3770aa5 --- /dev/null +++ b/apps/backend/internal/plugins/host_interactions_test.go @@ -0,0 +1,520 @@ +package plugins + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/kandev/kandev/internal/plugins/manifest" + taskmodels "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/pkg/pluginsdk" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// ── fakes ─────────────────────────────────────────────────────────────── + +type fakeInteractionDataSource struct { + pending []*taskmodels.Interaction + byID map[string]*taskmodels.Interaction + listErr error + getErr error + lastFilter taskmodels.PendingInteractionFilter + getCalls int + afterWrite *taskmodels.Interaction + writeCalled *bool +} + +func (f *fakeInteractionDataSource) ListPendingInteractions( + _ context.Context, filter taskmodels.PendingInteractionFilter, +) ([]*taskmodels.Interaction, error) { + f.lastFilter = filter + return f.pending, f.listErr +} + +func (f *fakeInteractionDataSource) GetInteraction( + _ context.Context, pendingID string, +) (*taskmodels.Interaction, error) { + f.getCalls++ + if f.getErr != nil { + return nil, f.getErr + } + // After a successful write the host re-reads to report the new terminal + // state; serve the post-write snapshot on that second read. + if f.writeCalled != nil && *f.writeCalled && f.afterWrite != nil { + return f.afterWrite, nil + } + return f.byID[pendingID], nil +} + +type recordedPermissionResponse struct { + in PluginPermissionResponse +} + +type fakeInteractionResponder struct { + permission *recordedPermissionResponse + answers []PluginClarificationAnswer + answered string + declined string + reason string + err error + writeCalled bool +} + +func (f *fakeInteractionResponder) RespondToPermission( + _ context.Context, in PluginPermissionResponse, +) error { + if f.err != nil { + return f.err + } + f.permission = &recordedPermissionResponse{in: in} + f.writeCalled = true + return nil +} + +func (f *fakeInteractionResponder) AnswerClarification( + _ context.Context, pendingID string, answers []PluginClarificationAnswer, +) error { + if f.err != nil { + return f.err + } + f.answered, f.answers, f.writeCalled = pendingID, answers, true + return nil +} + +func (f *fakeInteractionResponder) DeclineClarification( + _ context.Context, pendingID, reason string, +) error { + if f.err != nil { + return f.err + } + f.declined, f.reason, f.writeCalled = pendingID, reason, true + return nil +} + +func pendingPermissionInteraction() *taskmodels.Interaction { + return &taskmodels.Interaction{ + ID: "pending-1", Kind: taskmodels.InteractionKindPermission, + TaskID: "task-1", SessionID: "session-1", TurnID: "turn-1", + Status: taskmodels.InteractionStatusPending, RequestID: "request-1", Title: "Run a command?", + Options: []taskmodels.InteractionOption{ + {ID: "allow", Label: "Allow once", Kind: "allow_once"}, + {ID: "deny", Label: "Deny", Kind: "reject_once"}, + }, + CreatedAt: time.Date(2026, time.August, 21, 12, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, time.August, 21, 12, 0, 0, 0, time.UTC), + } +} + +func pendingClarificationInteraction() *taskmodels.Interaction { + return &taskmodels.Interaction{ + ID: "pending-2", Kind: taskmodels.InteractionKindClarification, + TaskID: "task-2", SessionID: "session-2", TurnID: "turn-2", + Status: taskmodels.InteractionStatusPending, + Questions: []taskmodels.InteractionQuestion{{ID: "q1", Prompt: "Which?"}}, + } +} + +func withInteraction(d *testDataHost, interaction *taskmodels.Interaction) { + d.interactions.byID = map[string]*taskmodels.Interaction{interaction.ID: interaction} + d.interactions.writeCalled = &d.responder.writeCalled +} + +func readWriteCaps() manifest.Capabilities { + return manifest.Capabilities{APIRead: []string{"interactions"}, APIWrite: []string{"interactions"}} +} + +func assertCode(t *testing.T, err error, want codes.Code) { + t.Helper() + if err == nil { + t.Fatalf("err = nil, want %v", want) + } + if got := status.Code(err); got != want { + t.Fatalf("code = %v (%v), want %v", got, err, want) + } +} + +// ── capability gating ─────────────────────────────────────────────────── + +func TestPluginHost_Interactions_ReadDeniedWithoutCapability(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"interactions"}}) + + _, _, err := d.host.Interactions().ListPending(context.Background(), pluginsdk.InteractionFilter{}, pluginsdk.Page{}) + assertPermissionDenied(t, err, "api_read:interactions") + + _, err = d.host.Interactions().Get(context.Background(), "pending-1") + assertPermissionDenied(t, err, "api_read:interactions") +} + +// TestPluginHost_Interactions_WriteDeniedWithReadOnly is the case an inbox +// plugin depends on: read-only really is read-only, so declaring +// api_read:interactions never smuggles in the ability to answer on the user's +// behalf. +func TestPluginHost_Interactions_WriteDeniedWithReadOnly(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"interactions"}}) + withInteraction(d, pendingPermissionInteraction()) + ctx := context.Background() + + _, err := d.host.Interactions().RespondToPermission(ctx, pluginsdk.PermissionResponse{ + InteractionID: "pending-1", OptionID: "allow", + }) + assertPermissionDenied(t, err, "api_write:interactions") + + _, err = d.host.Interactions().AnswerClarification(ctx, pluginsdk.ClarificationResponse{ + InteractionID: "pending-2", + Answers: []pluginsdk.ClarificationAnswer{{QuestionID: "q1", CustomText: "yes"}}, + }) + assertPermissionDenied(t, err, "api_write:interactions") + + _, err = d.host.Interactions().CancelClarification(ctx, "pending-2", "nope") + assertPermissionDenied(t, err, "api_write:interactions") + + if d.responder.writeCalled { + t.Fatal("responder reached despite missing api_write:interactions") + } +} + +// ── reads ─────────────────────────────────────────────────────────────── + +func TestPluginHost_Interactions_ListPendingMapsAndFilters(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"interactions"}}) + d.interactions.pending = []*taskmodels.Interaction{pendingPermissionInteraction(), nil} + + got, info, err := d.host.Interactions().ListPending(context.Background(), pluginsdk.InteractionFilter{ + SessionIDs: []string{"session-1"}, Kinds: []string{"permission"}, + }, pluginsdk.Page{}) + if err != nil { + t.Fatalf("ListPending: %v", err) + } + if len(got) != 1 { + t.Fatalf("interactions = %d, want 1 (nil rows dropped)", len(got)) + } + if got[0].ID != "pending-1" || got[0].Kind != pluginsdk.InteractionKindPermission || + got[0].Status != pluginsdk.InteractionStatusPending { + t.Fatalf("dto = %+v", got[0]) + } + if got[0].CreatedAt != "2026-08-21T12:00:00Z" { + t.Fatalf("created_at = %q, want RFC3339 UTC", got[0].CreatedAt) + } + if len(got[0].Options) != 2 || got[0].Options[0].OptionID != "allow" { + t.Fatalf("options = %+v", got[0].Options) + } + if info == nil || info.HasMore { + t.Fatalf("page info = %+v", info) + } + if len(d.interactions.lastFilter.SessionIDs) != 1 || d.interactions.lastFilter.SessionIDs[0] != "session-1" { + t.Fatalf("filter not forwarded: %+v", d.interactions.lastFilter) + } +} + +func TestPluginHost_Interactions_ListPendingRejectsUnknownKind(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"interactions"}}) + _, _, err := d.host.Interactions().ListPending(context.Background(), + pluginsdk.InteractionFilter{Kinds: []string{"permision"}}, pluginsdk.Page{}) + assertCode(t, err, codes.InvalidArgument) +} + +// TestPluginHost_Interactions_GetResolvesTerminal is what makes a plugin's +// event cache reconcilable: an id it saw in an event still resolves after the +// interaction was answered, instead of vanishing into NotFound. +func TestPluginHost_Interactions_GetResolvesTerminal(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"interactions"}}) + resolved := pendingPermissionInteraction() + resolved.Status = taskmodels.InteractionStatusApproved + withInteraction(d, resolved) + + got, err := d.host.Interactions().Get(context.Background(), "pending-1") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Status != pluginsdk.InteractionStatusApproved { + t.Fatalf("status = %q, want approved", got.Status) + } +} + +func TestPluginHost_Interactions_GetUnknownIsNotFound(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"interactions"}}) + d.interactions.byID = map[string]*taskmodels.Interaction{} + + _, err := d.host.Interactions().Get(context.Background(), "nope") + assertCode(t, err, codes.NotFound) + + _, err = d.host.Interactions().Get(context.Background(), "") + assertCode(t, err, codes.InvalidArgument) +} + +// ── permission writes ─────────────────────────────────────────────────── + +func TestPluginHost_Interactions_RespondToPermissionDerivesOutcomeFromOptionKind(t *testing.T) { + cases := []struct { + name string + optionID string + cancelled bool + }{ + {"allow option is forwarded", "allow", false}, + {"reject option is forwarded", "deny", false}, + {"dismissal carries no option", "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + d := newTestDataHost(readWriteCaps()) + withInteraction(d, pendingPermissionInteraction()) + resolved := pendingPermissionInteraction() + resolved.Status = taskmodels.InteractionStatusApproved + d.interactions.afterWrite = resolved + + got, err := d.host.Interactions().RespondToPermission(context.Background(), + pluginsdk.PermissionResponse{InteractionID: "pending-1", OptionID: tc.optionID, Cancelled: tc.cancelled}) + if err != nil { + t.Fatalf("RespondToPermission: %v", err) + } + if d.responder.permission == nil { + t.Fatal("responder not called") + } + if d.responder.permission.in.OptionID != tc.optionID { + t.Fatalf("option = %q, want %q", d.responder.permission.in.OptionID, tc.optionID) + } + if d.responder.permission.in.Cancelled != tc.cancelled { + t.Fatalf("cancelled = %v, want %v", d.responder.permission.in.Cancelled, tc.cancelled) + } + // Identity always comes from the durable record, never from the + // plugin: kandev's resolution is keyed on all four ids. + if d.responder.permission.in.SessionID != "session-1" || + d.responder.permission.in.TaskID != "task-1" || + d.responder.permission.in.RequestID != "request-1" || + d.responder.permission.in.PendingID != "pending-1" { + t.Fatalf("identity = %+v, want it taken from the durable record", d.responder.permission.in) + } + if got.Status != pluginsdk.InteractionStatusApproved { + t.Fatalf("returned status = %q, want the post-write state", got.Status) + } + }) + } +} + +func TestPluginHost_Interactions_RespondToPermissionValidatesOption(t *testing.T) { + cases := []struct { + name string + in pluginsdk.PermissionResponse + }{ + {"option the agent never offered", pluginsdk.PermissionResponse{InteractionID: "pending-1", OptionID: "sudo"}}, + {"no option and no cancel", pluginsdk.PermissionResponse{InteractionID: "pending-1"}}, + {"option together with cancel", pluginsdk.PermissionResponse{ + InteractionID: "pending-1", OptionID: "allow", Cancelled: true, + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + d := newTestDataHost(readWriteCaps()) + withInteraction(d, pendingPermissionInteraction()) + + _, err := d.host.Interactions().RespondToPermission(context.Background(), tc.in) + assertCode(t, err, codes.InvalidArgument) + if d.responder.writeCalled { + t.Fatal("responder reached with an invalid choice") + } + }) + } +} + +// TestPluginHost_Interactions_TerminalOnce pins the idempotency contract: the +// first terminal response wins, and a replay reports FailedPrecondition rather +// than dispatching a second response — which would either fail deep in the +// agent transport or answer whatever request reused the pending slot. +func TestPluginHost_Interactions_TerminalOnce(t *testing.T) { + resolved := pendingPermissionInteraction() + resolved.Status = taskmodels.InteractionStatusRejected + d := newTestDataHost(readWriteCaps()) + withInteraction(d, resolved) + + _, err := d.host.Interactions().RespondToPermission(context.Background(), + pluginsdk.PermissionResponse{InteractionID: "pending-1", OptionID: "allow"}) + assertCode(t, err, codes.FailedPrecondition) + if d.responder.writeCalled { + t.Fatal("responder reached for an already-resolved interaction") + } + + answeredBundle := pendingClarificationInteraction() + answeredBundle.Status = taskmodels.InteractionStatusAnswered + d2 := newTestDataHost(readWriteCaps()) + withInteraction(d2, answeredBundle) + _, err = d2.host.Interactions().AnswerClarification(context.Background(), pluginsdk.ClarificationResponse{ + InteractionID: "pending-2", Answers: []pluginsdk.ClarificationAnswer{{QuestionID: "q1"}}, + }) + assertCode(t, err, codes.FailedPrecondition) +} + +func TestPluginHost_Interactions_KindMismatchIsRefused(t *testing.T) { + d := newTestDataHost(readWriteCaps()) + withInteraction(d, pendingPermissionInteraction()) + + _, err := d.host.Interactions().AnswerClarification(context.Background(), pluginsdk.ClarificationResponse{ + InteractionID: "pending-1", Answers: []pluginsdk.ClarificationAnswer{{QuestionID: "q1"}}, + }) + assertCode(t, err, codes.FailedPrecondition) + + d2 := newTestDataHost(readWriteCaps()) + withInteraction(d2, pendingClarificationInteraction()) + _, err = d2.host.Interactions().RespondToPermission(context.Background(), + pluginsdk.PermissionResponse{InteractionID: "pending-2", Cancelled: true}) + assertCode(t, err, codes.FailedPrecondition) +} + +// ── clarification writes ──────────────────────────────────────────────── + +func TestPluginHost_Interactions_AnswerAndCancelClarification(t *testing.T) { + d := newTestDataHost(readWriteCaps()) + withInteraction(d, pendingClarificationInteraction()) + answered := pendingClarificationInteraction() + answered.Status = taskmodels.InteractionStatusAnswered + d.interactions.afterWrite = answered + + got, err := d.host.Interactions().AnswerClarification(context.Background(), pluginsdk.ClarificationResponse{ + InteractionID: "pending-2", + Answers: []pluginsdk.ClarificationAnswer{{QuestionID: "q1", SelectedOptions: []string{"a"}, CustomText: "why not"}}, + }) + if err != nil { + t.Fatalf("AnswerClarification: %v", err) + } + if d.responder.answered != "pending-2" || len(d.responder.answers) != 1 || + d.responder.answers[0].QuestionID != "q1" || d.responder.answers[0].CustomText != "why not" { + t.Fatalf("responder saw %q / %+v", d.responder.answered, d.responder.answers) + } + if got.Status != pluginsdk.InteractionStatusAnswered { + t.Fatalf("status = %q, want answered", got.Status) + } + + d2 := newTestDataHost(readWriteCaps()) + withInteraction(d2, pendingClarificationInteraction()) + if _, err := d2.host.Interactions().CancelClarification(context.Background(), "pending-2", "user went away"); err != nil { + t.Fatalf("CancelClarification: %v", err) + } + if d2.responder.declined != "pending-2" || d2.responder.reason != "user went away" { + t.Fatalf("decline recorded as %q / %q", d2.responder.declined, d2.responder.reason) + } +} + +func TestPluginHost_Interactions_AnswerClarificationValidatesAnswers(t *testing.T) { + cases := []struct { + name string + in pluginsdk.ClarificationResponse + }{ + {"no answers", pluginsdk.ClarificationResponse{InteractionID: "pending-2"}}, + {"answer without a question id", pluginsdk.ClarificationResponse{ + InteractionID: "pending-2", Answers: []pluginsdk.ClarificationAnswer{{CustomText: "yes"}}, + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + d := newTestDataHost(readWriteCaps()) + withInteraction(d, pendingClarificationInteraction()) + _, err := d.host.Interactions().AnswerClarification(context.Background(), tc.in) + assertCode(t, err, codes.InvalidArgument) + if d.responder.writeCalled { + t.Fatal("responder reached with an invalid answer set") + } + }) + } +} + +// TestPluginHost_Interactions_WriteSurvivesFailedReread proves a delivered +// response is never reported as an error: the re-read is only there to report +// the new state, so its failure falls back to the pre-write snapshot instead +// of inviting a retry that would double-answer. +func TestPluginHost_Interactions_WriteSurvivesFailedReread(t *testing.T) { + d := newTestDataHost(readWriteCaps()) + withInteraction(d, pendingClarificationInteraction()) + d.responder.writeCalled = false + + first := true + source := d.interactions + source.getErr = nil + d.host.interactionData = &rereadFailingSource{inner: source, failAfter: &first} + + got, err := d.host.Interactions().CancelClarification(context.Background(), "pending-2", "gone") + if err != nil { + t.Fatalf("CancelClarification: %v", err) + } + if got.ID != "pending-2" { + t.Fatalf("interaction = %+v, want the pre-write snapshot", got) + } + // The response contract says every write returns the interaction in its NEW + // terminal state. Handing back the pre-write snapshot verbatim would report + // "pending" for a response the agent already received, inviting the retry + // terminal-once exists to prevent. A cancel is delivered as a decline of the + // bundle, so its terminal status is rejected. + if got.Status != pluginsdk.InteractionStatusRejected { + t.Fatalf("status = %q, want the resolved status stamped on the fallback", got.Status) + } + if !d.responder.writeCalled { + t.Fatal("write never happened") + } +} + +type rereadFailingSource struct { + inner *fakeInteractionDataSource + failAfter *bool +} + +func (s *rereadFailingSource) ListPendingInteractions( + ctx context.Context, filter taskmodels.PendingInteractionFilter, +) ([]*taskmodels.Interaction, error) { + return s.inner.ListPendingInteractions(ctx, filter) +} + +func (s *rereadFailingSource) GetInteraction( + ctx context.Context, pendingID string, +) (*taskmodels.Interaction, error) { + if *s.failAfter { + *s.failAfter = false + return s.inner.GetInteraction(ctx, pendingID) + } + return nil, errors.New("database went away") +} + +// ── unwired host ──────────────────────────────────────────────────────── + +func TestPluginHost_Interactions_UnimplementedWithoutDependencies(t *testing.T) { + host := &pluginHost{pluginID: "p1", capabilities: readWriteCaps()} + ctx := context.Background() + + _, _, err := host.Interactions().ListPending(ctx, pluginsdk.InteractionFilter{}, pluginsdk.Page{}) + assertCode(t, err, codes.Unimplemented) + + _, err = host.Interactions().RespondToPermission(ctx, pluginsdk.PermissionResponse{InteractionID: "x", Cancelled: true}) + assertCode(t, err, codes.Unimplemented) +} + +// TestServiceHostCarriesInteractionDependencies pins the wiring hop between +// Service and the host it hands each plugin. Every capability check and every +// accessor test above runs against a hand-built pluginHost, so a +// SetDataSources or SetInteractionResponder field that never reaches +// hostForPlugin would leave the whole feature inert while every other test in +// this file still passed. +func TestServiceHostCarriesInteractionDependencies(t *testing.T) { + svc, _, _ := newTestService(t) + source := &fakeInteractionDataSource{ + byID: map[string]*taskmodels.Interaction{"pending-1": pendingPermissionInteraction()}, + } + responder := &fakeInteractionResponder{} + svc.SetDataSources(nil, nil, nil, nil, nil, nil, source, nil) + svc.SetInteractionResponder(responder) + + host, ok := svc.hostForPlugin("p1").(*pluginHost) + if !ok { + t.Fatal("hostForPlugin did not return a *pluginHost") + } + if host.interactionData == nil { + t.Fatal("interaction data source did not reach the plugin host") + } + if host.interactionDeps == nil || host.interactionDeps() == nil { + t.Fatal("interaction responder did not reach the plugin host") + } + // The host is built from an uninstalled plugin id, so it declares no + // capabilities: the reachable dependency must still be gated. + if _, err := host.Interactions().Get(context.Background(), "pending-1"); err == nil || + status.Code(err) != codes.PermissionDenied { + t.Fatalf("Get without api_read:interactions = %v, want PermissionDenied", err) + } +} diff --git a/apps/backend/internal/plugins/service.go b/apps/backend/internal/plugins/service.go index 5d692ce759..533a226d31 100644 --- a/apps/backend/internal/plugins/service.go +++ b/apps/backend/internal/plugins/service.go @@ -122,6 +122,7 @@ type Service struct { agentProfiles agentProfileDataSource sessionCodeStats sessionCodeStatsSource messageData messageDataSource + interactionData interactionDataSource taskWriter taskWriter // Utility agent invocation (ADR 0048), wired via SetUtilityAgent. @@ -135,6 +136,12 @@ type Service struct { messenger taskMessenger taskStarter taskStarter + // Interaction response path wired late via SetInteractionResponder (ADR + // 0052), for the same reason as messenger/taskStarter: the orchestrator + // and the clarification handler are constructed after boot-active plugins + // spawn. Mutex-guarded against the concurrent hostForPlugin reads. + interactionResponder interactionResponder + // authLogin establishes an authenticated browser session for an external // identity an auth-capable plugin asserts via its webhook response // (OIDC/SAML SSO), wired via SetAuthLoginBridge. nil until backendapp @@ -401,6 +408,7 @@ func (s *Service) SetDataSources( agentProfiles agentProfileDataSource, sessionCodeStats sessionCodeStatsSource, messages messageDataSource, + interactions interactionDataSource, taskWrites taskWriter, ) { s.taskData = tasks @@ -409,9 +417,31 @@ func (s *Service) SetDataSources( s.agentProfiles = agentProfiles s.sessionCodeStats = sessionCodeStats s.messageData = messages + s.interactionData = interactions s.taskWriter = taskWrites } +// SetInteractionResponder wires the interaction write path (ADR 0052): the +// adapter that answers permissions through the orchestrator and clarification +// bundles through the clarification handler. Wired LATE for the same reason as +// SetWriteDeps — both first-party services are constructed after +// StartActivePlugins has spawned boot-active plugins — so hosts read it live +// via interactionResponderDep rather than snapshotting it. A nil responder +// leaves the write RPCs returning Unimplemented; the reads are unaffected. +func (s *Service) SetInteractionResponder(responder interactionResponder) { + s.mu.Lock() + defer s.mu.Unlock() + s.interactionResponder = responder +} + +// interactionResponderDep returns the currently-wired interaction responder, +// guarded by s.mu against the SetInteractionResponder write. +func (s *Service) interactionResponderDep() interactionResponder { + s.mu.Lock() + defer s.mu.Unlock() + return s.interactionResponder +} + // SetWriteDeps wires the Host data API's late write dependencies (ADR 0043 // phase 2): the task-message delivery path behind the SendMessage RPC // (api_write:messages) and the orchestrator task-starter behind CreateTask's @@ -599,9 +629,11 @@ func (s *Service) hostForPlugin(pluginID string) pluginsdk.Host { agentProfiles: s.agentProfiles, sessionCodeStats: s.sessionCodeStats, messageData: s.messageData, + interactionData: s.interactionData, taskWriter: s.taskWriter, utilityDeps: s.utilityAgentDeps, writeDeps: s.writeDependencies, + interactionDeps: s.interactionResponderDep, } } diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index 8eb44246b2..85917d555d 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -264,6 +264,9 @@ func (m *mockRepository) FindActiveClarificationMessagesBySessionID(ctx context. func (m *mockRepository) GetPendingActionsBySessionIDs(ctx context.Context, sessionIDs []string) (map[string]models.TaskPendingAction, error) { return make(map[string]models.TaskPendingAction), nil } +func (m *mockRepository) ListPendingInteractions(context.Context, models.PendingInteractionFilter) ([]*models.Message, error) { + return nil, nil +} func (m *mockRepository) CompleteActiveClarificationBundle( context.Context, string, diff --git a/apps/backend/internal/task/models/interactions.go b/apps/backend/internal/task/models/interactions.go new file mode 100644 index 0000000000..8e31218d05 --- /dev/null +++ b/apps/backend/internal/task/models/interactions.go @@ -0,0 +1,132 @@ +package models + +import "time" + +// InteractionKind names the two agent-initiated request kinds that block a +// turn on a human decision. It is the durable vocabulary behind the compact +// TaskPendingAction projection, kept as its own type because the interaction +// contract carries the full request (options, questions, resolution) rather +// than the one-word "what is this session waiting on" answer. +type InteractionKind string + +const ( + // InteractionKindPermission is a tool-permission request (ACP + // session/request_permission), persisted as a permission_request message. + InteractionKindPermission InteractionKind = "permission" + // InteractionKindClarification is a structured question bundle + // (ask_user_question), persisted as one clarification_request message per + // question, all sharing metadata.pending_id. + InteractionKindClarification InteractionKind = "clarification" +) + +// InteractionStatus is the normalized resolution state of an interaction. It +// unifies PermissionStatus (approved/rejected/expired) with the clarification +// vocabulary (answered/rejected/cancelled/expired) so a consumer can branch on +// "still owed a response" without knowing which kind it is holding. +// +// Both kinds record the unresolved state differently on disk — a permission +// message omits metadata.status entirely, a clarification writes "pending" — +// so readers must normalize through InteractionStatusFromMetadata rather than +// comparing the raw metadata value. +type InteractionStatus string + +const ( + // InteractionStatusPending means the interaction is still owed a response. + InteractionStatusPending InteractionStatus = "pending" + // InteractionStatusApproved is a permission the user accepted. + InteractionStatusApproved InteractionStatus = "approved" + // InteractionStatusRejected is a permission the user denied or a + // clarification bundle the user declined. + InteractionStatusRejected InteractionStatus = "rejected" + // InteractionStatusAnswered is a clarification bundle the user answered. + InteractionStatusAnswered InteractionStatus = "answered" + // InteractionStatusCancelled is a clarification withdrawn by the operator. + InteractionStatusCancelled InteractionStatus = "cancelled" + // InteractionStatusExpired means the request became unanswerable before a + // response arrived (the agent withdrew it, or its pending entry was gone). + InteractionStatusExpired InteractionStatus = "expired" +) + +// metadataStatusPending is the literal a clarification message writes for the +// unresolved state. A permission message writes nothing at all (see +// PermissionStatusPending), so both the empty string and this literal +// normalize to InteractionStatusPending. +const metadataStatusPending = "pending" + +// IsTerminal reports whether the interaction can no longer accept a response. +func (s InteractionStatus) IsTerminal() bool { + return s != "" && s != InteractionStatusPending +} + +// InteractionStatusFromMetadata normalizes a message's metadata.status into an +// InteractionStatus. An absent or empty value is pending, matching +// PermissionStatusPending's absence sentinel. An unrecognized value is +// preserved verbatim rather than coerced to pending: an unknown terminal state +// must never read as "still owed a response". +func InteractionStatusFromMetadata(metadata map[string]interface{}) InteractionStatus { + raw, _ := metadata["status"].(string) + if raw == "" || raw == metadataStatusPending { + return InteractionStatusPending + } + return InteractionStatus(raw) +} + +// PendingInteractionFilter narrows the durable pending-interaction read. +// SessionIDs and TaskIDs are ORed within each axis and ANDed across the two +// (matching PluginMessageFilter); an empty axis is unconstrained. Kinds +// narrows by InteractionKind; an empty slice means both kinds. +type PendingInteractionFilter struct { + SessionIDs []string + TaskIDs []string + Kinds []string +} + +// InteractionOption is one selectable response on a permission request or a +// clarification question. Kind carries the ACP permission-option kind +// (allow_once / allow_always / reject_once / reject_always) for permissions and +// is empty for clarification options; Description is the reverse. +type InteractionOption struct { + ID string `json:"option_id"` + Label string `json:"label"` + Kind string `json:"kind,omitempty"` + Description string `json:"description,omitempty"` +} + +// InteractionQuestion is one question of a clarification bundle. Every question +// in a bundle must be answered before the agent unblocks. +type InteractionQuestion struct { + ID string `json:"id"` + Title string `json:"title,omitempty"` + Prompt string `json:"prompt"` + Options []InteractionOption `json:"options,omitempty"` +} + +// Interaction is the assembled durable projection of one agent interaction: a +// permission request, or a whole clarification bundle collapsed from its +// per-question messages. ID is the pending_id the response paths key on. +// +// AgentDisconnected marks a still-pending clarification whose original waiter +// has gone away (the agent moved on or the backend restarted). It stays +// pending and answerable — the answer resumes the session in a new turn — so a +// consumer must not treat disconnection as resolution. +type Interaction struct { + ID string `json:"id"` + Kind InteractionKind `json:"kind"` + TaskID string `json:"task_id"` + SessionID string `json:"session_id"` + TurnID string `json:"turn_id"` + Status InteractionStatus `json:"status"` + // RequestID is the provider-side permission request identity. Kandev's + // resolution path is keyed on it alongside the pending id; empty for a + // clarification bundle and for permission rows written before it existed. + RequestID string `json:"request_id,omitempty"` + Title string `json:"title,omitempty"` + Context string `json:"context,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + ActionType string `json:"action_type,omitempty"` + Options []InteractionOption `json:"options,omitempty"` + Questions []InteractionQuestion `json:"questions,omitempty"` + AgentDisconnected bool `json:"agent_disconnected,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 1cfe7c169c..b27bad56bd 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -222,6 +222,10 @@ type MessageRepository interface { FindMessageByPendingIDAndQuestion(ctx context.Context, sessionID, pendingID, questionID string) (*models.Message, error) FindActiveClarificationMessagesBySessionID(ctx context.Context, sessionID string) ([]*models.Message, error) GetPendingActionsBySessionIDs(ctx context.Context, sessionIDs []string) (map[string]models.TaskPendingAction, error) + // ListPendingInteractions returns the durable request rows behind the + // compact pending-action projection, under the same turn/session authority + // (ADR 0052). Clarification bundles come back as one row per question. + ListPendingInteractions(ctx context.Context, filter models.PendingInteractionFilter) ([]*models.Message, error) CompleteActiveClarificationBundle(ctx context.Context, pendingID, status string, responses map[string]interface{}) ([]*models.Message, bool, error) FinalizeClarificationResponseDelivery(ctx context.Context, pendingID, terminalStatus string, claimedMessages []*models.Message) ([]*models.Message, bool, error) RestoreActiveClarificationBundle(ctx context.Context, pendingID, terminalStatus string, claimedMessages []*models.Message) ([]*models.Message, bool, error) diff --git a/apps/backend/internal/task/repository/sqlite/pending_interactions.go b/apps/backend/internal/task/repository/sqlite/pending_interactions.go new file mode 100644 index 0000000000..f6ab17509a --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/pending_interactions.go @@ -0,0 +1,202 @@ +package sqlite + +import ( + "context" + "fmt" + "strings" + + "github.com/kandev/kandev/internal/agentctl/tracing" + "github.com/kandev/kandev/internal/db/dialect" + "github.com/kandev/kandev/internal/task/models" +) + +// pendingInteractionColumns is the message projection both pending-interaction +// CTEs select. Kept identical to scanMessageRows' expected column order so the +// UNION ALL below stays scannable by the shared helper. +const pendingInteractionColumns = `m.id, m.task_session_id, m.task_id, m.turn_id, m.author_type, + m.author_id, m.content, m.requests_input, m.type, m.metadata, m.created_at, m.updated_at` + +// ListPendingInteractions returns the durable request rows for every agent +// interaction still owed a user response, using the SAME authority the compact +// pending-action projection uses (GetPendingActionsBySessionIDs): only the +// session's current durable turn counts, terminal sessions quarantine pending +// history, and a permission row only counts when it is the newest permission of +// that turn. Anything weaker re-reports interactions kandev's own list surfaces +// already consider settled — the false-positive class ADR 0052 exists to close. +// +// Clarification bundles come back as one row per question (all sharing +// metadata.pending_id); the service layer groups them into a single +// interaction. A bundle qualifies when ANY of its questions is still pending, +// and then EVERY question of that bundle is returned: the agent stays blocked +// until the whole bundle is answered, so a caller handed only a partially +// answered bundle's pending rows would build a response the answer path +// rejects for having too few answers. It also keeps this read and +// GetInteraction (which resolves by pending_id) agreeing on a question set. +// Rows are ordered oldest-first by (created_at, id). +func (r *Repository) ListPendingInteractions( + ctx context.Context, + filter models.PendingInteractionFilter, +) ([]*models.Message, error) { + ctx, span := tracing.Tracer("kandev-db").Start(ctx, "db.ListPendingInteractions") + defer span.End() + + query, args := buildPendingInteractionQuery(r.ro.DriverName(), filter) + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + messages, _, err := scanMessageRows(rows, 0) + return messages, err +} + +// buildPendingInteractionQuery assembles the pending-interaction query. The +// session-scoping subquery is shared by the current-turn CTE and both message +// CTEs, so a session/task filter narrows the authority window itself rather +// than filtering rows the CTEs already resolved against a wider set. +func buildPendingInteractionQuery( + driverName string, + filter models.PendingInteractionFilter, +) (string, []interface{}) { + statusExpr := dialect.JSONExtract(driverName, "m.metadata", "status") + pendingIDExpr := dialect.JSONExtract(driverName, "m.metadata", "pending_id") + scopedSessions, args := pendingInteractionSessionScope(filter) + kindConditions, kindArgs := pendingInteractionKindClauses(filter.Kinds) + + query := fmt.Sprintf( + pendingInteractionQueryTemplate, + scopedSessions, + turnAuthorityPredicate(driverName, "turn_row"), + nonTerminalSessionPredicate("turn_row"), + pendingIDExpr, + models.MessageTypeClarificationRequest, + statusExpr, + models.InteractionStatusPending, + pendingInteractionColumns, + pendingIDExpr, + models.MessageTypeClarificationRequest, + pendingInteractionColumns, + statusExpr, + pendingActionMessageOrder(driverName, "m"), + models.MessageTypePermissionRequest, + models.InteractionStatusPending, + ) + if kindConditions != "" { + query = "SELECT * FROM (" + query + ") interactions WHERE " + kindConditions + args = append(args, kindArgs...) + } + query += " ORDER BY created_at ASC, id ASC" + return query, args +} + +// pendingInteractionQueryTemplate is the format string behind +// buildPendingInteractionQuery. current_turn duplicates the ranking in +// pendingActionsBySessionQuery on purpose: both must agree, and a shared +// builder would have to thread the scoping subquery's bind arguments through +// the projection's placeholder list. The pinning test +// (TestListPendingInteractionsAgreesWithPendingActionProjection) fails if the +// two ever disagree on a session. +const pendingInteractionQueryTemplate = ` + WITH scoped_sessions AS (%s), + current_turn AS ( + SELECT task_session_id, turn_id + FROM ( + SELECT task_session_id, + id AS turn_id, + ROW_NUMBER() OVER ( + PARTITION BY task_session_id + ORDER BY started_at DESC, created_at DESC, id DESC + ) AS rn + FROM task_session_turns turn_row + WHERE turn_row.task_session_id IN (SELECT id FROM scoped_sessions) + AND %s + AND %s + ) ranked + WHERE rn = 1 + ), + pending_bundles AS ( + SELECT DISTINCT m.task_session_id, %s AS pending_id + FROM task_session_messages m + JOIN current_turn current + ON current.task_session_id = m.task_session_id + AND current.turn_id = m.turn_id + WHERE m.type = '%s' + AND COALESCE(%s, '') IN ('', '%s') + ), + pending_clarifications AS ( + SELECT %s + FROM task_session_messages m + JOIN current_turn current + ON current.task_session_id = m.task_session_id + AND current.turn_id = m.turn_id + JOIN pending_bundles bundle + ON bundle.task_session_id = m.task_session_id + AND bundle.pending_id = %s + WHERE m.type = '%s' + ), + ranked_permissions AS ( + SELECT %s, + COALESCE(%s, '') AS permission_status, + ROW_NUMBER() OVER ( + PARTITION BY m.task_session_id + ORDER BY m.created_at DESC, %s DESC + ) AS rn + FROM task_session_messages m + JOIN current_turn current + ON current.task_session_id = m.task_session_id + AND current.turn_id = m.turn_id + WHERE m.type = '%s' + ) + SELECT id, task_session_id, task_id, turn_id, author_type, author_id, + content, requests_input, type, metadata, created_at, updated_at + FROM pending_clarifications + UNION ALL + SELECT id, task_session_id, task_id, turn_id, author_type, author_id, + content, requests_input, type, metadata, created_at, updated_at + FROM ranked_permissions + WHERE rn = 1 AND permission_status IN ('', '%s') + ` + +// pendingInteractionSessionScope builds the scoped_sessions subquery. Session +// and task filters are ANDed (a session must match both axes when both are +// set), matching PluginMessageFilter's documented semantics. +func pendingInteractionSessionScope(filter models.PendingInteractionFilter) (string, []interface{}) { + query := "SELECT id FROM task_sessions" + var conditions []string + var args []interface{} + if ph, inArgs := buildInPlaceholders(filter.SessionIDs); ph != "" { + conditions = append(conditions, "id IN ("+ph+")") + args = append(args, inArgs...) + } + if ph, inArgs := buildInPlaceholders(filter.TaskIDs); ph != "" { + conditions = append(conditions, "task_id IN ("+ph+")") + args = append(args, inArgs...) + } + if len(conditions) > 0 { + query += " WHERE " + strings.Join(conditions, " AND ") + } + return query, args +} + +// pendingInteractionKindClauses narrows the UNION result to the requested +// interaction kinds by mapping each kind back to its message type. An unknown +// kind matches nothing rather than everything, so a typo fails closed. +func pendingInteractionKindClauses(kinds []string) (string, []interface{}) { + if len(kinds) == 0 { + return "", nil + } + types := make([]string, 0, len(kinds)) + for _, kind := range kinds { + switch models.InteractionKind(kind) { + case models.InteractionKindClarification: + types = append(types, string(models.MessageTypeClarificationRequest)) + case models.InteractionKindPermission: + types = append(types, string(models.MessageTypePermissionRequest)) + } + } + if len(types) == 0 { + return "1 = 0", nil + } + ph, args := buildInPlaceholders(types) + return "type IN (" + ph + ")", args +} diff --git a/apps/backend/internal/task/repository/sqlite/pending_interactions_postgres_test.go b/apps/backend/internal/task/repository/sqlite/pending_interactions_postgres_test.go new file mode 100644 index 0000000000..e64c952d64 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/pending_interactions_postgres_test.go @@ -0,0 +1,107 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/testutil" +) + +// TestPostgresPendingInteractionsMatchSQLiteAuthority pins the pending-interaction +// read on PostgreSQL. The query is dialect-sensitive in three places — the JSON +// status extraction, the rowid-versus-id tiebreak that picks a turn's newest +// permission, and the WITH clause nested inside the kind-filter subquery — and a +// schema replay test would exercise none of them. It skips unless +// KANDEV_TEST_POSTGRES_DSN is configured. +func TestPostgresPendingInteractionsMatchSQLiteAuthority(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + base := time.Date(2026, time.August, 22, 9, 0, 0, 0, time.UTC) + + // An approved permission on a session still reading WAITING_FOR_INPUT must + // not be reported, exactly as on SQLite. + seedPendingActionSession(t, repo, "task-approved-pg", "session-approved-pg") + createPendingActionTurn(t, repo, "task-approved-pg", "session-approved-pg", "turn-approved-pg", base, base) + createInteractionMessage(t, repo, "perm-approved-pg", "task-approved-pg", "session-approved-pg", "turn-approved-pg", + models.MessageTypePermissionRequest, map[string]interface{}{ + "pending_id": "pending-approved-pg", + "status": string(models.InteractionStatusApproved), + }, base) + + // Two permissions in one turn: only the newest is answerable. Their + // created_at values differ deliberately — the secondary tiebreak is + // dialect-specific (SQLite orders by rowid, PostgreSQL by the text id), so + // identical timestamps would assert a difference between the two engines + // rather than the rule under test. + seedPendingActionSession(t, repo, "task-multi-pg", "session-multi-pg") + createPendingActionTurn(t, repo, "task-multi-pg", "session-multi-pg", "turn-multi-pg", base, base) + createInteractionMessage(t, repo, "perm-older-pg", "task-multi-pg", "session-multi-pg", "turn-multi-pg", + models.MessageTypePermissionRequest, map[string]interface{}{"pending_id": "pending-older-pg"}, base) + createInteractionMessage(t, repo, "perm-newer-pg", "task-multi-pg", "session-multi-pg", "turn-multi-pg", + models.MessageTypePermissionRequest, map[string]interface{}{"pending_id": "pending-newer-pg"}, + base.Add(time.Second)) + + seedPendingActionSession(t, repo, "task-clar-pg", "session-clar-pg") + createPendingActionTurn(t, repo, "task-clar-pg", "session-clar-pg", "turn-clar-pg", base, base) + createClarificationBundleMessage(t, repo, "clar-pg", "task-clar-pg", "session-clar-pg", "turn-clar-pg", + "pending-clar-pg", "q1", base) + + got, err := repo.ListPendingInteractions(ctx, models.PendingInteractionFilter{}) + if err != nil { + t.Fatalf("ListPendingInteractions: %v", err) + } + ids := map[string]bool{} + for _, message := range got { + ids[message.ID] = true + } + if ids["perm-approved-pg"] { + t.Fatal("approved permission reported as an owed interaction on postgres") + } + if ids["perm-older-pg"] { + t.Fatal("superseded permission reported as an owed interaction on postgres") + } + if !ids["perm-newer-pg"] || !ids["clar-pg"] { + t.Fatalf("pending interactions = %v, want the newest permission and the clarification", + interactionMessageIDs(got)) + } + + // The kind filter wraps the whole CTE query in a subquery; prove PostgreSQL + // accepts that nesting and narrows correctly. + clarifications, err := repo.ListPendingInteractions(ctx, models.PendingInteractionFilter{ + Kinds: []string{string(models.InteractionKindClarification)}, + }) + if err != nil { + t.Fatalf("ListPendingInteractions(kind): %v", err) + } + if len(clarifications) != 1 || clarifications[0].ID != "clar-pg" { + t.Fatalf("kind-filtered interactions = %v, want [clar-pg]", interactionMessageIDs(clarifications)) + } + + // Both projections must reach the same verdict per session on postgres too. + sessionIDs := []string{"session-approved-pg", "session-multi-pg", "session-clar-pg"} + actions, err := repo.GetPendingActionsBySessionIDs(ctx, sessionIDs) + if err != nil { + t.Fatalf("GetPendingActionsBySessionIDs: %v", err) + } + scoped, err := repo.ListPendingInteractions(ctx, models.PendingInteractionFilter{SessionIDs: sessionIDs}) + if err != nil { + t.Fatalf("ListPendingInteractions(sessions): %v", err) + } + bySession := map[string]bool{} + for _, message := range scoped { + bySession[message.TaskSessionID] = true + } + for _, sessionID := range sessionIDs { + _, projectionSaysPending := actions[sessionID] + if projectionSaysPending != bySession[sessionID] { + t.Fatalf("session %s: pending-action projection=%v, interaction list=%v (must agree)", + sessionID, projectionSaysPending, bySession[sessionID]) + } + } +} diff --git a/apps/backend/internal/task/repository/sqlite/pending_interactions_test.go b/apps/backend/internal/task/repository/sqlite/pending_interactions_test.go new file mode 100644 index 0000000000..9ca9db2054 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/pending_interactions_test.go @@ -0,0 +1,300 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +func createInteractionMessage( + t *testing.T, + repo *Repository, + id, taskID, sessionID, turnID string, + msgType models.MessageType, + metadata map[string]interface{}, + createdAt time.Time, +) { + t.Helper() + if err := repo.CreateMessage(context.Background(), &models.Message{ + ID: id, + TaskSessionID: sessionID, + TaskID: taskID, + TurnID: turnID, + AuthorType: models.MessageAuthorAgent, + Content: id, + Type: msgType, + Metadata: metadata, + CreatedAt: createdAt, + }); err != nil { + t.Fatalf("CreateMessage(%s): %v", id, err) + } +} + +func interactionMessageIDs(messages []*models.Message) []string { + out := make([]string, 0, len(messages)) + for _, message := range messages { + out = append(out, message.ID) + } + return out +} + +func TestListPendingInteractionsReturnsBothKinds(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 20, 9, 0, 0, 0, time.UTC) + + seedPendingActionSession(t, repo, "task-perm", "session-perm") + createPendingActionTurn(t, repo, "task-perm", "session-perm", "turn-perm", base, base) + createInteractionMessage(t, repo, "perm-1", "task-perm", "session-perm", "turn-perm", + models.MessageTypePermissionRequest, map[string]interface{}{ + "pending_id": "pending-perm", + "tool_call_id": "call-1", + "options": []interface{}{ + map[string]interface{}{"option_id": "allow", "name": "Allow", "kind": "allow_once"}, + }, + }, base) + + seedPendingActionSession(t, repo, "task-clar", "session-clar") + createPendingActionTurn(t, repo, "task-clar", "session-clar", "turn-clar", base, base) + createClarificationBundleMessage(t, repo, "clar-1", "task-clar", "session-clar", "turn-clar", "pending-clar", "q1", base) + createClarificationBundleMessage(t, repo, "clar-2", "task-clar", "session-clar", "turn-clar", "pending-clar", "q2", base.Add(time.Second)) + + got, err := repo.ListPendingInteractions(ctx, models.PendingInteractionFilter{}) + if err != nil { + t.Fatalf("ListPendingInteractions: %v", err) + } + want := map[string]bool{"perm-1": true, "clar-1": true, "clar-2": true} + if len(got) != len(want) { + t.Fatalf("pending interactions = %v, want %v", interactionMessageIDs(got), want) + } + for _, message := range got { + if !want[message.ID] { + t.Fatalf("unexpected pending interaction %q in %v", message.ID, interactionMessageIDs(got)) + } + } +} + +// TestListPendingInteractionsAgreesWithPendingActionProjection pins the whole +// point of the durable read: it must reach exactly the same verdict as the +// compact pending-action projection the native list surfaces render. The +// approved-permission case is the concrete false positive reported against +// Kandev 0.90.0 — the turn completed, the only permission row is durably +// approved, and the session snapshot still reads WAITING_FOR_INPUT. A +// state-only consumer claims attention is owed; both projections must not. +func TestListPendingInteractionsAgreesWithPendingActionProjection(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 20, 10, 0, 0, 0, time.UTC) + + // Resolved: approved permission on a session still reading WAITING_FOR_INPUT. + seedPendingActionSession(t, repo, "task-approved", "session-approved") + createPendingActionTurn(t, repo, "task-approved", "session-approved", "turn-approved", base, base) + createInteractionMessage(t, repo, "perm-approved", "task-approved", "session-approved", "turn-approved", + models.MessageTypePermissionRequest, map[string]interface{}{ + "pending_id": "pending-approved", + "status": string(models.InteractionStatusApproved), + }, base) + + // Genuinely pending: an unanswered permission on the current turn. + seedPendingActionSession(t, repo, "task-owed", "session-owed") + createPendingActionTurn(t, repo, "task-owed", "session-owed", "turn-owed", base, base) + createInteractionMessage(t, repo, "perm-owed", "task-owed", "session-owed", "turn-owed", + models.MessageTypePermissionRequest, map[string]interface{}{"pending_id": "pending-owed"}, base) + + sessionIDs := []string{"session-approved", "session-owed"} + actions, err := repo.GetPendingActionsBySessionIDs(ctx, sessionIDs) + if err != nil { + t.Fatalf("GetPendingActionsBySessionIDs: %v", err) + } + interactions, err := repo.ListPendingInteractions(ctx, models.PendingInteractionFilter{SessionIDs: sessionIDs}) + if err != nil { + t.Fatalf("ListPendingInteractions: %v", err) + } + + bySession := map[string]bool{} + for _, message := range interactions { + bySession[message.TaskSessionID] = true + } + for _, sessionID := range sessionIDs { + _, projectionSaysPending := actions[sessionID] + if projectionSaysPending != bySession[sessionID] { + t.Fatalf("session %s: pending-action projection=%v, interaction list=%v (must agree)", + sessionID, projectionSaysPending, bySession[sessionID]) + } + } + if bySession["session-approved"] { + t.Fatal("approved permission reported as an owed interaction") + } + if !bySession["session-owed"] { + t.Fatal("unanswered permission not reported as an owed interaction") + } +} + +func TestListPendingInteractionsExcludesSupersededTurnAndTerminalSession(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 20, 11, 0, 0, 0, time.UTC) + + // A clarification stranded on a superseded turn. + seedPendingActionSession(t, repo, "task-old", "session-old") + createPendingActionTurn(t, repo, "task-old", "session-old", "turn-old", base, base) + createClarificationBundleMessage(t, repo, "clar-old", "task-old", "session-old", "turn-old", "pending-old", "q1", base) + createPendingActionTurn(t, repo, "task-old", "session-old", "turn-new", base.Add(time.Minute), base.Add(time.Minute)) + createPendingActionMessage(t, repo, "ordinary-new", "task-old", "session-old", "turn-new", + models.MessageTypeMessage, "", base.Add(time.Minute)) + + // A pending clarification on a session that has since gone terminal. + seedPendingActionSession(t, repo, "task-done", "session-done") + createPendingActionTurn(t, repo, "task-done", "session-done", "turn-done", base, base) + createClarificationBundleMessage(t, repo, "clar-done", "task-done", "session-done", "turn-done", "pending-done", "q1", base) + session, err := repo.GetTaskSession(ctx, "session-done") + if err != nil { + t.Fatalf("GetTaskSession: %v", err) + } + session.State = models.TaskSessionStateCompleted + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("UpdateTaskSession: %v", err) + } + + got, err := repo.ListPendingInteractions(ctx, models.PendingInteractionFilter{}) + if err != nil { + t.Fatalf("ListPendingInteractions: %v", err) + } + if len(got) != 0 { + t.Fatalf("pending interactions = %v, want none", interactionMessageIDs(got)) + } +} + +// TestListPendingInteractionsReportsOnlyNewestPermissionOfTurn mirrors the +// pending-action projection's latest-permission rule: an older permission row +// in the same turn is not independently answerable, so surfacing it would +// hand a plugin an id whose response the agent has already moved past. +func TestListPendingInteractionsReportsOnlyNewestPermissionOfTurn(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 20, 12, 0, 0, 0, time.UTC) + + seedPendingActionSession(t, repo, "task-multi", "session-multi") + createPendingActionTurn(t, repo, "task-multi", "session-multi", "turn-multi", base, base) + createInteractionMessage(t, repo, "perm-older", "task-multi", "session-multi", "turn-multi", + models.MessageTypePermissionRequest, map[string]interface{}{"pending_id": "pending-older"}, base) + createInteractionMessage(t, repo, "perm-newer", "task-multi", "session-multi", "turn-multi", + models.MessageTypePermissionRequest, map[string]interface{}{"pending_id": "pending-newer"}, base.Add(time.Second)) + + got, err := repo.ListPendingInteractions(ctx, models.PendingInteractionFilter{}) + if err != nil { + t.Fatalf("ListPendingInteractions: %v", err) + } + if len(got) != 1 || got[0].ID != "perm-newer" { + t.Fatalf("pending interactions = %v, want [perm-newer]", interactionMessageIDs(got)) + } +} + +func TestListPendingInteractionsFilters(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 20, 13, 0, 0, 0, time.UTC) + + seedPendingActionSession(t, repo, "task-a", "session-a") + createPendingActionTurn(t, repo, "task-a", "session-a", "turn-a", base, base) + createInteractionMessage(t, repo, "perm-a", "task-a", "session-a", "turn-a", + models.MessageTypePermissionRequest, map[string]interface{}{"pending_id": "pending-a"}, base) + + seedPendingActionSession(t, repo, "task-b", "session-b") + createPendingActionTurn(t, repo, "task-b", "session-b", "turn-b", base, base) + createClarificationBundleMessage(t, repo, "clar-b", "task-b", "session-b", "turn-b", "pending-b", "q1", base) + + cases := []struct { + name string + filter models.PendingInteractionFilter + want []string + }{ + {"by session", models.PendingInteractionFilter{SessionIDs: []string{"session-a"}}, []string{"perm-a"}}, + {"by task", models.PendingInteractionFilter{TaskIDs: []string{"task-b"}}, []string{"clar-b"}}, + {"by kind", models.PendingInteractionFilter{ + Kinds: []string{string(models.InteractionKindClarification)}, + }, []string{"clar-b"}}, + {"session and task must both match", models.PendingInteractionFilter{ + SessionIDs: []string{"session-a"}, TaskIDs: []string{"task-b"}, + }, nil}, + {"unknown kind matches nothing", models.PendingInteractionFilter{Kinds: []string{"nope"}}, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := repo.ListPendingInteractions(ctx, tc.filter) + if err != nil { + t.Fatalf("ListPendingInteractions: %v", err) + } + ids := interactionMessageIDs(got) + if len(ids) != len(tc.want) { + t.Fatalf("ids = %v, want %v", ids, tc.want) + } + for i := range ids { + if ids[i] != tc.want[i] { + t.Fatalf("ids = %v, want %v", ids, tc.want) + } + } + }) + } +} + +// TestListPendingInteractionsReturnsWholeBundleWhenPartiallyAnswered pins the +// bundle contract: the agent stays blocked until EVERY question of a +// clarification is answered, so a bundle with one answered and one pending +// question must come back whole. Returning only the pending row would hand a +// caller a question set smaller than the answer path requires (the live +// clarification request still expects one answer per original question), and +// would disagree with GetInteraction, which resolves the bundle by pending id. +func TestListPendingInteractionsReturnsWholeBundleWhenPartiallyAnswered(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + base := time.Date(2026, time.August, 22, 14, 0, 0, 0, time.UTC) + + seedPendingActionSession(t, repo, "task-partial", "session-partial") + createPendingActionTurn(t, repo, "task-partial", "session-partial", "turn-partial", base, base) + createInteractionMessage(t, repo, "clar-answered", "task-partial", "session-partial", "turn-partial", + models.MessageTypeClarificationRequest, map[string]interface{}{ + "pending_id": "pending-partial", + "question_id": "q1", + "question_index": 0, + "status": string(models.InteractionStatusAnswered), + }, base) + createInteractionMessage(t, repo, "clar-pending", "task-partial", "session-partial", "turn-partial", + models.MessageTypeClarificationRequest, map[string]interface{}{ + "pending_id": "pending-partial", + "question_id": "q2", + "question_index": 1, + "status": string(models.InteractionStatusPending), + }, base.Add(time.Second)) + + // A fully answered bundle on the same turn must stay out entirely. + createInteractionMessage(t, repo, "clar-done", "task-partial", "session-partial", "turn-partial", + models.MessageTypeClarificationRequest, map[string]interface{}{ + "pending_id": "pending-done", + "question_id": "q1", + "question_index": 0, + "status": string(models.InteractionStatusAnswered), + }, base.Add(2*time.Second)) + + got, err := repo.ListPendingInteractions(ctx, models.PendingInteractionFilter{}) + if err != nil { + t.Fatalf("ListPendingInteractions: %v", err) + } + ids := interactionMessageIDs(got) + if len(ids) != 2 { + t.Fatalf("ids = %v, want the whole partially-answered bundle and nothing else", ids) + } + for _, want := range []string{"clar-answered", "clar-pending"} { + found := false + for _, id := range ids { + if id == want { + found = true + } + } + if !found { + t.Fatalf("ids = %v, missing %q from the pending bundle", ids, want) + } + } +} diff --git a/apps/backend/internal/task/service/service_interactions.go b/apps/backend/internal/task/service/service_interactions.go new file mode 100644 index 0000000000..f07ac43f32 --- /dev/null +++ b/apps/backend/internal/task/service/service_interactions.go @@ -0,0 +1,239 @@ +// service_interactions.go assembles the durable agent-interaction projection +// (ADR 0052) from the persisted permission_request / clarification_request +// message rows. It is the single place that knows how those rows encode an +// interaction, so the plugin Host interaction API and any future consumer read +// the same shape rather than re-deriving it from message metadata. +package service + +import ( + "context" + "sort" + + "github.com/kandev/kandev/internal/task/models" +) + +// ListPendingInteractions returns every interaction still owed a user response +// under filter, newest last. Clarification bundles are collapsed from their +// per-question rows into a single Interaction carrying every question. +func (s *Service) ListPendingInteractions( + ctx context.Context, + filter models.PendingInteractionFilter, +) ([]*models.Interaction, error) { + rows, err := s.messages.ListPendingInteractions(ctx, filter) + if err != nil { + return nil, err + } + return assembleInteractions(rows), nil +} + +// GetInteraction returns one interaction by its pending id whatever its +// current status — including terminal ones. That is what makes an +// event-driven plugin cache reconcilable: a replayed or missed event resolves +// to the current durable result rather than to "not found". Returns +// (nil, nil) when no message carries the id. +func (s *Service) GetInteraction(ctx context.Context, pendingID string) (*models.Interaction, error) { + if pendingID == "" { + return nil, nil + } + rows, err := s.messages.FindMessagesByPendingID(ctx, pendingID) + if err != nil { + return nil, err + } + interactions := assembleInteractions(rows) + if len(interactions) == 0 { + return nil, nil + } + return interactions[0], nil +} + +// assembleInteractions groups request messages by pending id and converts each +// group into one Interaction. Rows whose metadata carries no pending_id are +// dropped: without it there is no id a response could ever be keyed on. +func assembleInteractions(rows []*models.Message) []*models.Interaction { + grouped := make(map[string][]*models.Message) + order := make([]string, 0, len(rows)) + for _, row := range rows { + if row == nil { + continue + } + pendingID, _ := row.Metadata["pending_id"].(string) + if pendingID == "" { + continue + } + if _, seen := grouped[pendingID]; !seen { + order = append(order, pendingID) + } + grouped[pendingID] = append(grouped[pendingID], row) + } + out := make([]*models.Interaction, 0, len(order)) + for _, pendingID := range order { + if interaction := buildInteraction(pendingID, grouped[pendingID]); interaction != nil { + out = append(out, interaction) + } + } + return out +} + +func buildInteraction(pendingID string, rows []*models.Message) *models.Interaction { + if len(rows) == 0 { + return nil + } + switch rows[0].Type { + case models.MessageTypePermissionRequest: + return buildPermissionInteraction(pendingID, rows[0]) + case models.MessageTypeClarificationRequest: + return buildClarificationInteraction(pendingID, rows) + default: + return nil + } +} + +func buildPermissionInteraction(pendingID string, row *models.Message) *models.Interaction { + toolCallID, _ := row.Metadata["tool_call_id"].(string) + actionType, _ := row.Metadata["action_type"].(string) + requestID, _ := row.Metadata["request_id"].(string) + return &models.Interaction{ + ID: pendingID, + Kind: models.InteractionKindPermission, + TaskID: row.TaskID, + SessionID: row.TaskSessionID, + TurnID: row.TurnID, + Status: models.InteractionStatusFromMetadata(row.Metadata), + RequestID: requestID, + Title: row.Content, + ToolCallID: toolCallID, + ActionType: actionType, + Options: permissionOptionsFromMetadata(row.Metadata), + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +// buildClarificationInteraction collapses a bundle's per-question rows. The +// bundle's status is the status of its LEAST resolved question: a bundle is +// only terminal once every question is, because the agent stays blocked until +// the whole bundle is answered. Its timestamps span the bundle (earliest +// created, latest updated) so a cache can order it against sibling events. +func buildClarificationInteraction(pendingID string, rows []*models.Message) *models.Interaction { + ordered := make([]*models.Message, len(rows)) + copy(ordered, rows) + sort.SliceStable(ordered, func(i, j int) bool { + return questionIndex(ordered[i].Metadata) < questionIndex(ordered[j].Metadata) + }) + + first := ordered[0] + sharedContext, _ := first.Metadata["context"].(string) + interaction := &models.Interaction{ + ID: pendingID, + Kind: models.InteractionKindClarification, + TaskID: first.TaskID, + SessionID: first.TaskSessionID, + TurnID: first.TurnID, + Context: sharedContext, + CreatedAt: first.CreatedAt, + UpdatedAt: first.UpdatedAt, + } + + status := models.InteractionStatus("") + for _, row := range ordered { + interaction.Questions = append(interaction.Questions, questionFromMetadata(row.Metadata)) + if disconnected, _ := row.Metadata["agent_disconnected"].(bool); disconnected { + interaction.AgentDisconnected = true + } + rowStatus := models.InteractionStatusFromMetadata(row.Metadata) + if status == "" || (status.IsTerminal() && !rowStatus.IsTerminal()) { + status = rowStatus + } + if row.CreatedAt.Before(interaction.CreatedAt) { + interaction.CreatedAt = row.CreatedAt + } + if row.UpdatedAt.After(interaction.UpdatedAt) { + interaction.UpdatedAt = row.UpdatedAt + } + } + interaction.Status = status + if len(interaction.Questions) > 0 { + interaction.Title = interaction.Questions[0].Prompt + } + return interaction +} + +func questionIndex(metadata map[string]interface{}) int { + switch v := metadata["question_index"].(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + } + return 0 +} + +func questionFromMetadata(metadata map[string]interface{}) models.InteractionQuestion { + questionID, _ := metadata["question_id"].(string) + question := models.InteractionQuestion{ID: questionID} + data, ok := metadata["question"].(map[string]interface{}) + if !ok { + return question + } + if v, ok := data["prompt"].(string); ok { + question.Prompt = v + } + if v, ok := data["title"].(string); ok { + question.Title = v + } + if question.ID == "" { + if v, ok := data["id"].(string); ok { + question.ID = v + } + } + question.Options = clarificationOptionsFromMetadata(data["options"]) + return question +} + +func permissionOptionsFromMetadata(metadata map[string]interface{}) []models.InteractionOption { + raw, ok := metadata["options"].([]interface{}) + if !ok { + return nil + } + out := make([]models.InteractionOption, 0, len(raw)) + for _, entry := range raw { + data, ok := entry.(map[string]interface{}) + if !ok { + continue + } + id, _ := data["option_id"].(string) + if id == "" { + continue + } + // The persisted permission option uses ACP's "name" for its human + // label; the interaction contract calls every selectable label "label". + label, _ := data["name"].(string) + kind, _ := data["kind"].(string) + out = append(out, models.InteractionOption{ID: id, Label: label, Kind: kind}) + } + return out +} + +func clarificationOptionsFromMetadata(value interface{}) []models.InteractionOption { + raw, ok := value.([]interface{}) + if !ok { + return nil + } + out := make([]models.InteractionOption, 0, len(raw)) + for _, entry := range raw { + data, ok := entry.(map[string]interface{}) + if !ok { + continue + } + id, _ := data["option_id"].(string) + if id == "" { + continue + } + label, _ := data["label"].(string) + description, _ := data["description"].(string) + out = append(out, models.InteractionOption{ID: id, Label: label, Description: description}) + } + return out +} diff --git a/apps/backend/internal/task/service/service_interactions_test.go b/apps/backend/internal/task/service/service_interactions_test.go new file mode 100644 index 0000000000..2c3d70f444 --- /dev/null +++ b/apps/backend/internal/task/service/service_interactions_test.go @@ -0,0 +1,181 @@ +package service + +import ( + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +func permissionRow(pendingID, status string, createdAt time.Time) *models.Message { + metadata := map[string]interface{}{ + "pending_id": pendingID, + "tool_call_id": "call-9", + "action_type": "execute", + "options": []interface{}{ + map[string]interface{}{"option_id": "allow", "name": "Allow once", "kind": "allow_once"}, + map[string]interface{}{"option_id": "deny", "name": "Deny", "kind": "reject_once"}, + map[string]interface{}{"name": "Malformed option without an id"}, + }, + } + if status != "" { + metadata["status"] = status + } + return &models.Message{ + ID: "msg-" + pendingID, TaskSessionID: "session-1", TaskID: "task-1", TurnID: "turn-1", + Type: models.MessageTypePermissionRequest, Content: "Run rm -rf /tmp/x?", + Metadata: metadata, CreatedAt: createdAt, UpdatedAt: createdAt, + } +} + +func clarificationRow( + id, pendingID, questionID, status string, index int, disconnected bool, at time.Time, +) *models.Message { + metadata := map[string]interface{}{ + "pending_id": pendingID, + "question_id": questionID, + "question_index": index, + "context": "shared context", + "status": status, + "question": map[string]interface{}{ + "id": questionID, + "title": "Title " + questionID, + "prompt": "Prompt " + questionID, + "options": []interface{}{ + map[string]interface{}{"option_id": "a", "label": "A", "description": "first"}, + map[string]interface{}{"option_id": "b", "label": "B"}, + }, + }, + } + if disconnected { + metadata["agent_disconnected"] = true + } + return &models.Message{ + ID: id, TaskSessionID: "session-2", TaskID: "task-2", TurnID: "turn-2", + Type: models.MessageTypeClarificationRequest, Content: "Prompt " + questionID, + Metadata: metadata, CreatedAt: at, UpdatedAt: at, + } +} + +func TestAssembleInteractionsBuildsPermission(t *testing.T) { + at := time.Date(2026, time.August, 21, 8, 0, 0, 0, time.UTC) + got := assembleInteractions([]*models.Message{permissionRow("pending-1", "", at)}) + if len(got) != 1 { + t.Fatalf("interactions = %d, want 1", len(got)) + } + interaction := got[0] + if interaction.ID != "pending-1" || interaction.Kind != models.InteractionKindPermission { + t.Fatalf("id/kind = %q/%q", interaction.ID, interaction.Kind) + } + if interaction.Status != models.InteractionStatusPending { + t.Fatalf("status = %q, want pending (absent metadata.status is the pending sentinel)", interaction.Status) + } + if interaction.Title != "Run rm -rf /tmp/x?" || interaction.ToolCallID != "call-9" || interaction.ActionType != "execute" { + t.Fatalf("permission fields = %+v", interaction) + } + // The malformed third option carries no option_id, so no response could + // ever name it; surfacing it would offer a choice the agent cannot accept. + if len(interaction.Options) != 2 { + t.Fatalf("options = %+v, want the two id-carrying options", interaction.Options) + } + if interaction.Options[0].ID != "allow" || interaction.Options[0].Label != "Allow once" || + interaction.Options[0].Kind != "allow_once" { + t.Fatalf("option[0] = %+v (label must come from the persisted ACP \"name\")", interaction.Options[0]) + } + if interaction.Options[1].Kind != "reject_once" { + t.Fatalf("option[1].Kind = %q, want reject_once", interaction.Options[1].Kind) + } +} + +func TestAssembleInteractionsCollapsesClarificationBundle(t *testing.T) { + at := time.Date(2026, time.August, 21, 9, 0, 0, 0, time.UTC) + rows := []*models.Message{ + // Deliberately out of question_index order: the bundle must be + // reassembled by index, not by row order. + clarificationRow("m2", "pending-2", "q2", "pending", 1, true, at.Add(time.Minute)), + clarificationRow("m1", "pending-2", "q1", "answered", 0, false, at), + } + got := assembleInteractions(rows) + if len(got) != 1 { + t.Fatalf("interactions = %d, want 1 (bundle collapses to one interaction)", len(got)) + } + interaction := got[0] + if interaction.Kind != models.InteractionKindClarification { + t.Fatalf("kind = %q", interaction.Kind) + } + if len(interaction.Questions) != 2 || + interaction.Questions[0].ID != "q1" || interaction.Questions[1].ID != "q2" { + t.Fatalf("questions = %+v, want q1 then q2", interaction.Questions) + } + // The agent stays blocked until EVERY question is answered, so one + // answered question must not make the bundle read as resolved. + if interaction.Status != models.InteractionStatusPending { + t.Fatalf("status = %q, want pending while any question is unanswered", interaction.Status) + } + if !interaction.AgentDisconnected { + t.Fatal("AgentDisconnected = false, want true when any row is detached") + } + if !interaction.CreatedAt.Equal(at) || !interaction.UpdatedAt.Equal(at.Add(time.Minute)) { + t.Fatalf("timestamps = %v..%v, want the bundle span", interaction.CreatedAt, interaction.UpdatedAt) + } + if interaction.Context != "shared context" || interaction.Title != "Prompt q1" { + t.Fatalf("context/title = %q/%q", interaction.Context, interaction.Title) + } + if len(interaction.Questions[0].Options) != 2 || interaction.Questions[0].Options[0].Description != "first" { + t.Fatalf("question options = %+v", interaction.Questions[0].Options) + } +} + +func TestAssembleInteractionsTerminalBundle(t *testing.T) { + at := time.Date(2026, time.August, 21, 10, 0, 0, 0, time.UTC) + got := assembleInteractions([]*models.Message{ + clarificationRow("m1", "pending-3", "q1", "answered", 0, false, at), + clarificationRow("m2", "pending-3", "q2", "answered", 1, false, at), + }) + if len(got) != 1 { + t.Fatalf("interactions = %d, want 1 (bundle collapses to one interaction)", len(got)) + } + if got[0].Status != models.InteractionStatusAnswered { + t.Fatalf("status = %q, want answered once every question is", got[0].Status) + } + if !got[0].Status.IsTerminal() { + t.Fatal("answered bundle must report terminal") + } +} + +func TestAssembleInteractionsGroupsAndSkipsUnkeyedRows(t *testing.T) { + at := time.Date(2026, time.August, 21, 11, 0, 0, 0, time.UTC) + orphan := clarificationRow("orphan", "", "q1", "pending", 0, false, at) + delete(orphan.Metadata, "pending_id") + got := assembleInteractions([]*models.Message{ + permissionRow("pending-a", "", at), + orphan, + clarificationRow("m1", "pending-b", "q1", "pending", 0, false, at), + permissionRow("pending-c", "approved", at), + }) + if len(got) != 3 { + t.Fatalf("interactions = %d, want 3 (a row with no pending_id has no id to respond to)", len(got)) + } + if got[0].ID != "pending-a" || got[1].ID != "pending-b" || got[2].ID != "pending-c" { + t.Fatalf("ids = %q/%q/%q, want first-seen order preserved", got[0].ID, got[1].ID, got[2].ID) + } + if got[2].Status != models.InteractionStatusApproved { + t.Fatalf("terminal permission status = %q, want approved", got[2].Status) + } +} + +func TestInteractionStatusFromMetadataPreservesUnknownTerminalValues(t *testing.T) { + if got := models.InteractionStatusFromMetadata(nil); got != models.InteractionStatusPending { + t.Fatalf("nil metadata = %q, want pending", got) + } + if got := models.InteractionStatusFromMetadata(map[string]interface{}{"status": ""}); got != models.InteractionStatusPending { + t.Fatalf("empty status = %q, want pending", got) + } + // An unrecognized value must NOT collapse to pending: reporting an unknown + // terminal state as "still owed a response" is the false positive this + // whole projection exists to prevent. + got := models.InteractionStatusFromMetadata(map[string]interface{}{"status": "some_future_state"}) + if got != models.InteractionStatus("some_future_state") || !got.IsTerminal() { + t.Fatalf("unknown status = %q (terminal=%v), want it preserved and terminal", got, got.IsTerminal()) + } +} diff --git a/apps/backend/pkg/pluginsdk/host.go b/apps/backend/pkg/pluginsdk/host.go index d24c302460..447e5b29cf 100644 --- a/apps/backend/pkg/pluginsdk/host.go +++ b/apps/backend/pkg/pluginsdk/host.go @@ -236,6 +236,9 @@ type PluginOwnedTaskTreeManager interface { Delete(ctx context.Context, rootTaskID string) ([]string, error) } +// The third optional Host extension, InteractionHost (pending agent +// interactions and their responses), lives in interactions.go. + // PluginOwnedTaskTrees returns the optional provenance-safe task-tree manager. func PluginOwnedTaskTrees(host Host) (PluginOwnedTaskTreeManager, bool) { manager, ok := host.(PluginOwnedTaskTreeHost) diff --git a/apps/backend/pkg/pluginsdk/interactions.go b/apps/backend/pkg/pluginsdk/interactions.go new file mode 100644 index 0000000000..c6fda8ebba --- /dev/null +++ b/apps/backend/pkg/pluginsdk/interactions.go @@ -0,0 +1,572 @@ +// interactions.go is the Go-native side of the Host interaction API (ADR +// 0052): the durable record of every agent request still owed a human answer, +// plus the capability-gated writes that answer them. +// +// It exists because session state cannot answer "does a human owe this session +// something?". WAITING_FOR_INPUT also describes an ordinarily completed turn, +// so a state-only plugin reports attention nobody owes. Events alone cannot +// answer it either — a plugin may start after the request, restart, or drop an +// event. Only the durable record can, which is what ListPending returns and +// what Get resolves for any id, terminal ones included. +// +// Like ExecutorProfileHost and PluginOwnedTaskTreeHost, this is an OPTIONAL +// Host extension rather than a method on Host itself, so every existing +// Go-native Host implementation stays source-compatible. +package pluginsdk + +import ( + "context" + + pluginv1 "github.com/kandev/kandev/proto/kandev/plugin/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// Interaction kind values. A plugin should compare against these rather than +// bare strings; new kinds are additive. +const ( + InteractionKindPermission = "permission" + InteractionKindClarification = "clarification" +) + +// Interaction status values. Anything other than InteractionStatusPending is +// terminal: the interaction can no longer be answered, and a write against it +// returns a gRPC FailedPrecondition. +const ( + InteractionStatusPending = "pending" + InteractionStatusApproved = "approved" + InteractionStatusRejected = "rejected" + InteractionStatusAnswered = "answered" + InteractionStatusCancelled = "cancelled" + InteractionStatusExpired = "expired" +) + +// InteractionOption is one selectable response. Kind carries the ACP +// permission-option kind on a permission ("allow_once", "allow_always", +// "reject_once", "reject_always") and is empty on a clarification option; +// Description is the reverse. +type InteractionOption struct { + OptionID string + Label string + Kind string + Description string +} + +// InteractionQuestion is one question of a clarification bundle. Every +// question must be answered before the agent unblocks. +type InteractionQuestion struct { + ID string + Title string + Prompt string + Options []InteractionOption +} + +// Interaction is one thing a human still owes an answer to. ID is the pending +// id every response method keys on — the same id the permission and +// clarification bus events carry, so an event-driven cache can reconcile +// against a ListPending snapshot without inventing a correlation key. +// +// AgentDisconnected marks a still-pending clarification whose original waiter +// has gone away. It remains answerable (the answer resumes the session in a +// new turn), so it is NOT a resolution — treating it as one is exactly the +// false negative the durable record exists to prevent. +type Interaction struct { + ID string + Kind string // InteractionKind* constant + TaskID string + SessionID string + TurnID string + Status string // InteractionStatus* constant + Title string + Context string + ToolCallID string + ActionType string + Options []InteractionOption // permission options + Questions []InteractionQuestion // clarification questions + AgentDisconnected bool + CreatedAt string // RFC3339 + UpdatedAt string // RFC3339 +} + +// InteractionFilter narrows ListPending. SessionIDs and TaskIDs are ORed +// within each axis and ANDed across the two; an empty axis is unconstrained. +// Kinds narrows by interaction kind; empty means both. +type InteractionFilter struct { + SessionIDs []string + TaskIDs []string + Kinds []string +} + +// PermissionResponse names the option a user chose for a permission request. +// kandev derives the approve/deny outcome from that option's recorded kind, so +// a plugin cannot report an outcome the agent never offered. Set Cancelled to +// dismiss the request instead of choosing (OptionID must then be empty). +type PermissionResponse struct { + InteractionID string + OptionID string + Cancelled bool +} + +// ClarificationAnswer is a user's answer to one question of a bundle. +type ClarificationAnswer struct { + QuestionID string + SelectedOptions []string + CustomText string +} + +// ClarificationResponse answers a whole clarification bundle. Answers must +// contain exactly one entry per question in the interaction. +type ClarificationResponse struct { + InteractionID string + Answers []ClarificationAnswer +} + +// InteractionHost is the optional Host extension exposing Interactions(). +type InteractionHost interface { + Interactions() InteractionAccessor +} + +// InteractionAccessor is the accessor behind Host.Interactions(). Reads +// (ListPending/Get) require api_read:interactions; writes require +// api_write:interactions. The two gate independently, so an attention or inbox +// plugin can declare read-only. +// +// Every write is terminal-once: the first response wins, and a later attempt +// against an already-resolved interaction returns gRPC FailedPrecondition +// rather than dispatching a second response to the agent. An unknown id +// returns NotFound. Those two codes are what let a reconciling cache tell +// "someone else answered" apart from "my id is stale". +type InteractionAccessor interface { + // ListPending returns every interaction still owed a response, under the + // same turn/session authority kandev's own list surfaces use. + ListPending(ctx context.Context, filter InteractionFilter, page Page) ([]Interaction, *PageInfo, error) + + // Get resolves one interaction by pending id whatever its status, + // terminal ones included, so a replayed or missed event converges on the + // current durable result instead of disappearing. + Get(ctx context.Context, id string) (*Interaction, error) + + // RespondToPermission answers a permission request through kandev's + // orchestrator — the same path the native UI uses, so the agent unblocks + // and every surface converges. Returns the interaction's new terminal state. + RespondToPermission(ctx context.Context, in PermissionResponse) (*Interaction, error) + + // AnswerClarification answers every question of a clarification bundle. + AnswerClarification(ctx context.Context, in ClarificationResponse) (*Interaction, error) + + // CancelClarification declines a clarification bundle on the user's + // behalf, surfacing reason to the agent. Works whether or not the original + // waiter is still parked. + CancelClarification(ctx context.Context, id, reason string) (*Interaction, error) +} + +// Interactions returns the optional interaction accessor, and false when the +// host predates the extension. +func Interactions(host Host) (InteractionAccessor, bool) { + provider, ok := host.(InteractionHost) + if !ok { + return nil, false + } + return provider.Interactions(), true +} + +// ── proto conversion ──────────────────────────────────────────────────────── + +func (o InteractionOption) toProto() *pluginv1.InteractionOption { + return &pluginv1.InteractionOption{ + OptionId: o.OptionID, Label: o.Label, Kind: o.Kind, Description: o.Description, + } +} + +func interactionOptionFromProto(p *pluginv1.InteractionOption) InteractionOption { + if p == nil { + return InteractionOption{} + } + return InteractionOption{ + OptionID: p.GetOptionId(), Label: p.GetLabel(), Kind: p.GetKind(), Description: p.GetDescription(), + } +} + +func interactionOptionsToProto(items []InteractionOption) []*pluginv1.InteractionOption { + if len(items) == 0 { + return nil + } + out := make([]*pluginv1.InteractionOption, len(items)) + for i := range items { + out[i] = items[i].toProto() + } + return out +} + +func interactionOptionsFromProto(items []*pluginv1.InteractionOption) []InteractionOption { + if len(items) == 0 { + return nil + } + out := make([]InteractionOption, len(items)) + for i, item := range items { + out[i] = interactionOptionFromProto(item) + } + return out +} + +func (q InteractionQuestion) toProto() *pluginv1.InteractionQuestion { + return &pluginv1.InteractionQuestion{ + Id: q.ID, Title: q.Title, Prompt: q.Prompt, Options: interactionOptionsToProto(q.Options), + } +} + +func interactionQuestionFromProto(p *pluginv1.InteractionQuestion) InteractionQuestion { + if p == nil { + return InteractionQuestion{} + } + return InteractionQuestion{ + ID: p.GetId(), + Title: p.GetTitle(), + Prompt: p.GetPrompt(), + Options: interactionOptionsFromProto(p.GetOptions()), + } +} + +func (i Interaction) toProto() *pluginv1.Interaction { + questions := make([]*pluginv1.InteractionQuestion, 0, len(i.Questions)) + for _, question := range i.Questions { + questions = append(questions, question.toProto()) + } + if len(questions) == 0 { + questions = nil + } + return &pluginv1.Interaction{ + Id: i.ID, + Kind: i.Kind, + TaskId: i.TaskID, + SessionId: i.SessionID, + TurnId: i.TurnID, + Status: i.Status, + Title: i.Title, + Context: i.Context, + ToolCallId: i.ToolCallID, + ActionType: i.ActionType, + Options: interactionOptionsToProto(i.Options), + Questions: questions, + AgentDisconnected: i.AgentDisconnected, + CreatedAt: i.CreatedAt, + UpdatedAt: i.UpdatedAt, + } +} + +func interactionFromProto(p *pluginv1.Interaction) Interaction { + if p == nil { + return Interaction{} + } + protoQuestions := p.GetQuestions() + var questions []InteractionQuestion + if len(protoQuestions) > 0 { + questions = make([]InteractionQuestion, len(protoQuestions)) + for i, question := range protoQuestions { + questions[i] = interactionQuestionFromProto(question) + } + } + return Interaction{ + ID: p.GetId(), + Kind: p.GetKind(), + TaskID: p.GetTaskId(), + SessionID: p.GetSessionId(), + TurnID: p.GetTurnId(), + Status: p.GetStatus(), + Title: p.GetTitle(), + Context: p.GetContext(), + ToolCallID: p.GetToolCallId(), + ActionType: p.GetActionType(), + Options: interactionOptionsFromProto(p.GetOptions()), + Questions: questions, + AgentDisconnected: p.GetAgentDisconnected(), + CreatedAt: p.GetCreatedAt(), + UpdatedAt: p.GetUpdatedAt(), + } +} + +func interactionsToProto(items []Interaction) []*pluginv1.Interaction { + out := make([]*pluginv1.Interaction, len(items)) + for i := range items { + out[i] = items[i].toProto() + } + return out +} + +func interactionsFromProto(items []*pluginv1.Interaction) []Interaction { + out := make([]Interaction, len(items)) + for i, item := range items { + out[i] = interactionFromProto(item) + } + return out +} + +func (f InteractionFilter) toProto() *pluginv1.InteractionFilter { + return &pluginv1.InteractionFilter{ + SessionIds: f.SessionIDs, TaskIds: f.TaskIDs, Kinds: f.Kinds, + } +} + +func interactionFilterFromProto(p *pluginv1.InteractionFilter) InteractionFilter { + if p == nil { + return InteractionFilter{} + } + return InteractionFilter{ + SessionIDs: p.GetSessionIds(), TaskIDs: p.GetTaskIds(), Kinds: p.GetKinds(), + } +} + +func clarificationAnswersToProto(items []ClarificationAnswer) []*pluginv1.ClarificationAnswer { + out := make([]*pluginv1.ClarificationAnswer, len(items)) + for i, item := range items { + out[i] = &pluginv1.ClarificationAnswer{ + QuestionId: item.QuestionID, + SelectedOptions: item.SelectedOptions, + CustomText: item.CustomText, + } + } + return out +} + +func clarificationAnswersFromProto(items []*pluginv1.ClarificationAnswer) []ClarificationAnswer { + out := make([]ClarificationAnswer, len(items)) + for i, item := range items { + out[i] = ClarificationAnswer{ + QuestionID: item.GetQuestionId(), + SelectedOptions: item.GetSelectedOptions(), + CustomText: item.GetCustomText(), + } + } + return out +} + +// ── plugin-side client ────────────────────────────────────────────────────── + +type grpcInteractionAccessor struct { + client pluginv1.HostClient +} + +func (a grpcInteractionAccessor) ListPending( + ctx context.Context, filter InteractionFilter, page Page, +) ([]Interaction, *PageInfo, error) { + resp, err := a.client.ListPendingInteractions(ctx, &pluginv1.ListPendingInteractionsRequest{ + Filter: filter.toProto(), Page: page.toProto(), + }) + if err != nil { + return nil, nil, err + } + return interactionsFromProto(resp.GetInteractions()), pageInfoFromProto(resp.GetPageInfo()), nil +} + +func (a grpcInteractionAccessor) Get(ctx context.Context, id string) (*Interaction, error) { + resp, err := a.client.GetInteraction(ctx, &pluginv1.GetInteractionRequest{Id: id}) + if err != nil { + return nil, err + } + interaction := interactionFromProto(resp.GetInteraction()) + return &interaction, nil +} + +func (a grpcInteractionAccessor) RespondToPermission( + ctx context.Context, in PermissionResponse, +) (*Interaction, error) { + resp, err := a.client.RespondToPermission(ctx, &pluginv1.RespondToPermissionRequest{ + Id: in.InteractionID, OptionId: in.OptionID, Cancelled: in.Cancelled, + }) + if err != nil { + return nil, err + } + interaction := interactionFromProto(resp.GetInteraction()) + return &interaction, nil +} + +func (a grpcInteractionAccessor) AnswerClarification( + ctx context.Context, in ClarificationResponse, +) (*Interaction, error) { + resp, err := a.client.AnswerClarification(ctx, &pluginv1.AnswerClarificationRequest{ + Id: in.InteractionID, Answers: clarificationAnswersToProto(in.Answers), + }) + if err != nil { + return nil, err + } + interaction := interactionFromProto(resp.GetInteraction()) + return &interaction, nil +} + +func (a grpcInteractionAccessor) CancelClarification( + ctx context.Context, id, reason string, +) (*Interaction, error) { + resp, err := a.client.CancelClarification(ctx, &pluginv1.CancelClarificationRequest{ + Id: id, Reason: reason, + }) + if err != nil { + return nil, err + } + interaction := interactionFromProto(resp.GetInteraction()) + return &interaction, nil +} + +// ── kandev-side server ────────────────────────────────────────────────────── + +// interactionAccessor resolves the impl's optional interaction extension, +// returning the same Unimplemented error the other optional extensions use +// when the host predates it. +func (s *grpcHostServer) interactionAccessor() (InteractionAccessor, error) { + provider, ok := s.impl.(InteractionHost) + if !ok { + return nil, errUnimplementedHostData(resourceNameInteractions) + } + return provider.Interactions(), nil +} + +// resourceNameInteractions is the resource label in the Unimplemented error; +// it mirrors the api_read:interactions / api_write:interactions capability +// names the kandev-side host gates on. +const resourceNameInteractions = "interactions" + +func (s *grpcHostServer) ListPendingInteractions( + ctx context.Context, req *pluginv1.ListPendingInteractionsRequest, +) (*pluginv1.ListPendingInteractionsResponse, error) { + accessor, err := s.interactionAccessor() + if err != nil { + return nil, err + } + interactions, pageInfo, err := accessor.ListPending( + ctx, interactionFilterFromProto(req.GetFilter()), pageFromProto(req.GetPage()), + ) + if err != nil { + return nil, err + } + return &pluginv1.ListPendingInteractionsResponse{ + Interactions: interactionsToProto(interactions), PageInfo: pageInfo.toProto(), + }, nil +} + +func (s *grpcHostServer) GetInteraction( + ctx context.Context, req *pluginv1.GetInteractionRequest, +) (*pluginv1.GetInteractionResponse, error) { + accessor, err := s.interactionAccessor() + if err != nil { + return nil, err + } + interaction, err := accessor.Get(ctx, req.GetId()) + if err != nil { + return nil, err + } + if interaction == nil { + return nil, interactionNotFound(req.GetId()) + } + return &pluginv1.GetInteractionResponse{Interaction: interaction.toProto()}, nil +} + +func (s *grpcHostServer) RespondToPermission( + ctx context.Context, req *pluginv1.RespondToPermissionRequest, +) (*pluginv1.RespondToPermissionResponse, error) { + accessor, err := s.interactionAccessor() + if err != nil { + return nil, err + } + interaction, err := accessor.RespondToPermission(ctx, PermissionResponse{ + InteractionID: req.GetId(), OptionID: req.GetOptionId(), Cancelled: req.GetCancelled(), + }) + if err != nil { + return nil, err + } + if interaction == nil { + return nil, interactionNotFound(req.GetId()) + } + return &pluginv1.RespondToPermissionResponse{Interaction: interaction.toProto()}, nil +} + +func (s *grpcHostServer) AnswerClarification( + ctx context.Context, req *pluginv1.AnswerClarificationRequest, +) (*pluginv1.AnswerClarificationResponse, error) { + accessor, err := s.interactionAccessor() + if err != nil { + return nil, err + } + interaction, err := accessor.AnswerClarification(ctx, ClarificationResponse{ + InteractionID: req.GetId(), Answers: clarificationAnswersFromProto(req.GetAnswers()), + }) + if err != nil { + return nil, err + } + if interaction == nil { + return nil, interactionNotFound(req.GetId()) + } + return &pluginv1.AnswerClarificationResponse{Interaction: interaction.toProto()}, nil +} + +func (s *grpcHostServer) CancelClarification( + ctx context.Context, req *pluginv1.CancelClarificationRequest, +) (*pluginv1.CancelClarificationResponse, error) { + accessor, err := s.interactionAccessor() + if err != nil { + return nil, err + } + interaction, err := accessor.CancelClarification(ctx, req.GetId(), req.GetReason()) + if err != nil { + return nil, err + } + if interaction == nil { + return nil, interactionNotFound(req.GetId()) + } + return &pluginv1.CancelClarificationResponse{Interaction: interaction.toProto()}, nil +} + +// ── unimplemented default ─────────────────────────────────────────────────── + +// Interactions is the embeddable default on UnimplementedHostData, so a Host +// that has not wired the interaction API still satisfies InteractionHost and +// answers Unimplemented rather than panicking. +func (UnimplementedHostData) Interactions() InteractionAccessor { + return unimplementedInteractionAccessor{} +} + +type unimplementedInteractionAccessor struct{} + +func (unimplementedInteractionAccessor) ListPending( + context.Context, InteractionFilter, Page, +) ([]Interaction, *PageInfo, error) { + return nil, nil, errUnimplementedHostData(resourceNameInteractions) +} + +func (unimplementedInteractionAccessor) Get(context.Context, string) (*Interaction, error) { + return nil, errUnimplementedHostData(resourceNameInteractions) +} + +func (unimplementedInteractionAccessor) RespondToPermission( + context.Context, PermissionResponse, +) (*Interaction, error) { + return nil, errUnimplementedHostData(resourceNameInteractions) +} + +func (unimplementedInteractionAccessor) AnswerClarification( + context.Context, ClarificationResponse, +) (*Interaction, error) { + return nil, errUnimplementedHostData(resourceNameInteractions) +} + +func (unimplementedInteractionAccessor) CancelClarification( + context.Context, string, string, +) (*Interaction, error) { + return nil, errUnimplementedHostData(resourceNameInteractions) +} + +// grpcHostClient satisfies the optional extension so a plugin reaches the +// Interactions accessor through the same pluginsdk.Interactions(host) helper +// kandev-side callers use. +func (h *grpcHostClient) Interactions() InteractionAccessor { + return grpcInteractionAccessor{client: h.client} +} + +var _ InteractionHost = (*grpcHostClient)(nil) + +// interactionNotFound is the error every interaction RPC returns for an id +// that resolves to nothing. Defense-in-depth for the trust boundary: the +// in-tree host never returns (nil, nil) on success, but a custom or test host +// might, and a plugin should get NotFound rather than a zero-value DTO that +// reads as a real, pending interaction. +func interactionNotFound(id string) error { + return status.Errorf(codes.NotFound, "interaction %q not found", id) +} diff --git a/apps/backend/pkg/pluginsdk/interactions_test.go b/apps/backend/pkg/pluginsdk/interactions_test.go new file mode 100644 index 0000000000..f03839d4fd --- /dev/null +++ b/apps/backend/pkg/pluginsdk/interactions_test.go @@ -0,0 +1,241 @@ +package pluginsdk + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// interactionRecordingHost is a Go-native Host whose interaction accessor is +// backed by in-memory fixtures, used to prove grpcHostServer -> grpcHostClient +// reachability for the interaction RPCs. Everything else is inherited +// (unimplemented) from UnimplementedHostData. +type interactionRecordingHost struct { + UnimplementedHostData + + pending []Interaction + pageInfo *PageInfo + byID map[string]Interaction + lastFilter InteractionFilter + lastPage Page + lastPerm PermissionResponse + lastAnswer ClarificationResponse + lastCancel [2]string +} + +func (h *interactionRecordingHost) GetState(context.Context, string, string, string) (map[string]any, bool, error) { + return nil, false, nil +} +func (h *interactionRecordingHost) SetState(context.Context, string, string, string, map[string]any) error { + return nil +} +func (h *interactionRecordingHost) DeleteState(context.Context, string, string, string) error { + return nil +} +func (h *interactionRecordingHost) ListState(context.Context, string, string) ([]StateEntry, error) { + return nil, nil +} +func (h *interactionRecordingHost) GetConfig(context.Context) (map[string]any, error) { + return map[string]any{}, nil +} +func (h *interactionRecordingHost) RevealSecret(context.Context, string) (string, error) { + return "", nil +} +func (h *interactionRecordingHost) GetSecret(context.Context, string) (string, bool, error) { + return "", false, nil +} +func (h *interactionRecordingHost) SetSecret(context.Context, string, string) error { return nil } +func (h *interactionRecordingHost) DeleteSecret(context.Context, string) error { return nil } +func (h *interactionRecordingHost) EmitEvent(context.Context, string, map[string]any) error { + return nil +} + +func (h *interactionRecordingHost) Interactions() InteractionAccessor { + return &recordingInteractionAccessor{host: h} +} + +type recordingInteractionAccessor struct{ host *interactionRecordingHost } + +func (a *recordingInteractionAccessor) ListPending( + _ context.Context, filter InteractionFilter, page Page, +) ([]Interaction, *PageInfo, error) { + a.host.lastFilter, a.host.lastPage = filter, page + return a.host.pending, a.host.pageInfo, nil +} + +func (a *recordingInteractionAccessor) Get(_ context.Context, id string) (*Interaction, error) { + interaction, ok := a.host.byID[id] + if !ok { + return nil, status.Errorf(codes.NotFound, "interaction %q not found", id) + } + return &interaction, nil +} + +func (a *recordingInteractionAccessor) RespondToPermission( + _ context.Context, in PermissionResponse, +) (*Interaction, error) { + a.host.lastPerm = in + interaction := a.host.byID[in.InteractionID] + interaction.Status = InteractionStatusApproved + return &interaction, nil +} + +func (a *recordingInteractionAccessor) AnswerClarification( + _ context.Context, in ClarificationResponse, +) (*Interaction, error) { + a.host.lastAnswer = in + interaction := a.host.byID[in.InteractionID] + interaction.Status = InteractionStatusAnswered + return &interaction, nil +} + +func (a *recordingInteractionAccessor) CancelClarification( + _ context.Context, id, reason string, +) (*Interaction, error) { + a.host.lastCancel = [2]string{id, reason} + interaction := a.host.byID[id] + interaction.Status = InteractionStatusRejected + return &interaction, nil +} + +func fixtureInteractions() (Interaction, Interaction) { + permission := Interaction{ + ID: "pending-perm", Kind: InteractionKindPermission, + TaskID: "task-1", SessionID: "session-1", TurnID: "turn-1", + Status: InteractionStatusPending, Title: "Run a command?", + ToolCallID: "call-1", ActionType: "execute", + Options: []InteractionOption{ + {OptionID: "allow", Label: "Allow once", Kind: "allow_once"}, + {OptionID: "deny", Label: "Deny", Kind: "reject_once"}, + }, + CreatedAt: "2026-08-21T12:00:00Z", UpdatedAt: "2026-08-21T12:00:00Z", + } + clarification := Interaction{ + ID: "pending-clar", Kind: InteractionKindClarification, + TaskID: "task-2", SessionID: "session-2", TurnID: "turn-2", + Status: InteractionStatusPending, Title: "Which database?", + Context: "migration planning", AgentDisconnected: true, + Questions: []InteractionQuestion{{ + ID: "q1", Title: "DB", Prompt: "Which database?", + Options: []InteractionOption{ + {OptionID: "pg", Label: "Postgres", Description: "the default"}, + {OptionID: "sqlite", Label: "SQLite"}, + }, + }}, + CreatedAt: "2026-08-21T13:00:00Z", UpdatedAt: "2026-08-21T13:05:00Z", + } + return permission, clarification +} + +func newInteractionHostPair(t *testing.T) (*interactionRecordingHost, InteractionAccessor) { + t.Helper() + permission, clarification := fixtureInteractions() + impl := &interactionRecordingHost{ + pending: []Interaction{permission, clarification}, + pageInfo: &PageInfo{NextCursor: "2", HasMore: true}, + byID: map[string]Interaction{ + permission.ID: permission, clarification.ID: clarification, + }, + } + host := dialHostOverBufconn(t, impl) + accessor, ok := Interactions(host) + require.True(t, ok, "the wire client must expose the interaction extension") + return impl, accessor +} + +// TestInteractions_ListPendingRoundTrip is the field-by-field wire proof: a +// dropped field in either conversion direction would silently strip the very +// options and questions a plugin needs to render a valid response. +func TestInteractions_ListPendingRoundTrip(t *testing.T) { + impl, accessor := newInteractionHostPair(t) + wantPermission, wantClarification := fixtureInteractions() + + got, info, err := accessor.ListPending(context.Background(), InteractionFilter{ + SessionIDs: []string{"session-1"}, TaskIDs: []string{"task-1"}, Kinds: []string{InteractionKindPermission}, + }, Page{Limit: 10, Cursor: "1"}) + require.NoError(t, err) + require.Equal(t, []Interaction{wantPermission, wantClarification}, got) + require.Equal(t, &PageInfo{NextCursor: "2", HasMore: true}, info) + + require.Equal(t, []string{"session-1"}, impl.lastFilter.SessionIDs) + require.Equal(t, []string{"task-1"}, impl.lastFilter.TaskIDs) + require.Equal(t, []string{InteractionKindPermission}, impl.lastFilter.Kinds) + require.Equal(t, Page{Limit: 10, Cursor: "1"}, impl.lastPage) +} + +func TestInteractions_GetRoundTripAndNotFound(t *testing.T) { + _, accessor := newInteractionHostPair(t) + _, wantClarification := fixtureInteractions() + + got, err := accessor.Get(context.Background(), "pending-clar") + require.NoError(t, err) + require.Equal(t, wantClarification, *got) + + _, err = accessor.Get(context.Background(), "nope") + require.Equal(t, codes.NotFound, status.Code(err)) +} + +func TestInteractions_WritesRoundTrip(t *testing.T) { + impl, accessor := newInteractionHostPair(t) + ctx := context.Background() + + permission, err := accessor.RespondToPermission(ctx, PermissionResponse{ + InteractionID: "pending-perm", OptionID: "deny", + }) + require.NoError(t, err) + require.Equal(t, InteractionStatusApproved, permission.Status) + require.Equal(t, PermissionResponse{InteractionID: "pending-perm", OptionID: "deny"}, impl.lastPerm) + + answered, err := accessor.AnswerClarification(ctx, ClarificationResponse{ + InteractionID: "pending-clar", + Answers: []ClarificationAnswer{ + {QuestionID: "q1", SelectedOptions: []string{"pg"}, CustomText: "prefer managed"}, + }, + }) + require.NoError(t, err) + require.Equal(t, InteractionStatusAnswered, answered.Status) + require.Equal(t, "pending-clar", impl.lastAnswer.InteractionID) + require.Equal(t, []ClarificationAnswer{ + {QuestionID: "q1", SelectedOptions: []string{"pg"}, CustomText: "prefer managed"}, + }, impl.lastAnswer.Answers) + + cancelled, err := accessor.CancelClarification(ctx, "pending-clar", "user stepped away") + require.NoError(t, err) + require.Equal(t, InteractionStatusRejected, cancelled.Status) + require.Equal(t, [2]string{"pending-clar", "user stepped away"}, impl.lastCancel) +} + +// TestInteractions_HostWithoutExtensionIsUnimplemented covers the +// source-compatibility promise: a Host implementation written before this +// extension still serves every other RPC, and the interaction RPCs answer +// Unimplemented rather than panicking on a nil accessor. +func TestInteractions_HostWithoutExtensionIsUnimplemented(t *testing.T) { + host := dialHostOverBufconn(t, &recordingHost{}) + accessor, ok := Interactions(host) + require.True(t, ok) + + _, _, err := accessor.ListPending(context.Background(), InteractionFilter{}, Page{}) + require.Equal(t, codes.Unimplemented, status.Code(err)) + + _, err = accessor.RespondToPermission(context.Background(), PermissionResponse{InteractionID: "x"}) + require.Equal(t, codes.Unimplemented, status.Code(err)) +} + +func TestUnimplementedHostDataInteractionsReturnsUnimplemented(t *testing.T) { + accessor := UnimplementedHostData{}.Interactions() + ctx := context.Background() + + _, _, err := accessor.ListPending(ctx, InteractionFilter{}, Page{}) + require.Equal(t, codes.Unimplemented, status.Code(err)) + _, err = accessor.Get(ctx, "x") + require.Equal(t, codes.Unimplemented, status.Code(err)) + _, err = accessor.RespondToPermission(ctx, PermissionResponse{}) + require.Equal(t, codes.Unimplemented, status.Code(err)) + _, err = accessor.AnswerClarification(ctx, ClarificationResponse{}) + require.Equal(t, codes.Unimplemented, status.Code(err)) + _, err = accessor.CancelClarification(ctx, "x", "") + require.Equal(t, codes.Unimplemented, status.Code(err)) +} diff --git a/apps/backend/proto/kandev/plugin/v1/plugin.pb.go b/apps/backend/proto/kandev/plugin/v1/plugin.pb.go index 33b8d572ca..b6c11038a9 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin.pb.go +++ b/apps/backend/proto/kandev/plugin/v1/plugin.pb.go @@ -4793,6 +4793,932 @@ func (x *ListMessagesResponse) GetPageInfo() *PageInfo { return nil } +// ── Pending interactions (api_read/api_write:interactions) ─────────────────── +// +// One Interaction is one thing a human still owes an answer to: a tool +// permission request, or a whole clarification bundle (its per-question rows +// collapsed into `questions`). `id` is the pending id every response RPC keys +// on, and the same id the permission_request.received / clarification.* bus +// events and the persisted request messages carry — that shared key is what an +// event-driven cache reconciles its snapshot against. +type InteractionOption struct { + state protoimpl.MessageState `protogen:"open.v1"` + OptionId string `protobuf:"bytes,1,opt,name=option_id,json=optionId,proto3" json:"option_id,omitempty"` + Label string `protobuf:"bytes,2,opt,name=label,proto3" json:"label,omitempty"` + // ACP permission-option kind ("allow_once" | "allow_always" | + // "reject_once" | "reject_always") on a permission; empty on a + // clarification option. + Kind string `protobuf:"bytes,3,opt,name=kind,proto3" json:"kind,omitempty"` + // Longer explanation of a clarification option; empty on a permission. + Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InteractionOption) Reset() { + *x = InteractionOption{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[78] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InteractionOption) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InteractionOption) ProtoMessage() {} + +func (x *InteractionOption) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[78] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InteractionOption.ProtoReflect.Descriptor instead. +func (*InteractionOption) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{78} +} + +func (x *InteractionOption) GetOptionId() string { + if x != nil { + return x.OptionId + } + return "" +} + +func (x *InteractionOption) GetLabel() string { + if x != nil { + return x.Label + } + return "" +} + +func (x *InteractionOption) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *InteractionOption) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +type InteractionQuestion struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` + Prompt string `protobuf:"bytes,3,opt,name=prompt,proto3" json:"prompt,omitempty"` + Options []*InteractionOption `protobuf:"bytes,4,rep,name=options,proto3" json:"options,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InteractionQuestion) Reset() { + *x = InteractionQuestion{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[79] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InteractionQuestion) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InteractionQuestion) ProtoMessage() {} + +func (x *InteractionQuestion) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[79] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InteractionQuestion.ProtoReflect.Descriptor instead. +func (*InteractionQuestion) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{79} +} + +func (x *InteractionQuestion) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *InteractionQuestion) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *InteractionQuestion) GetPrompt() string { + if x != nil { + return x.Prompt + } + return "" +} + +func (x *InteractionQuestion) GetOptions() []*InteractionOption { + if x != nil { + return x.Options + } + return nil +} + +type Interaction struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // pending id + Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` // "permission" | "clarification" + TaskId string `protobuf:"bytes,3,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + SessionId string `protobuf:"bytes,4,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + TurnId string `protobuf:"bytes,5,opt,name=turn_id,json=turnId,proto3" json:"turn_id,omitempty"` + // "pending" | "approved" | "rejected" | "answered" | "cancelled" | "expired". + // Anything other than "pending" is terminal and no longer answerable. A + // clarification bundle is only terminal once every question is. + Status string `protobuf:"bytes,6,opt,name=status,proto3" json:"status,omitempty"` + Title string `protobuf:"bytes,7,opt,name=title,proto3" json:"title,omitempty"` // permission prompt, or the bundle's first question + Context string `protobuf:"bytes,8,opt,name=context,proto3" json:"context,omitempty"` // shared clarification context; empty on a permission + ToolCallId string `protobuf:"bytes,9,opt,name=tool_call_id,json=toolCallId,proto3" json:"tool_call_id,omitempty"` + ActionType string `protobuf:"bytes,10,opt,name=action_type,json=actionType,proto3" json:"action_type,omitempty"` + Options []*InteractionOption `protobuf:"bytes,11,rep,name=options,proto3" json:"options,omitempty"` // permission options + Questions []*InteractionQuestion `protobuf:"bytes,12,rep,name=questions,proto3" json:"questions,omitempty"` // clarification questions + // A still-pending clarification whose original waiter has gone away (the + // agent moved on, or kandev restarted). Still answerable — the answer + // resumes the session in a NEW turn — so this is not a resolution. + AgentDisconnected bool `protobuf:"varint,13,opt,name=agent_disconnected,json=agentDisconnected,proto3" json:"agent_disconnected,omitempty"` + CreatedAt string `protobuf:"bytes,14,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` // RFC3339 + UpdatedAt string `protobuf:"bytes,15,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` // RFC3339 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Interaction) Reset() { + *x = Interaction{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[80] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Interaction) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Interaction) ProtoMessage() {} + +func (x *Interaction) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[80] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Interaction.ProtoReflect.Descriptor instead. +func (*Interaction) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{80} +} + +func (x *Interaction) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Interaction) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *Interaction) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *Interaction) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *Interaction) GetTurnId() string { + if x != nil { + return x.TurnId + } + return "" +} + +func (x *Interaction) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *Interaction) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *Interaction) GetContext() string { + if x != nil { + return x.Context + } + return "" +} + +func (x *Interaction) GetToolCallId() string { + if x != nil { + return x.ToolCallId + } + return "" +} + +func (x *Interaction) GetActionType() string { + if x != nil { + return x.ActionType + } + return "" +} + +func (x *Interaction) GetOptions() []*InteractionOption { + if x != nil { + return x.Options + } + return nil +} + +func (x *Interaction) GetQuestions() []*InteractionQuestion { + if x != nil { + return x.Questions + } + return nil +} + +func (x *Interaction) GetAgentDisconnected() bool { + if x != nil { + return x.AgentDisconnected + } + return false +} + +func (x *Interaction) GetCreatedAt() string { + if x != nil { + return x.CreatedAt + } + return "" +} + +func (x *Interaction) GetUpdatedAt() string { + if x != nil { + return x.UpdatedAt + } + return "" +} + +type InteractionFilter struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionIds []string `protobuf:"bytes,1,rep,name=session_ids,json=sessionIds,proto3" json:"session_ids,omitempty"` // empty → not constrained by session + TaskIds []string `protobuf:"bytes,2,rep,name=task_ids,json=taskIds,proto3" json:"task_ids,omitempty"` // empty → not constrained by task + Kinds []string `protobuf:"bytes,3,rep,name=kinds,proto3" json:"kinds,omitempty"` // empty → both kinds + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InteractionFilter) Reset() { + *x = InteractionFilter{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[81] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InteractionFilter) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InteractionFilter) ProtoMessage() {} + +func (x *InteractionFilter) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[81] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InteractionFilter.ProtoReflect.Descriptor instead. +func (*InteractionFilter) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{81} +} + +func (x *InteractionFilter) GetSessionIds() []string { + if x != nil { + return x.SessionIds + } + return nil +} + +func (x *InteractionFilter) GetTaskIds() []string { + if x != nil { + return x.TaskIds + } + return nil +} + +func (x *InteractionFilter) GetKinds() []string { + if x != nil { + return x.Kinds + } + return nil +} + +type ListPendingInteractionsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Filter *InteractionFilter `protobuf:"bytes,1,opt,name=filter,proto3" json:"filter,omitempty"` + Page *Page `protobuf:"bytes,2,opt,name=page,proto3" json:"page,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListPendingInteractionsRequest) Reset() { + *x = ListPendingInteractionsRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[82] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListPendingInteractionsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListPendingInteractionsRequest) ProtoMessage() {} + +func (x *ListPendingInteractionsRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[82] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListPendingInteractionsRequest.ProtoReflect.Descriptor instead. +func (*ListPendingInteractionsRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{82} +} + +func (x *ListPendingInteractionsRequest) GetFilter() *InteractionFilter { + if x != nil { + return x.Filter + } + return nil +} + +func (x *ListPendingInteractionsRequest) GetPage() *Page { + if x != nil { + return x.Page + } + return nil +} + +type ListPendingInteractionsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Interactions []*Interaction `protobuf:"bytes,1,rep,name=interactions,proto3" json:"interactions,omitempty"` + PageInfo *PageInfo `protobuf:"bytes,2,opt,name=page_info,json=pageInfo,proto3" json:"page_info,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListPendingInteractionsResponse) Reset() { + *x = ListPendingInteractionsResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[83] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListPendingInteractionsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListPendingInteractionsResponse) ProtoMessage() {} + +func (x *ListPendingInteractionsResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[83] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListPendingInteractionsResponse.ProtoReflect.Descriptor instead. +func (*ListPendingInteractionsResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{83} +} + +func (x *ListPendingInteractionsResponse) GetInteractions() []*Interaction { + if x != nil { + return x.Interactions + } + return nil +} + +func (x *ListPendingInteractionsResponse) GetPageInfo() *PageInfo { + if x != nil { + return x.PageInfo + } + return nil +} + +type GetInteractionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetInteractionRequest) Reset() { + *x = GetInteractionRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[84] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetInteractionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetInteractionRequest) ProtoMessage() {} + +func (x *GetInteractionRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[84] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetInteractionRequest.ProtoReflect.Descriptor instead. +func (*GetInteractionRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{84} +} + +func (x *GetInteractionRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type GetInteractionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Interaction *Interaction `protobuf:"bytes,1,opt,name=interaction,proto3" json:"interaction,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetInteractionResponse) Reset() { + *x = GetInteractionResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[85] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetInteractionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetInteractionResponse) ProtoMessage() {} + +func (x *GetInteractionResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[85] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetInteractionResponse.ProtoReflect.Descriptor instead. +func (*GetInteractionResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{85} +} + +func (x *GetInteractionResponse) GetInteraction() *Interaction { + if x != nil { + return x.Interaction + } + return nil +} + +// ── Interaction responses (api_write:interactions) ─────────────────────────── +type RespondToPermissionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // pending id + // Must name one of the interaction's declared options. kandev derives the + // approve/deny outcome from that option's kind, so a plugin cannot report an + // outcome the agent never offered. + OptionId string `protobuf:"bytes,2,opt,name=option_id,json=optionId,proto3" json:"option_id,omitempty"` + // Dismiss the request instead of selecting an option (the ACP "cancelled" + // outcome). option_id must be empty when this is set. + Cancelled bool `protobuf:"varint,3,opt,name=cancelled,proto3" json:"cancelled,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RespondToPermissionRequest) Reset() { + *x = RespondToPermissionRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[86] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RespondToPermissionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RespondToPermissionRequest) ProtoMessage() {} + +func (x *RespondToPermissionRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[86] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RespondToPermissionRequest.ProtoReflect.Descriptor instead. +func (*RespondToPermissionRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{86} +} + +func (x *RespondToPermissionRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *RespondToPermissionRequest) GetOptionId() string { + if x != nil { + return x.OptionId + } + return "" +} + +func (x *RespondToPermissionRequest) GetCancelled() bool { + if x != nil { + return x.Cancelled + } + return false +} + +type RespondToPermissionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Interaction *Interaction `protobuf:"bytes,1,opt,name=interaction,proto3" json:"interaction,omitempty"` // the interaction in its new terminal state + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RespondToPermissionResponse) Reset() { + *x = RespondToPermissionResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[87] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RespondToPermissionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RespondToPermissionResponse) ProtoMessage() {} + +func (x *RespondToPermissionResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[87] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RespondToPermissionResponse.ProtoReflect.Descriptor instead. +func (*RespondToPermissionResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{87} +} + +func (x *RespondToPermissionResponse) GetInteraction() *Interaction { + if x != nil { + return x.Interaction + } + return nil +} + +type ClarificationAnswer struct { + state protoimpl.MessageState `protogen:"open.v1"` + QuestionId string `protobuf:"bytes,1,opt,name=question_id,json=questionId,proto3" json:"question_id,omitempty"` + SelectedOptions []string `protobuf:"bytes,2,rep,name=selected_options,json=selectedOptions,proto3" json:"selected_options,omitempty"` // option ids + CustomText string `protobuf:"bytes,3,opt,name=custom_text,json=customText,proto3" json:"custom_text,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClarificationAnswer) Reset() { + *x = ClarificationAnswer{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[88] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClarificationAnswer) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClarificationAnswer) ProtoMessage() {} + +func (x *ClarificationAnswer) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[88] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClarificationAnswer.ProtoReflect.Descriptor instead. +func (*ClarificationAnswer) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{88} +} + +func (x *ClarificationAnswer) GetQuestionId() string { + if x != nil { + return x.QuestionId + } + return "" +} + +func (x *ClarificationAnswer) GetSelectedOptions() []string { + if x != nil { + return x.SelectedOptions + } + return nil +} + +func (x *ClarificationAnswer) GetCustomText() string { + if x != nil { + return x.CustomText + } + return "" +} + +type AnswerClarificationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // pending id + // Exactly one answer per question in the bundle; the agent stays blocked + // until every question is answered. + Answers []*ClarificationAnswer `protobuf:"bytes,2,rep,name=answers,proto3" json:"answers,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AnswerClarificationRequest) Reset() { + *x = AnswerClarificationRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[89] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AnswerClarificationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AnswerClarificationRequest) ProtoMessage() {} + +func (x *AnswerClarificationRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[89] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AnswerClarificationRequest.ProtoReflect.Descriptor instead. +func (*AnswerClarificationRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{89} +} + +func (x *AnswerClarificationRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *AnswerClarificationRequest) GetAnswers() []*ClarificationAnswer { + if x != nil { + return x.Answers + } + return nil +} + +type AnswerClarificationResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Interaction *Interaction `protobuf:"bytes,1,opt,name=interaction,proto3" json:"interaction,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AnswerClarificationResponse) Reset() { + *x = AnswerClarificationResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[90] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AnswerClarificationResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AnswerClarificationResponse) ProtoMessage() {} + +func (x *AnswerClarificationResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[90] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AnswerClarificationResponse.ProtoReflect.Descriptor instead. +func (*AnswerClarificationResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{90} +} + +func (x *AnswerClarificationResponse) GetInteraction() *Interaction { + if x != nil { + return x.Interaction + } + return nil +} + +type CancelClarificationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // pending id + Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` // surfaced to the agent as the decline reason + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CancelClarificationRequest) Reset() { + *x = CancelClarificationRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[91] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CancelClarificationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CancelClarificationRequest) ProtoMessage() {} + +func (x *CancelClarificationRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[91] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CancelClarificationRequest.ProtoReflect.Descriptor instead. +func (*CancelClarificationRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{91} +} + +func (x *CancelClarificationRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *CancelClarificationRequest) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +type CancelClarificationResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Interaction *Interaction `protobuf:"bytes,1,opt,name=interaction,proto3" json:"interaction,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CancelClarificationResponse) Reset() { + *x = CancelClarificationResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[92] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CancelClarificationResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CancelClarificationResponse) ProtoMessage() {} + +func (x *CancelClarificationResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[92] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CancelClarificationResponse.ProtoReflect.Descriptor instead. +func (*CancelClarificationResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{92} +} + +func (x *CancelClarificationResponse) GetInteraction() *Interaction { + if x != nil { + return x.Interaction + } + return nil +} + // ── Utility agent invocation (agent_invoke) ────────────────────────────────── // A one-shot completion delegated to the operator-configured utility agent. // Adding fields here (e.g. a future system_prompt or max_tokens) is the @@ -4806,7 +5732,7 @@ type InvokeUtilityAgentRequest struct { func (x *InvokeUtilityAgentRequest) Reset() { *x = InvokeUtilityAgentRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[78] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4818,7 +5744,7 @@ func (x *InvokeUtilityAgentRequest) String() string { func (*InvokeUtilityAgentRequest) ProtoMessage() {} func (x *InvokeUtilityAgentRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[78] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4831,7 +5757,7 @@ func (x *InvokeUtilityAgentRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InvokeUtilityAgentRequest.ProtoReflect.Descriptor instead. func (*InvokeUtilityAgentRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{78} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{93} } func (x *InvokeUtilityAgentRequest) GetPrompt() string { @@ -4850,7 +5776,7 @@ type InvokeUtilityAgentResponse struct { func (x *InvokeUtilityAgentResponse) Reset() { *x = InvokeUtilityAgentResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[79] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4862,7 +5788,7 @@ func (x *InvokeUtilityAgentResponse) String() string { func (*InvokeUtilityAgentResponse) ProtoMessage() {} func (x *InvokeUtilityAgentResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[79] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4875,7 +5801,7 @@ func (x *InvokeUtilityAgentResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InvokeUtilityAgentResponse.ProtoReflect.Descriptor instead. func (*InvokeUtilityAgentResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{79} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{94} } func (x *InvokeUtilityAgentResponse) GetText() string { @@ -4905,7 +5831,7 @@ type CreateTaskRequest struct { func (x *CreateTaskRequest) Reset() { *x = CreateTaskRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[80] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4917,7 +5843,7 @@ func (x *CreateTaskRequest) String() string { func (*CreateTaskRequest) ProtoMessage() {} func (x *CreateTaskRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[80] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4930,7 +5856,7 @@ func (x *CreateTaskRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateTaskRequest.ProtoReflect.Descriptor instead. func (*CreateTaskRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{80} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{95} } func (x *CreateTaskRequest) GetWorkspaceId() string { @@ -5016,7 +5942,7 @@ type PluginTaskRepository struct { func (x *PluginTaskRepository) Reset() { *x = PluginTaskRepository{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[81] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5028,7 +5954,7 @@ func (x *PluginTaskRepository) String() string { func (*PluginTaskRepository) ProtoMessage() {} func (x *PluginTaskRepository) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[81] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5041,7 +5967,7 @@ func (x *PluginTaskRepository) ProtoReflect() protoreflect.Message { // Deprecated: Use PluginTaskRepository.ProtoReflect.Descriptor instead. func (*PluginTaskRepository) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{81} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{96} } func (x *PluginTaskRepository) GetRepositoryId() string { @@ -5100,7 +6026,7 @@ type RemoteRepositoryDescriptor struct { func (x *RemoteRepositoryDescriptor) Reset() { *x = RemoteRepositoryDescriptor{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[82] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[97] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5112,7 +6038,7 @@ func (x *RemoteRepositoryDescriptor) String() string { func (*RemoteRepositoryDescriptor) ProtoMessage() {} func (x *RemoteRepositoryDescriptor) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[82] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[97] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5125,7 +6051,7 @@ func (x *RemoteRepositoryDescriptor) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoteRepositoryDescriptor.ProtoReflect.Descriptor instead. func (*RemoteRepositoryDescriptor) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{82} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{97} } func (x *RemoteRepositoryDescriptor) GetProviderId() string { @@ -5217,7 +6143,7 @@ type PluginTaskLaunchOptions struct { func (x *PluginTaskLaunchOptions) Reset() { *x = PluginTaskLaunchOptions{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[83] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5229,7 +6155,7 @@ func (x *PluginTaskLaunchOptions) String() string { func (*PluginTaskLaunchOptions) ProtoMessage() {} func (x *PluginTaskLaunchOptions) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[83] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5242,7 +6168,7 @@ func (x *PluginTaskLaunchOptions) ProtoReflect() protoreflect.Message { // Deprecated: Use PluginTaskLaunchOptions.ProtoReflect.Descriptor instead. func (*PluginTaskLaunchOptions) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{83} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{98} } func (x *PluginTaskLaunchOptions) GetAgentProfileId() string { @@ -5282,7 +6208,7 @@ type CreateTaskResponse struct { func (x *CreateTaskResponse) Reset() { *x = CreateTaskResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[84] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[99] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5294,7 +6220,7 @@ func (x *CreateTaskResponse) String() string { func (*CreateTaskResponse) ProtoMessage() {} func (x *CreateTaskResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[84] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[99] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5307,7 +6233,7 @@ func (x *CreateTaskResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateTaskResponse.ProtoReflect.Descriptor instead. func (*CreateTaskResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{84} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{99} } func (x *CreateTaskResponse) GetTask() *Task { @@ -5330,7 +6256,7 @@ type UpdateTaskRequest struct { func (x *UpdateTaskRequest) Reset() { *x = UpdateTaskRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[85] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[100] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5342,7 +6268,7 @@ func (x *UpdateTaskRequest) String() string { func (*UpdateTaskRequest) ProtoMessage() {} func (x *UpdateTaskRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[85] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[100] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5355,7 +6281,7 @@ func (x *UpdateTaskRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateTaskRequest.ProtoReflect.Descriptor instead. func (*UpdateTaskRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{85} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{100} } func (x *UpdateTaskRequest) GetId() string { @@ -5402,7 +6328,7 @@ type UpdateTaskResponse struct { func (x *UpdateTaskResponse) Reset() { *x = UpdateTaskResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[86] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[101] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5414,7 +6340,7 @@ func (x *UpdateTaskResponse) String() string { func (*UpdateTaskResponse) ProtoMessage() {} func (x *UpdateTaskResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[86] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[101] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5427,7 +6353,7 @@ func (x *UpdateTaskResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateTaskResponse.ProtoReflect.Descriptor instead. func (*UpdateTaskResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{86} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{101} } func (x *UpdateTaskResponse) GetTask() *Task { @@ -5454,7 +6380,7 @@ type SendMessageRequest struct { func (x *SendMessageRequest) Reset() { *x = SendMessageRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[87] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[102] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5466,7 +6392,7 @@ func (x *SendMessageRequest) String() string { func (*SendMessageRequest) ProtoMessage() {} func (x *SendMessageRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[87] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[102] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5479,7 +6405,7 @@ func (x *SendMessageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SendMessageRequest.ProtoReflect.Descriptor instead. func (*SendMessageRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{87} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{102} } func (x *SendMessageRequest) GetTaskId() string { @@ -5513,7 +6439,7 @@ type SendMessageResponse struct { func (x *SendMessageResponse) Reset() { *x = SendMessageResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[88] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[103] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5525,7 +6451,7 @@ func (x *SendMessageResponse) String() string { func (*SendMessageResponse) ProtoMessage() {} func (x *SendMessageResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[88] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[103] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5538,7 +6464,7 @@ func (x *SendMessageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SendMessageResponse.ProtoReflect.Descriptor instead. func (*SendMessageResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{88} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{103} } func (x *SendMessageResponse) GetSessionId() string { @@ -5564,7 +6490,7 @@ type PreviewPluginOwnedTaskTreeRequest struct { func (x *PreviewPluginOwnedTaskTreeRequest) Reset() { *x = PreviewPluginOwnedTaskTreeRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[89] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[104] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5576,7 +6502,7 @@ func (x *PreviewPluginOwnedTaskTreeRequest) String() string { func (*PreviewPluginOwnedTaskTreeRequest) ProtoMessage() {} func (x *PreviewPluginOwnedTaskTreeRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[89] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[104] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5589,7 +6515,7 @@ func (x *PreviewPluginOwnedTaskTreeRequest) ProtoReflect() protoreflect.Message // Deprecated: Use PreviewPluginOwnedTaskTreeRequest.ProtoReflect.Descriptor instead. func (*PreviewPluginOwnedTaskTreeRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{89} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{104} } func (x *PreviewPluginOwnedTaskTreeRequest) GetRootTaskId() string { @@ -5608,7 +6534,7 @@ type PreviewPluginOwnedTaskTreeResponse struct { func (x *PreviewPluginOwnedTaskTreeResponse) Reset() { *x = PreviewPluginOwnedTaskTreeResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[90] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[105] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5620,7 +6546,7 @@ func (x *PreviewPluginOwnedTaskTreeResponse) String() string { func (*PreviewPluginOwnedTaskTreeResponse) ProtoMessage() {} func (x *PreviewPluginOwnedTaskTreeResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[90] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[105] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5633,7 +6559,7 @@ func (x *PreviewPluginOwnedTaskTreeResponse) ProtoReflect() protoreflect.Message // Deprecated: Use PreviewPluginOwnedTaskTreeResponse.ProtoReflect.Descriptor instead. func (*PreviewPluginOwnedTaskTreeResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{90} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{105} } func (x *PreviewPluginOwnedTaskTreeResponse) GetTasks() []*Task { @@ -5652,7 +6578,7 @@ type DeletePluginOwnedTaskTreeRequest struct { func (x *DeletePluginOwnedTaskTreeRequest) Reset() { *x = DeletePluginOwnedTaskTreeRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[91] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[106] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5664,7 +6590,7 @@ func (x *DeletePluginOwnedTaskTreeRequest) String() string { func (*DeletePluginOwnedTaskTreeRequest) ProtoMessage() {} func (x *DeletePluginOwnedTaskTreeRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[91] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[106] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5677,7 +6603,7 @@ func (x *DeletePluginOwnedTaskTreeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeletePluginOwnedTaskTreeRequest.ProtoReflect.Descriptor instead. func (*DeletePluginOwnedTaskTreeRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{91} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{106} } func (x *DeletePluginOwnedTaskTreeRequest) GetRootTaskId() string { @@ -5696,7 +6622,7 @@ type DeletePluginOwnedTaskTreeResponse struct { func (x *DeletePluginOwnedTaskTreeResponse) Reset() { *x = DeletePluginOwnedTaskTreeResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[92] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[107] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5708,7 +6634,7 @@ func (x *DeletePluginOwnedTaskTreeResponse) String() string { func (*DeletePluginOwnedTaskTreeResponse) ProtoMessage() {} func (x *DeletePluginOwnedTaskTreeResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[92] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[107] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5721,7 +6647,7 @@ func (x *DeletePluginOwnedTaskTreeResponse) ProtoReflect() protoreflect.Message // Deprecated: Use DeletePluginOwnedTaskTreeResponse.ProtoReflect.Descriptor instead. func (*DeletePluginOwnedTaskTreeResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{92} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{107} } func (x *DeletePluginOwnedTaskTreeResponse) GetDeletedTaskIds() []string { @@ -5743,7 +6669,7 @@ type DeletePluginOwnedTaskTreeProgress struct { func (x *DeletePluginOwnedTaskTreeProgress) Reset() { *x = DeletePluginOwnedTaskTreeProgress{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[93] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[108] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5755,7 +6681,7 @@ func (x *DeletePluginOwnedTaskTreeProgress) String() string { func (*DeletePluginOwnedTaskTreeProgress) ProtoMessage() {} func (x *DeletePluginOwnedTaskTreeProgress) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[93] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[108] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5768,7 +6694,7 @@ func (x *DeletePluginOwnedTaskTreeProgress) ProtoReflect() protoreflect.Message // Deprecated: Use DeletePluginOwnedTaskTreeProgress.ProtoReflect.Descriptor instead. func (*DeletePluginOwnedTaskTreeProgress) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{93} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{108} } func (x *DeletePluginOwnedTaskTreeProgress) GetDeletedTaskIds() []string { @@ -6176,7 +7102,76 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" + "\x04page\x18\x02 \x01(\v2\x16.kandev.plugin.v1.PageR\x04page\"\x86\x01\n" + "\x14ListMessagesResponse\x125\n" + "\bmessages\x18\x01 \x03(\v2\x19.kandev.plugin.v1.MessageR\bmessages\x127\n" + - "\tpage_info\x18\x02 \x01(\v2\x1a.kandev.plugin.v1.PageInfoR\bpageInfo\"3\n" + + "\tpage_info\x18\x02 \x01(\v2\x1a.kandev.plugin.v1.PageInfoR\bpageInfo\"|\n" + + "\x11InteractionOption\x12\x1b\n" + + "\toption_id\x18\x01 \x01(\tR\boptionId\x12\x14\n" + + "\x05label\x18\x02 \x01(\tR\x05label\x12\x12\n" + + "\x04kind\x18\x03 \x01(\tR\x04kind\x12 \n" + + "\vdescription\x18\x04 \x01(\tR\vdescription\"\x92\x01\n" + + "\x13InteractionQuestion\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" + + "\x05title\x18\x02 \x01(\tR\x05title\x12\x16\n" + + "\x06prompt\x18\x03 \x01(\tR\x06prompt\x12=\n" + + "\aoptions\x18\x04 \x03(\v2#.kandev.plugin.v1.InteractionOptionR\aoptions\"\xfe\x03\n" + + "\vInteraction\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04kind\x18\x02 \x01(\tR\x04kind\x12\x17\n" + + "\atask_id\x18\x03 \x01(\tR\x06taskId\x12\x1d\n" + + "\n" + + "session_id\x18\x04 \x01(\tR\tsessionId\x12\x17\n" + + "\aturn_id\x18\x05 \x01(\tR\x06turnId\x12\x16\n" + + "\x06status\x18\x06 \x01(\tR\x06status\x12\x14\n" + + "\x05title\x18\a \x01(\tR\x05title\x12\x18\n" + + "\acontext\x18\b \x01(\tR\acontext\x12 \n" + + "\ftool_call_id\x18\t \x01(\tR\n" + + "toolCallId\x12\x1f\n" + + "\vaction_type\x18\n" + + " \x01(\tR\n" + + "actionType\x12=\n" + + "\aoptions\x18\v \x03(\v2#.kandev.plugin.v1.InteractionOptionR\aoptions\x12C\n" + + "\tquestions\x18\f \x03(\v2%.kandev.plugin.v1.InteractionQuestionR\tquestions\x12-\n" + + "\x12agent_disconnected\x18\r \x01(\bR\x11agentDisconnected\x12\x1d\n" + + "\n" + + "created_at\x18\x0e \x01(\tR\tcreatedAt\x12\x1d\n" + + "\n" + + "updated_at\x18\x0f \x01(\tR\tupdatedAt\"e\n" + + "\x11InteractionFilter\x12\x1f\n" + + "\vsession_ids\x18\x01 \x03(\tR\n" + + "sessionIds\x12\x19\n" + + "\btask_ids\x18\x02 \x03(\tR\ataskIds\x12\x14\n" + + "\x05kinds\x18\x03 \x03(\tR\x05kinds\"\x89\x01\n" + + "\x1eListPendingInteractionsRequest\x12;\n" + + "\x06filter\x18\x01 \x01(\v2#.kandev.plugin.v1.InteractionFilterR\x06filter\x12*\n" + + "\x04page\x18\x02 \x01(\v2\x16.kandev.plugin.v1.PageR\x04page\"\x9d\x01\n" + + "\x1fListPendingInteractionsResponse\x12A\n" + + "\finteractions\x18\x01 \x03(\v2\x1d.kandev.plugin.v1.InteractionR\finteractions\x127\n" + + "\tpage_info\x18\x02 \x01(\v2\x1a.kandev.plugin.v1.PageInfoR\bpageInfo\"'\n" + + "\x15GetInteractionRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"Y\n" + + "\x16GetInteractionResponse\x12?\n" + + "\vinteraction\x18\x01 \x01(\v2\x1d.kandev.plugin.v1.InteractionR\vinteraction\"g\n" + + "\x1aRespondToPermissionRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1b\n" + + "\toption_id\x18\x02 \x01(\tR\boptionId\x12\x1c\n" + + "\tcancelled\x18\x03 \x01(\bR\tcancelled\"^\n" + + "\x1bRespondToPermissionResponse\x12?\n" + + "\vinteraction\x18\x01 \x01(\v2\x1d.kandev.plugin.v1.InteractionR\vinteraction\"\x82\x01\n" + + "\x13ClarificationAnswer\x12\x1f\n" + + "\vquestion_id\x18\x01 \x01(\tR\n" + + "questionId\x12)\n" + + "\x10selected_options\x18\x02 \x03(\tR\x0fselectedOptions\x12\x1f\n" + + "\vcustom_text\x18\x03 \x01(\tR\n" + + "customText\"m\n" + + "\x1aAnswerClarificationRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12?\n" + + "\aanswers\x18\x02 \x03(\v2%.kandev.plugin.v1.ClarificationAnswerR\aanswers\"^\n" + + "\x1bAnswerClarificationResponse\x12?\n" + + "\vinteraction\x18\x01 \x01(\v2\x1d.kandev.plugin.v1.InteractionR\vinteraction\"D\n" + + "\x1aCancelClarificationRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x16\n" + + "\x06reason\x18\x02 \x01(\tR\x06reason\"^\n" + + "\x1bCancelClarificationResponse\x12?\n" + + "\vinteraction\x18\x01 \x01(\v2\x1d.kandev.plugin.v1.InteractionR\vinteraction\"3\n" + "\x19InvokeUtilityAgentRequest\x12\x16\n" + "\x06prompt\x18\x01 \x01(\tR\x06prompt\"0\n" + "\x1aInvokeUtilityAgentResponse\x12\x12\n" + @@ -6283,7 +7278,7 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" + "\x18AuthorizeEntityReference\x121.kandev.plugin.v1.AuthorizeEntityReferenceRequest\x1a2.kandev.plugin.v1.AuthorizeEntityReferenceResponse\x12u\n" + "\x14ResolveGitCredential\x12-.kandev.plugin.v1.ResolveGitCredentialRequest\x1a..kandev.plugin.v1.ResolveGitCredentialResponse\x12x\n" + "\x17GetGitCredentialBinding\x12-.kandev.plugin.v1.GitCredentialBindingRequest\x1a..kandev.plugin.v1.GitCredentialBindingResponse\x12Z\n" + - "\x0fInvokeAgentTool\x12\".kandev.plugin.v1.AgentToolRequest\x1a#.kandev.plugin.v1.AgentToolResponse2\xe4\x14\n" + + "\x0fInvokeAgentTool\x12\".kandev.plugin.v1.AgentToolRequest\x1a#.kandev.plugin.v1.AgentToolResponse2\xa5\x19\n" + "\x04Host\x12Q\n" + "\bGetState\x12!.kandev.plugin.v1.GetStateRequest\x1a\".kandev.plugin.v1.GetStateResponse\x12Q\n" + "\bSetState\x12!.kandev.plugin.v1.SetStateRequest\x1a\".kandev.plugin.v1.SetStateResponse\x12Z\n" + @@ -6305,7 +7300,9 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" + "\x10ListRepositories\x12).kandev.plugin.v1.ListRepositoriesRequest\x1a*.kandev.plugin.v1.ListRepositoriesResponse\x12]\n" + "\fListSessions\x12%.kandev.plugin.v1.ListSessionsRequest\x1a&.kandev.plugin.v1.ListSessionsResponse\x12u\n" + "\x14ListSessionCodeStats\x12-.kandev.plugin.v1.ListSessionCodeStatsRequest\x1a..kandev.plugin.v1.ListSessionCodeStatsResponse\x12]\n" + - "\fListMessages\x12%.kandev.plugin.v1.ListMessagesRequest\x1a&.kandev.plugin.v1.ListMessagesResponse\x12o\n" + + "\fListMessages\x12%.kandev.plugin.v1.ListMessagesRequest\x1a&.kandev.plugin.v1.ListMessagesResponse\x12~\n" + + "\x17ListPendingInteractions\x120.kandev.plugin.v1.ListPendingInteractionsRequest\x1a1.kandev.plugin.v1.ListPendingInteractionsResponse\x12c\n" + + "\x0eGetInteraction\x12'.kandev.plugin.v1.GetInteractionRequest\x1a(.kandev.plugin.v1.GetInteractionResponse\x12o\n" + "\x12InvokeUtilityAgent\x12+.kandev.plugin.v1.InvokeUtilityAgentRequest\x1a,.kandev.plugin.v1.InvokeUtilityAgentResponse\x12W\n" + "\n" + "CreateTask\x12#.kandev.plugin.v1.CreateTaskRequest\x1a$.kandev.plugin.v1.CreateTaskResponse\x12W\n" + @@ -6313,7 +7310,10 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" + "UpdateTask\x12#.kandev.plugin.v1.UpdateTaskRequest\x1a$.kandev.plugin.v1.UpdateTaskResponse\x12Z\n" + "\vSendMessage\x12$.kandev.plugin.v1.SendMessageRequest\x1a%.kandev.plugin.v1.SendMessageResponse\x12\x87\x01\n" + "\x1aPreviewPluginOwnedTaskTree\x123.kandev.plugin.v1.PreviewPluginOwnedTaskTreeRequest\x1a4.kandev.plugin.v1.PreviewPluginOwnedTaskTreeResponse\x12\x84\x01\n" + - "\x19DeletePluginOwnedTaskTree\x122.kandev.plugin.v1.DeletePluginOwnedTaskTreeRequest\x1a3.kandev.plugin.v1.DeletePluginOwnedTaskTreeResponseB:Z8github.com/kandev/kandev/proto/kandev/plugin/v1;pluginv1b\x06proto3" + "\x19DeletePluginOwnedTaskTree\x122.kandev.plugin.v1.DeletePluginOwnedTaskTreeRequest\x1a3.kandev.plugin.v1.DeletePluginOwnedTaskTreeResponse\x12r\n" + + "\x13RespondToPermission\x12,.kandev.plugin.v1.RespondToPermissionRequest\x1a-.kandev.plugin.v1.RespondToPermissionResponse\x12r\n" + + "\x13AnswerClarification\x12,.kandev.plugin.v1.AnswerClarificationRequest\x1a-.kandev.plugin.v1.AnswerClarificationResponse\x12r\n" + + "\x13CancelClarification\x12,.kandev.plugin.v1.CancelClarificationRequest\x1a-.kandev.plugin.v1.CancelClarificationResponseB:Z8github.com/kandev/kandev/proto/kandev/plugin/v1;pluginv1b\x06proto3" var ( file_kandev_plugin_v1_plugin_proto_rawDescOnce sync.Once @@ -6327,7 +7327,7 @@ func file_kandev_plugin_v1_plugin_proto_rawDescGZIP() []byte { return file_kandev_plugin_v1_plugin_proto_rawDescData } -var file_kandev_plugin_v1_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 97) +var file_kandev_plugin_v1_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 112) var file_kandev_plugin_v1_plugin_proto_goTypes = []any{ (*Event)(nil), // 0: kandev.plugin.v1.Event (*EventAck)(nil), // 1: kandev.plugin.v1.EventAck @@ -6407,162 +7407,199 @@ var file_kandev_plugin_v1_plugin_proto_goTypes = []any{ (*MessageFilter)(nil), // 75: kandev.plugin.v1.MessageFilter (*ListMessagesRequest)(nil), // 76: kandev.plugin.v1.ListMessagesRequest (*ListMessagesResponse)(nil), // 77: kandev.plugin.v1.ListMessagesResponse - (*InvokeUtilityAgentRequest)(nil), // 78: kandev.plugin.v1.InvokeUtilityAgentRequest - (*InvokeUtilityAgentResponse)(nil), // 79: kandev.plugin.v1.InvokeUtilityAgentResponse - (*CreateTaskRequest)(nil), // 80: kandev.plugin.v1.CreateTaskRequest - (*PluginTaskRepository)(nil), // 81: kandev.plugin.v1.PluginTaskRepository - (*RemoteRepositoryDescriptor)(nil), // 82: kandev.plugin.v1.RemoteRepositoryDescriptor - (*PluginTaskLaunchOptions)(nil), // 83: kandev.plugin.v1.PluginTaskLaunchOptions - (*CreateTaskResponse)(nil), // 84: kandev.plugin.v1.CreateTaskResponse - (*UpdateTaskRequest)(nil), // 85: kandev.plugin.v1.UpdateTaskRequest - (*UpdateTaskResponse)(nil), // 86: kandev.plugin.v1.UpdateTaskResponse - (*SendMessageRequest)(nil), // 87: kandev.plugin.v1.SendMessageRequest - (*SendMessageResponse)(nil), // 88: kandev.plugin.v1.SendMessageResponse - (*PreviewPluginOwnedTaskTreeRequest)(nil), // 89: kandev.plugin.v1.PreviewPluginOwnedTaskTreeRequest - (*PreviewPluginOwnedTaskTreeResponse)(nil), // 90: kandev.plugin.v1.PreviewPluginOwnedTaskTreeResponse - (*DeletePluginOwnedTaskTreeRequest)(nil), // 91: kandev.plugin.v1.DeletePluginOwnedTaskTreeRequest - (*DeletePluginOwnedTaskTreeResponse)(nil), // 92: kandev.plugin.v1.DeletePluginOwnedTaskTreeResponse - (*DeletePluginOwnedTaskTreeProgress)(nil), // 93: kandev.plugin.v1.DeletePluginOwnedTaskTreeProgress - nil, // 94: kandev.plugin.v1.WebhookRequest.HeadersEntry - nil, // 95: kandev.plugin.v1.WebhookResponse.HeadersEntry - nil, // 96: kandev.plugin.v1.PluginActionResponse.HeadersEntry - (*structpb.Struct)(nil), // 97: google.protobuf.Struct + (*InteractionOption)(nil), // 78: kandev.plugin.v1.InteractionOption + (*InteractionQuestion)(nil), // 79: kandev.plugin.v1.InteractionQuestion + (*Interaction)(nil), // 80: kandev.plugin.v1.Interaction + (*InteractionFilter)(nil), // 81: kandev.plugin.v1.InteractionFilter + (*ListPendingInteractionsRequest)(nil), // 82: kandev.plugin.v1.ListPendingInteractionsRequest + (*ListPendingInteractionsResponse)(nil), // 83: kandev.plugin.v1.ListPendingInteractionsResponse + (*GetInteractionRequest)(nil), // 84: kandev.plugin.v1.GetInteractionRequest + (*GetInteractionResponse)(nil), // 85: kandev.plugin.v1.GetInteractionResponse + (*RespondToPermissionRequest)(nil), // 86: kandev.plugin.v1.RespondToPermissionRequest + (*RespondToPermissionResponse)(nil), // 87: kandev.plugin.v1.RespondToPermissionResponse + (*ClarificationAnswer)(nil), // 88: kandev.plugin.v1.ClarificationAnswer + (*AnswerClarificationRequest)(nil), // 89: kandev.plugin.v1.AnswerClarificationRequest + (*AnswerClarificationResponse)(nil), // 90: kandev.plugin.v1.AnswerClarificationResponse + (*CancelClarificationRequest)(nil), // 91: kandev.plugin.v1.CancelClarificationRequest + (*CancelClarificationResponse)(nil), // 92: kandev.plugin.v1.CancelClarificationResponse + (*InvokeUtilityAgentRequest)(nil), // 93: kandev.plugin.v1.InvokeUtilityAgentRequest + (*InvokeUtilityAgentResponse)(nil), // 94: kandev.plugin.v1.InvokeUtilityAgentResponse + (*CreateTaskRequest)(nil), // 95: kandev.plugin.v1.CreateTaskRequest + (*PluginTaskRepository)(nil), // 96: kandev.plugin.v1.PluginTaskRepository + (*RemoteRepositoryDescriptor)(nil), // 97: kandev.plugin.v1.RemoteRepositoryDescriptor + (*PluginTaskLaunchOptions)(nil), // 98: kandev.plugin.v1.PluginTaskLaunchOptions + (*CreateTaskResponse)(nil), // 99: kandev.plugin.v1.CreateTaskResponse + (*UpdateTaskRequest)(nil), // 100: kandev.plugin.v1.UpdateTaskRequest + (*UpdateTaskResponse)(nil), // 101: kandev.plugin.v1.UpdateTaskResponse + (*SendMessageRequest)(nil), // 102: kandev.plugin.v1.SendMessageRequest + (*SendMessageResponse)(nil), // 103: kandev.plugin.v1.SendMessageResponse + (*PreviewPluginOwnedTaskTreeRequest)(nil), // 104: kandev.plugin.v1.PreviewPluginOwnedTaskTreeRequest + (*PreviewPluginOwnedTaskTreeResponse)(nil), // 105: kandev.plugin.v1.PreviewPluginOwnedTaskTreeResponse + (*DeletePluginOwnedTaskTreeRequest)(nil), // 106: kandev.plugin.v1.DeletePluginOwnedTaskTreeRequest + (*DeletePluginOwnedTaskTreeResponse)(nil), // 107: kandev.plugin.v1.DeletePluginOwnedTaskTreeResponse + (*DeletePluginOwnedTaskTreeProgress)(nil), // 108: kandev.plugin.v1.DeletePluginOwnedTaskTreeProgress + nil, // 109: kandev.plugin.v1.WebhookRequest.HeadersEntry + nil, // 110: kandev.plugin.v1.WebhookResponse.HeadersEntry + nil, // 111: kandev.plugin.v1.PluginActionResponse.HeadersEntry + (*structpb.Struct)(nil), // 112: google.protobuf.Struct } var file_kandev_plugin_v1_plugin_proto_depIdxs = []int32{ - 97, // 0: kandev.plugin.v1.Event.payload:type_name -> google.protobuf.Struct - 97, // 1: kandev.plugin.v1.AgentToolRequest.arguments:type_name -> google.protobuf.Struct - 3, // 2: kandev.plugin.v1.AgentToolRequest.context:type_name -> kandev.plugin.v1.AgentToolContext - 97, // 3: kandev.plugin.v1.AgentToolResponse.structured_content:type_name -> google.protobuf.Struct - 94, // 4: kandev.plugin.v1.WebhookRequest.headers:type_name -> kandev.plugin.v1.WebhookRequest.HeadersEntry - 95, // 5: kandev.plugin.v1.WebhookResponse.headers:type_name -> kandev.plugin.v1.WebhookResponse.HeadersEntry - 8, // 6: kandev.plugin.v1.PluginActionRequest.context:type_name -> kandev.plugin.v1.VerifiedActionContext - 96, // 7: kandev.plugin.v1.PluginActionResponse.headers:type_name -> kandev.plugin.v1.PluginActionResponse.HeadersEntry - 12, // 8: kandev.plugin.v1.SearchEntityReferencesResponse.candidates:type_name -> kandev.plugin.v1.EntityReferenceCandidate - 97, // 9: kandev.plugin.v1.EntityReferenceCandidate.attributes:type_name -> google.protobuf.Struct - 97, // 10: kandev.plugin.v1.AuthorizeEntityReferenceRequest.reference:type_name -> google.protobuf.Struct - 97, // 11: kandev.plugin.v1.GetStateResponse.value:type_name -> google.protobuf.Struct - 97, // 12: kandev.plugin.v1.SetStateRequest.value:type_name -> google.protobuf.Struct - 27, // 13: kandev.plugin.v1.ListStateResponse.entries:type_name -> kandev.plugin.v1.StateEntry - 97, // 14: kandev.plugin.v1.StateEntry.value:type_name -> google.protobuf.Struct - 97, // 15: kandev.plugin.v1.GetConfigResponse.config:type_name -> google.protobuf.Struct - 97, // 16: kandev.plugin.v1.EmitEventRequest.payload:type_name -> google.protobuf.Struct - 43, // 17: kandev.plugin.v1.Task.repositories:type_name -> kandev.plugin.v1.TaskRepository - 97, // 18: kandev.plugin.v1.Task.metadata:type_name -> google.protobuf.Struct - 44, // 19: kandev.plugin.v1.ListTasksRequest.filter:type_name -> kandev.plugin.v1.TaskFilter - 40, // 20: kandev.plugin.v1.ListTasksRequest.page:type_name -> kandev.plugin.v1.Page - 42, // 21: kandev.plugin.v1.ListTasksResponse.tasks:type_name -> kandev.plugin.v1.Task - 41, // 22: kandev.plugin.v1.ListTasksResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 42, // 23: kandev.plugin.v1.GetTaskResponse.task:type_name -> kandev.plugin.v1.Task - 40, // 24: kandev.plugin.v1.ListWorkspacesRequest.page:type_name -> kandev.plugin.v1.Page - 49, // 25: kandev.plugin.v1.ListWorkspacesResponse.workspaces:type_name -> kandev.plugin.v1.Workspace - 41, // 26: kandev.plugin.v1.ListWorkspacesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 40, // 27: kandev.plugin.v1.ListWorkflowsRequest.page:type_name -> kandev.plugin.v1.Page - 52, // 28: kandev.plugin.v1.ListWorkflowsResponse.workflows:type_name -> kandev.plugin.v1.Workflow - 41, // 29: kandev.plugin.v1.ListWorkflowsResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 55, // 30: kandev.plugin.v1.ListWorkflowStepsResponse.steps:type_name -> kandev.plugin.v1.WorkflowStep - 40, // 31: kandev.plugin.v1.ListAgentProfilesRequest.page:type_name -> kandev.plugin.v1.Page - 58, // 32: kandev.plugin.v1.ListAgentProfilesResponse.profiles:type_name -> kandev.plugin.v1.AgentProfile - 41, // 33: kandev.plugin.v1.ListAgentProfilesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 40, // 34: kandev.plugin.v1.ListExecutorProfilesRequest.page:type_name -> kandev.plugin.v1.Page - 61, // 35: kandev.plugin.v1.ListExecutorProfilesResponse.profiles:type_name -> kandev.plugin.v1.ExecutorProfile - 41, // 36: kandev.plugin.v1.ListExecutorProfilesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 40, // 37: kandev.plugin.v1.ListRepositoriesRequest.page:type_name -> kandev.plugin.v1.Page - 64, // 38: kandev.plugin.v1.ListRepositoriesResponse.repositories:type_name -> kandev.plugin.v1.Repository - 41, // 39: kandev.plugin.v1.ListRepositoriesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 68, // 40: kandev.plugin.v1.ListSessionsRequest.filter:type_name -> kandev.plugin.v1.SessionFilter - 40, // 41: kandev.plugin.v1.ListSessionsRequest.page:type_name -> kandev.plugin.v1.Page - 67, // 42: kandev.plugin.v1.ListSessionsResponse.sessions:type_name -> kandev.plugin.v1.Session - 41, // 43: kandev.plugin.v1.ListSessionsResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 68, // 44: kandev.plugin.v1.ListSessionCodeStatsRequest.filter:type_name -> kandev.plugin.v1.SessionFilter - 40, // 45: kandev.plugin.v1.ListSessionCodeStatsRequest.page:type_name -> kandev.plugin.v1.Page - 71, // 46: kandev.plugin.v1.ListSessionCodeStatsResponse.stats:type_name -> kandev.plugin.v1.SessionCodeStats - 41, // 47: kandev.plugin.v1.ListSessionCodeStatsResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 75, // 48: kandev.plugin.v1.ListMessagesRequest.filter:type_name -> kandev.plugin.v1.MessageFilter - 40, // 49: kandev.plugin.v1.ListMessagesRequest.page:type_name -> kandev.plugin.v1.Page - 74, // 50: kandev.plugin.v1.ListMessagesResponse.messages:type_name -> kandev.plugin.v1.Message - 41, // 51: kandev.plugin.v1.ListMessagesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 81, // 52: kandev.plugin.v1.CreateTaskRequest.repositories:type_name -> kandev.plugin.v1.PluginTaskRepository - 83, // 53: kandev.plugin.v1.CreateTaskRequest.launch:type_name -> kandev.plugin.v1.PluginTaskLaunchOptions - 97, // 54: kandev.plugin.v1.CreateTaskRequest.metadata:type_name -> google.protobuf.Struct - 82, // 55: kandev.plugin.v1.PluginTaskRepository.remote:type_name -> kandev.plugin.v1.RemoteRepositoryDescriptor - 42, // 56: kandev.plugin.v1.CreateTaskResponse.task:type_name -> kandev.plugin.v1.Task - 42, // 57: kandev.plugin.v1.UpdateTaskResponse.task:type_name -> kandev.plugin.v1.Task - 42, // 58: kandev.plugin.v1.PreviewPluginOwnedTaskTreeResponse.tasks:type_name -> kandev.plugin.v1.Task - 0, // 59: kandev.plugin.v1.Plugin.DeliverEvent:input_type -> kandev.plugin.v1.Event - 5, // 60: kandev.plugin.v1.Plugin.HandleWebhook:input_type -> kandev.plugin.v1.WebhookRequest - 7, // 61: kandev.plugin.v1.Plugin.HandleAction:input_type -> kandev.plugin.v1.PluginActionRequest - 10, // 62: kandev.plugin.v1.Plugin.SearchEntityReferences:input_type -> kandev.plugin.v1.SearchEntityReferencesRequest - 13, // 63: kandev.plugin.v1.Plugin.AuthorizeEntityReference:input_type -> kandev.plugin.v1.AuthorizeEntityReferenceRequest - 15, // 64: kandev.plugin.v1.Plugin.ResolveGitCredential:input_type -> kandev.plugin.v1.ResolveGitCredentialRequest - 17, // 65: kandev.plugin.v1.Plugin.GetGitCredentialBinding:input_type -> kandev.plugin.v1.GitCredentialBindingRequest - 2, // 66: kandev.plugin.v1.Plugin.InvokeAgentTool:input_type -> kandev.plugin.v1.AgentToolRequest - 19, // 67: kandev.plugin.v1.Host.GetState:input_type -> kandev.plugin.v1.GetStateRequest - 21, // 68: kandev.plugin.v1.Host.SetState:input_type -> kandev.plugin.v1.SetStateRequest - 23, // 69: kandev.plugin.v1.Host.DeleteState:input_type -> kandev.plugin.v1.DeleteStateRequest - 25, // 70: kandev.plugin.v1.Host.ListState:input_type -> kandev.plugin.v1.ListStateRequest - 36, // 71: kandev.plugin.v1.Host.RevealSecret:input_type -> kandev.plugin.v1.RevealSecretRequest - 38, // 72: kandev.plugin.v1.Host.EmitEvent:input_type -> kandev.plugin.v1.EmitEventRequest - 30, // 73: kandev.plugin.v1.Host.GetSecret:input_type -> kandev.plugin.v1.GetSecretRequest - 32, // 74: kandev.plugin.v1.Host.SetSecret:input_type -> kandev.plugin.v1.SetSecretRequest - 34, // 75: kandev.plugin.v1.Host.DeleteSecret:input_type -> kandev.plugin.v1.DeleteSecretRequest - 28, // 76: kandev.plugin.v1.Host.GetConfig:input_type -> kandev.plugin.v1.GetConfigRequest - 45, // 77: kandev.plugin.v1.Host.ListTasks:input_type -> kandev.plugin.v1.ListTasksRequest - 47, // 78: kandev.plugin.v1.Host.GetTask:input_type -> kandev.plugin.v1.GetTaskRequest - 50, // 79: kandev.plugin.v1.Host.ListWorkspaces:input_type -> kandev.plugin.v1.ListWorkspacesRequest - 53, // 80: kandev.plugin.v1.Host.ListWorkflows:input_type -> kandev.plugin.v1.ListWorkflowsRequest - 56, // 81: kandev.plugin.v1.Host.ListWorkflowSteps:input_type -> kandev.plugin.v1.ListWorkflowStepsRequest - 59, // 82: kandev.plugin.v1.Host.ListAgentProfiles:input_type -> kandev.plugin.v1.ListAgentProfilesRequest - 62, // 83: kandev.plugin.v1.Host.ListExecutorProfiles:input_type -> kandev.plugin.v1.ListExecutorProfilesRequest - 65, // 84: kandev.plugin.v1.Host.ListRepositories:input_type -> kandev.plugin.v1.ListRepositoriesRequest - 69, // 85: kandev.plugin.v1.Host.ListSessions:input_type -> kandev.plugin.v1.ListSessionsRequest - 72, // 86: kandev.plugin.v1.Host.ListSessionCodeStats:input_type -> kandev.plugin.v1.ListSessionCodeStatsRequest - 76, // 87: kandev.plugin.v1.Host.ListMessages:input_type -> kandev.plugin.v1.ListMessagesRequest - 78, // 88: kandev.plugin.v1.Host.InvokeUtilityAgent:input_type -> kandev.plugin.v1.InvokeUtilityAgentRequest - 80, // 89: kandev.plugin.v1.Host.CreateTask:input_type -> kandev.plugin.v1.CreateTaskRequest - 85, // 90: kandev.plugin.v1.Host.UpdateTask:input_type -> kandev.plugin.v1.UpdateTaskRequest - 87, // 91: kandev.plugin.v1.Host.SendMessage:input_type -> kandev.plugin.v1.SendMessageRequest - 89, // 92: kandev.plugin.v1.Host.PreviewPluginOwnedTaskTree:input_type -> kandev.plugin.v1.PreviewPluginOwnedTaskTreeRequest - 91, // 93: kandev.plugin.v1.Host.DeletePluginOwnedTaskTree:input_type -> kandev.plugin.v1.DeletePluginOwnedTaskTreeRequest - 1, // 94: kandev.plugin.v1.Plugin.DeliverEvent:output_type -> kandev.plugin.v1.EventAck - 6, // 95: kandev.plugin.v1.Plugin.HandleWebhook:output_type -> kandev.plugin.v1.WebhookResponse - 9, // 96: kandev.plugin.v1.Plugin.HandleAction:output_type -> kandev.plugin.v1.PluginActionResponse - 11, // 97: kandev.plugin.v1.Plugin.SearchEntityReferences:output_type -> kandev.plugin.v1.SearchEntityReferencesResponse - 14, // 98: kandev.plugin.v1.Plugin.AuthorizeEntityReference:output_type -> kandev.plugin.v1.AuthorizeEntityReferenceResponse - 16, // 99: kandev.plugin.v1.Plugin.ResolveGitCredential:output_type -> kandev.plugin.v1.ResolveGitCredentialResponse - 18, // 100: kandev.plugin.v1.Plugin.GetGitCredentialBinding:output_type -> kandev.plugin.v1.GitCredentialBindingResponse - 4, // 101: kandev.plugin.v1.Plugin.InvokeAgentTool:output_type -> kandev.plugin.v1.AgentToolResponse - 20, // 102: kandev.plugin.v1.Host.GetState:output_type -> kandev.plugin.v1.GetStateResponse - 22, // 103: kandev.plugin.v1.Host.SetState:output_type -> kandev.plugin.v1.SetStateResponse - 24, // 104: kandev.plugin.v1.Host.DeleteState:output_type -> kandev.plugin.v1.DeleteStateResponse - 26, // 105: kandev.plugin.v1.Host.ListState:output_type -> kandev.plugin.v1.ListStateResponse - 37, // 106: kandev.plugin.v1.Host.RevealSecret:output_type -> kandev.plugin.v1.RevealSecretResponse - 39, // 107: kandev.plugin.v1.Host.EmitEvent:output_type -> kandev.plugin.v1.EmitEventResponse - 31, // 108: kandev.plugin.v1.Host.GetSecret:output_type -> kandev.plugin.v1.GetSecretResponse - 33, // 109: kandev.plugin.v1.Host.SetSecret:output_type -> kandev.plugin.v1.SetSecretResponse - 35, // 110: kandev.plugin.v1.Host.DeleteSecret:output_type -> kandev.plugin.v1.DeleteSecretResponse - 29, // 111: kandev.plugin.v1.Host.GetConfig:output_type -> kandev.plugin.v1.GetConfigResponse - 46, // 112: kandev.plugin.v1.Host.ListTasks:output_type -> kandev.plugin.v1.ListTasksResponse - 48, // 113: kandev.plugin.v1.Host.GetTask:output_type -> kandev.plugin.v1.GetTaskResponse - 51, // 114: kandev.plugin.v1.Host.ListWorkspaces:output_type -> kandev.plugin.v1.ListWorkspacesResponse - 54, // 115: kandev.plugin.v1.Host.ListWorkflows:output_type -> kandev.plugin.v1.ListWorkflowsResponse - 57, // 116: kandev.plugin.v1.Host.ListWorkflowSteps:output_type -> kandev.plugin.v1.ListWorkflowStepsResponse - 60, // 117: kandev.plugin.v1.Host.ListAgentProfiles:output_type -> kandev.plugin.v1.ListAgentProfilesResponse - 63, // 118: kandev.plugin.v1.Host.ListExecutorProfiles:output_type -> kandev.plugin.v1.ListExecutorProfilesResponse - 66, // 119: kandev.plugin.v1.Host.ListRepositories:output_type -> kandev.plugin.v1.ListRepositoriesResponse - 70, // 120: kandev.plugin.v1.Host.ListSessions:output_type -> kandev.plugin.v1.ListSessionsResponse - 73, // 121: kandev.plugin.v1.Host.ListSessionCodeStats:output_type -> kandev.plugin.v1.ListSessionCodeStatsResponse - 77, // 122: kandev.plugin.v1.Host.ListMessages:output_type -> kandev.plugin.v1.ListMessagesResponse - 79, // 123: kandev.plugin.v1.Host.InvokeUtilityAgent:output_type -> kandev.plugin.v1.InvokeUtilityAgentResponse - 84, // 124: kandev.plugin.v1.Host.CreateTask:output_type -> kandev.plugin.v1.CreateTaskResponse - 86, // 125: kandev.plugin.v1.Host.UpdateTask:output_type -> kandev.plugin.v1.UpdateTaskResponse - 88, // 126: kandev.plugin.v1.Host.SendMessage:output_type -> kandev.plugin.v1.SendMessageResponse - 90, // 127: kandev.plugin.v1.Host.PreviewPluginOwnedTaskTree:output_type -> kandev.plugin.v1.PreviewPluginOwnedTaskTreeResponse - 92, // 128: kandev.plugin.v1.Host.DeletePluginOwnedTaskTree:output_type -> kandev.plugin.v1.DeletePluginOwnedTaskTreeResponse - 94, // [94:129] is the sub-list for method output_type - 59, // [59:94] is the sub-list for method input_type - 59, // [59:59] is the sub-list for extension type_name - 59, // [59:59] is the sub-list for extension extendee - 0, // [0:59] is the sub-list for field type_name + 112, // 0: kandev.plugin.v1.Event.payload:type_name -> google.protobuf.Struct + 112, // 1: kandev.plugin.v1.AgentToolRequest.arguments:type_name -> google.protobuf.Struct + 3, // 2: kandev.plugin.v1.AgentToolRequest.context:type_name -> kandev.plugin.v1.AgentToolContext + 112, // 3: kandev.plugin.v1.AgentToolResponse.structured_content:type_name -> google.protobuf.Struct + 109, // 4: kandev.plugin.v1.WebhookRequest.headers:type_name -> kandev.plugin.v1.WebhookRequest.HeadersEntry + 110, // 5: kandev.plugin.v1.WebhookResponse.headers:type_name -> kandev.plugin.v1.WebhookResponse.HeadersEntry + 8, // 6: kandev.plugin.v1.PluginActionRequest.context:type_name -> kandev.plugin.v1.VerifiedActionContext + 111, // 7: kandev.plugin.v1.PluginActionResponse.headers:type_name -> kandev.plugin.v1.PluginActionResponse.HeadersEntry + 12, // 8: kandev.plugin.v1.SearchEntityReferencesResponse.candidates:type_name -> kandev.plugin.v1.EntityReferenceCandidate + 112, // 9: kandev.plugin.v1.EntityReferenceCandidate.attributes:type_name -> google.protobuf.Struct + 112, // 10: kandev.plugin.v1.AuthorizeEntityReferenceRequest.reference:type_name -> google.protobuf.Struct + 112, // 11: kandev.plugin.v1.GetStateResponse.value:type_name -> google.protobuf.Struct + 112, // 12: kandev.plugin.v1.SetStateRequest.value:type_name -> google.protobuf.Struct + 27, // 13: kandev.plugin.v1.ListStateResponse.entries:type_name -> kandev.plugin.v1.StateEntry + 112, // 14: kandev.plugin.v1.StateEntry.value:type_name -> google.protobuf.Struct + 112, // 15: kandev.plugin.v1.GetConfigResponse.config:type_name -> google.protobuf.Struct + 112, // 16: kandev.plugin.v1.EmitEventRequest.payload:type_name -> google.protobuf.Struct + 43, // 17: kandev.plugin.v1.Task.repositories:type_name -> kandev.plugin.v1.TaskRepository + 112, // 18: kandev.plugin.v1.Task.metadata:type_name -> google.protobuf.Struct + 44, // 19: kandev.plugin.v1.ListTasksRequest.filter:type_name -> kandev.plugin.v1.TaskFilter + 40, // 20: kandev.plugin.v1.ListTasksRequest.page:type_name -> kandev.plugin.v1.Page + 42, // 21: kandev.plugin.v1.ListTasksResponse.tasks:type_name -> kandev.plugin.v1.Task + 41, // 22: kandev.plugin.v1.ListTasksResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 42, // 23: kandev.plugin.v1.GetTaskResponse.task:type_name -> kandev.plugin.v1.Task + 40, // 24: kandev.plugin.v1.ListWorkspacesRequest.page:type_name -> kandev.plugin.v1.Page + 49, // 25: kandev.plugin.v1.ListWorkspacesResponse.workspaces:type_name -> kandev.plugin.v1.Workspace + 41, // 26: kandev.plugin.v1.ListWorkspacesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 40, // 27: kandev.plugin.v1.ListWorkflowsRequest.page:type_name -> kandev.plugin.v1.Page + 52, // 28: kandev.plugin.v1.ListWorkflowsResponse.workflows:type_name -> kandev.plugin.v1.Workflow + 41, // 29: kandev.plugin.v1.ListWorkflowsResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 55, // 30: kandev.plugin.v1.ListWorkflowStepsResponse.steps:type_name -> kandev.plugin.v1.WorkflowStep + 40, // 31: kandev.plugin.v1.ListAgentProfilesRequest.page:type_name -> kandev.plugin.v1.Page + 58, // 32: kandev.plugin.v1.ListAgentProfilesResponse.profiles:type_name -> kandev.plugin.v1.AgentProfile + 41, // 33: kandev.plugin.v1.ListAgentProfilesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 40, // 34: kandev.plugin.v1.ListExecutorProfilesRequest.page:type_name -> kandev.plugin.v1.Page + 61, // 35: kandev.plugin.v1.ListExecutorProfilesResponse.profiles:type_name -> kandev.plugin.v1.ExecutorProfile + 41, // 36: kandev.plugin.v1.ListExecutorProfilesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 40, // 37: kandev.plugin.v1.ListRepositoriesRequest.page:type_name -> kandev.plugin.v1.Page + 64, // 38: kandev.plugin.v1.ListRepositoriesResponse.repositories:type_name -> kandev.plugin.v1.Repository + 41, // 39: kandev.plugin.v1.ListRepositoriesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 68, // 40: kandev.plugin.v1.ListSessionsRequest.filter:type_name -> kandev.plugin.v1.SessionFilter + 40, // 41: kandev.plugin.v1.ListSessionsRequest.page:type_name -> kandev.plugin.v1.Page + 67, // 42: kandev.plugin.v1.ListSessionsResponse.sessions:type_name -> kandev.plugin.v1.Session + 41, // 43: kandev.plugin.v1.ListSessionsResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 68, // 44: kandev.plugin.v1.ListSessionCodeStatsRequest.filter:type_name -> kandev.plugin.v1.SessionFilter + 40, // 45: kandev.plugin.v1.ListSessionCodeStatsRequest.page:type_name -> kandev.plugin.v1.Page + 71, // 46: kandev.plugin.v1.ListSessionCodeStatsResponse.stats:type_name -> kandev.plugin.v1.SessionCodeStats + 41, // 47: kandev.plugin.v1.ListSessionCodeStatsResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 75, // 48: kandev.plugin.v1.ListMessagesRequest.filter:type_name -> kandev.plugin.v1.MessageFilter + 40, // 49: kandev.plugin.v1.ListMessagesRequest.page:type_name -> kandev.plugin.v1.Page + 74, // 50: kandev.plugin.v1.ListMessagesResponse.messages:type_name -> kandev.plugin.v1.Message + 41, // 51: kandev.plugin.v1.ListMessagesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 78, // 52: kandev.plugin.v1.InteractionQuestion.options:type_name -> kandev.plugin.v1.InteractionOption + 78, // 53: kandev.plugin.v1.Interaction.options:type_name -> kandev.plugin.v1.InteractionOption + 79, // 54: kandev.plugin.v1.Interaction.questions:type_name -> kandev.plugin.v1.InteractionQuestion + 81, // 55: kandev.plugin.v1.ListPendingInteractionsRequest.filter:type_name -> kandev.plugin.v1.InteractionFilter + 40, // 56: kandev.plugin.v1.ListPendingInteractionsRequest.page:type_name -> kandev.plugin.v1.Page + 80, // 57: kandev.plugin.v1.ListPendingInteractionsResponse.interactions:type_name -> kandev.plugin.v1.Interaction + 41, // 58: kandev.plugin.v1.ListPendingInteractionsResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 80, // 59: kandev.plugin.v1.GetInteractionResponse.interaction:type_name -> kandev.plugin.v1.Interaction + 80, // 60: kandev.plugin.v1.RespondToPermissionResponse.interaction:type_name -> kandev.plugin.v1.Interaction + 88, // 61: kandev.plugin.v1.AnswerClarificationRequest.answers:type_name -> kandev.plugin.v1.ClarificationAnswer + 80, // 62: kandev.plugin.v1.AnswerClarificationResponse.interaction:type_name -> kandev.plugin.v1.Interaction + 80, // 63: kandev.plugin.v1.CancelClarificationResponse.interaction:type_name -> kandev.plugin.v1.Interaction + 96, // 64: kandev.plugin.v1.CreateTaskRequest.repositories:type_name -> kandev.plugin.v1.PluginTaskRepository + 98, // 65: kandev.plugin.v1.CreateTaskRequest.launch:type_name -> kandev.plugin.v1.PluginTaskLaunchOptions + 112, // 66: kandev.plugin.v1.CreateTaskRequest.metadata:type_name -> google.protobuf.Struct + 97, // 67: kandev.plugin.v1.PluginTaskRepository.remote:type_name -> kandev.plugin.v1.RemoteRepositoryDescriptor + 42, // 68: kandev.plugin.v1.CreateTaskResponse.task:type_name -> kandev.plugin.v1.Task + 42, // 69: kandev.plugin.v1.UpdateTaskResponse.task:type_name -> kandev.plugin.v1.Task + 42, // 70: kandev.plugin.v1.PreviewPluginOwnedTaskTreeResponse.tasks:type_name -> kandev.plugin.v1.Task + 0, // 71: kandev.plugin.v1.Plugin.DeliverEvent:input_type -> kandev.plugin.v1.Event + 5, // 72: kandev.plugin.v1.Plugin.HandleWebhook:input_type -> kandev.plugin.v1.WebhookRequest + 7, // 73: kandev.plugin.v1.Plugin.HandleAction:input_type -> kandev.plugin.v1.PluginActionRequest + 10, // 74: kandev.plugin.v1.Plugin.SearchEntityReferences:input_type -> kandev.plugin.v1.SearchEntityReferencesRequest + 13, // 75: kandev.plugin.v1.Plugin.AuthorizeEntityReference:input_type -> kandev.plugin.v1.AuthorizeEntityReferenceRequest + 15, // 76: kandev.plugin.v1.Plugin.ResolveGitCredential:input_type -> kandev.plugin.v1.ResolveGitCredentialRequest + 17, // 77: kandev.plugin.v1.Plugin.GetGitCredentialBinding:input_type -> kandev.plugin.v1.GitCredentialBindingRequest + 2, // 78: kandev.plugin.v1.Plugin.InvokeAgentTool:input_type -> kandev.plugin.v1.AgentToolRequest + 19, // 79: kandev.plugin.v1.Host.GetState:input_type -> kandev.plugin.v1.GetStateRequest + 21, // 80: kandev.plugin.v1.Host.SetState:input_type -> kandev.plugin.v1.SetStateRequest + 23, // 81: kandev.plugin.v1.Host.DeleteState:input_type -> kandev.plugin.v1.DeleteStateRequest + 25, // 82: kandev.plugin.v1.Host.ListState:input_type -> kandev.plugin.v1.ListStateRequest + 36, // 83: kandev.plugin.v1.Host.RevealSecret:input_type -> kandev.plugin.v1.RevealSecretRequest + 38, // 84: kandev.plugin.v1.Host.EmitEvent:input_type -> kandev.plugin.v1.EmitEventRequest + 30, // 85: kandev.plugin.v1.Host.GetSecret:input_type -> kandev.plugin.v1.GetSecretRequest + 32, // 86: kandev.plugin.v1.Host.SetSecret:input_type -> kandev.plugin.v1.SetSecretRequest + 34, // 87: kandev.plugin.v1.Host.DeleteSecret:input_type -> kandev.plugin.v1.DeleteSecretRequest + 28, // 88: kandev.plugin.v1.Host.GetConfig:input_type -> kandev.plugin.v1.GetConfigRequest + 45, // 89: kandev.plugin.v1.Host.ListTasks:input_type -> kandev.plugin.v1.ListTasksRequest + 47, // 90: kandev.plugin.v1.Host.GetTask:input_type -> kandev.plugin.v1.GetTaskRequest + 50, // 91: kandev.plugin.v1.Host.ListWorkspaces:input_type -> kandev.plugin.v1.ListWorkspacesRequest + 53, // 92: kandev.plugin.v1.Host.ListWorkflows:input_type -> kandev.plugin.v1.ListWorkflowsRequest + 56, // 93: kandev.plugin.v1.Host.ListWorkflowSteps:input_type -> kandev.plugin.v1.ListWorkflowStepsRequest + 59, // 94: kandev.plugin.v1.Host.ListAgentProfiles:input_type -> kandev.plugin.v1.ListAgentProfilesRequest + 62, // 95: kandev.plugin.v1.Host.ListExecutorProfiles:input_type -> kandev.plugin.v1.ListExecutorProfilesRequest + 65, // 96: kandev.plugin.v1.Host.ListRepositories:input_type -> kandev.plugin.v1.ListRepositoriesRequest + 69, // 97: kandev.plugin.v1.Host.ListSessions:input_type -> kandev.plugin.v1.ListSessionsRequest + 72, // 98: kandev.plugin.v1.Host.ListSessionCodeStats:input_type -> kandev.plugin.v1.ListSessionCodeStatsRequest + 76, // 99: kandev.plugin.v1.Host.ListMessages:input_type -> kandev.plugin.v1.ListMessagesRequest + 82, // 100: kandev.plugin.v1.Host.ListPendingInteractions:input_type -> kandev.plugin.v1.ListPendingInteractionsRequest + 84, // 101: kandev.plugin.v1.Host.GetInteraction:input_type -> kandev.plugin.v1.GetInteractionRequest + 93, // 102: kandev.plugin.v1.Host.InvokeUtilityAgent:input_type -> kandev.plugin.v1.InvokeUtilityAgentRequest + 95, // 103: kandev.plugin.v1.Host.CreateTask:input_type -> kandev.plugin.v1.CreateTaskRequest + 100, // 104: kandev.plugin.v1.Host.UpdateTask:input_type -> kandev.plugin.v1.UpdateTaskRequest + 102, // 105: kandev.plugin.v1.Host.SendMessage:input_type -> kandev.plugin.v1.SendMessageRequest + 104, // 106: kandev.plugin.v1.Host.PreviewPluginOwnedTaskTree:input_type -> kandev.plugin.v1.PreviewPluginOwnedTaskTreeRequest + 106, // 107: kandev.plugin.v1.Host.DeletePluginOwnedTaskTree:input_type -> kandev.plugin.v1.DeletePluginOwnedTaskTreeRequest + 86, // 108: kandev.plugin.v1.Host.RespondToPermission:input_type -> kandev.plugin.v1.RespondToPermissionRequest + 89, // 109: kandev.plugin.v1.Host.AnswerClarification:input_type -> kandev.plugin.v1.AnswerClarificationRequest + 91, // 110: kandev.plugin.v1.Host.CancelClarification:input_type -> kandev.plugin.v1.CancelClarificationRequest + 1, // 111: kandev.plugin.v1.Plugin.DeliverEvent:output_type -> kandev.plugin.v1.EventAck + 6, // 112: kandev.plugin.v1.Plugin.HandleWebhook:output_type -> kandev.plugin.v1.WebhookResponse + 9, // 113: kandev.plugin.v1.Plugin.HandleAction:output_type -> kandev.plugin.v1.PluginActionResponse + 11, // 114: kandev.plugin.v1.Plugin.SearchEntityReferences:output_type -> kandev.plugin.v1.SearchEntityReferencesResponse + 14, // 115: kandev.plugin.v1.Plugin.AuthorizeEntityReference:output_type -> kandev.plugin.v1.AuthorizeEntityReferenceResponse + 16, // 116: kandev.plugin.v1.Plugin.ResolveGitCredential:output_type -> kandev.plugin.v1.ResolveGitCredentialResponse + 18, // 117: kandev.plugin.v1.Plugin.GetGitCredentialBinding:output_type -> kandev.plugin.v1.GitCredentialBindingResponse + 4, // 118: kandev.plugin.v1.Plugin.InvokeAgentTool:output_type -> kandev.plugin.v1.AgentToolResponse + 20, // 119: kandev.plugin.v1.Host.GetState:output_type -> kandev.plugin.v1.GetStateResponse + 22, // 120: kandev.plugin.v1.Host.SetState:output_type -> kandev.plugin.v1.SetStateResponse + 24, // 121: kandev.plugin.v1.Host.DeleteState:output_type -> kandev.plugin.v1.DeleteStateResponse + 26, // 122: kandev.plugin.v1.Host.ListState:output_type -> kandev.plugin.v1.ListStateResponse + 37, // 123: kandev.plugin.v1.Host.RevealSecret:output_type -> kandev.plugin.v1.RevealSecretResponse + 39, // 124: kandev.plugin.v1.Host.EmitEvent:output_type -> kandev.plugin.v1.EmitEventResponse + 31, // 125: kandev.plugin.v1.Host.GetSecret:output_type -> kandev.plugin.v1.GetSecretResponse + 33, // 126: kandev.plugin.v1.Host.SetSecret:output_type -> kandev.plugin.v1.SetSecretResponse + 35, // 127: kandev.plugin.v1.Host.DeleteSecret:output_type -> kandev.plugin.v1.DeleteSecretResponse + 29, // 128: kandev.plugin.v1.Host.GetConfig:output_type -> kandev.plugin.v1.GetConfigResponse + 46, // 129: kandev.plugin.v1.Host.ListTasks:output_type -> kandev.plugin.v1.ListTasksResponse + 48, // 130: kandev.plugin.v1.Host.GetTask:output_type -> kandev.plugin.v1.GetTaskResponse + 51, // 131: kandev.plugin.v1.Host.ListWorkspaces:output_type -> kandev.plugin.v1.ListWorkspacesResponse + 54, // 132: kandev.plugin.v1.Host.ListWorkflows:output_type -> kandev.plugin.v1.ListWorkflowsResponse + 57, // 133: kandev.plugin.v1.Host.ListWorkflowSteps:output_type -> kandev.plugin.v1.ListWorkflowStepsResponse + 60, // 134: kandev.plugin.v1.Host.ListAgentProfiles:output_type -> kandev.plugin.v1.ListAgentProfilesResponse + 63, // 135: kandev.plugin.v1.Host.ListExecutorProfiles:output_type -> kandev.plugin.v1.ListExecutorProfilesResponse + 66, // 136: kandev.plugin.v1.Host.ListRepositories:output_type -> kandev.plugin.v1.ListRepositoriesResponse + 70, // 137: kandev.plugin.v1.Host.ListSessions:output_type -> kandev.plugin.v1.ListSessionsResponse + 73, // 138: kandev.plugin.v1.Host.ListSessionCodeStats:output_type -> kandev.plugin.v1.ListSessionCodeStatsResponse + 77, // 139: kandev.plugin.v1.Host.ListMessages:output_type -> kandev.plugin.v1.ListMessagesResponse + 83, // 140: kandev.plugin.v1.Host.ListPendingInteractions:output_type -> kandev.plugin.v1.ListPendingInteractionsResponse + 85, // 141: kandev.plugin.v1.Host.GetInteraction:output_type -> kandev.plugin.v1.GetInteractionResponse + 94, // 142: kandev.plugin.v1.Host.InvokeUtilityAgent:output_type -> kandev.plugin.v1.InvokeUtilityAgentResponse + 99, // 143: kandev.plugin.v1.Host.CreateTask:output_type -> kandev.plugin.v1.CreateTaskResponse + 101, // 144: kandev.plugin.v1.Host.UpdateTask:output_type -> kandev.plugin.v1.UpdateTaskResponse + 103, // 145: kandev.plugin.v1.Host.SendMessage:output_type -> kandev.plugin.v1.SendMessageResponse + 105, // 146: kandev.plugin.v1.Host.PreviewPluginOwnedTaskTree:output_type -> kandev.plugin.v1.PreviewPluginOwnedTaskTreeResponse + 107, // 147: kandev.plugin.v1.Host.DeletePluginOwnedTaskTree:output_type -> kandev.plugin.v1.DeletePluginOwnedTaskTreeResponse + 87, // 148: kandev.plugin.v1.Host.RespondToPermission:output_type -> kandev.plugin.v1.RespondToPermissionResponse + 90, // 149: kandev.plugin.v1.Host.AnswerClarification:output_type -> kandev.plugin.v1.AnswerClarificationResponse + 92, // 150: kandev.plugin.v1.Host.CancelClarification:output_type -> kandev.plugin.v1.CancelClarificationResponse + 111, // [111:151] is the sub-list for method output_type + 71, // [71:111] is the sub-list for method input_type + 71, // [71:71] is the sub-list for extension type_name + 71, // [71:71] is the sub-list for extension extendee + 0, // [0:71] is the sub-list for field type_name } func init() { file_kandev_plugin_v1_plugin_proto_init() } @@ -6577,18 +7614,18 @@ func file_kandev_plugin_v1_plugin_proto_init() { file_kandev_plugin_v1_plugin_proto_msgTypes[64].OneofWrappers = []any{} file_kandev_plugin_v1_plugin_proto_msgTypes[67].OneofWrappers = []any{} file_kandev_plugin_v1_plugin_proto_msgTypes[75].OneofWrappers = []any{} - file_kandev_plugin_v1_plugin_proto_msgTypes[80].OneofWrappers = []any{} - file_kandev_plugin_v1_plugin_proto_msgTypes[81].OneofWrappers = []any{} - file_kandev_plugin_v1_plugin_proto_msgTypes[82].OneofWrappers = []any{} - file_kandev_plugin_v1_plugin_proto_msgTypes[83].OneofWrappers = []any{} - file_kandev_plugin_v1_plugin_proto_msgTypes[85].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[95].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[96].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[97].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[98].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[100].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_kandev_plugin_v1_plugin_proto_rawDesc), len(file_kandev_plugin_v1_plugin_proto_rawDesc)), NumEnums: 0, - NumMessages: 97, + NumMessages: 112, NumExtensions: 0, NumServices: 2, }, diff --git a/apps/backend/proto/kandev/plugin/v1/plugin.proto b/apps/backend/proto/kandev/plugin/v1/plugin.proto index 76dc655e75..3607fae376 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin.proto +++ b/apps/backend/proto/kandev/plugin/v1/plugin.proto @@ -101,6 +101,19 @@ service Host { // event — raw system prompts are never exposed to plugins. rpc ListMessages(ListMessagesRequest) returns (ListMessagesResponse); + // Pending agent interactions — capability api_read:interactions. The + // DURABLE record of every permission request and clarification bundle still + // owed a human response, under the same turn/session authority kandev's own + // list surfaces use. Session state alone cannot answer this: WAITING_FOR_INPUT + // also describes an ordinarily completed turn, so a state-only consumer + // reports attention that is not owed. Reading the record is what lets a + // consumer that started late, restarted, or missed an event reconcile. + // GetInteraction resolves ANY interaction — including terminal ones — so a + // replayed or missed event converges on the current result instead of + // NotFound. + rpc ListPendingInteractions(ListPendingInteractionsRequest) returns (ListPendingInteractionsResponse); + rpc GetInteraction(GetInteractionRequest) returns (GetInteractionResponse); + // Utility agent — capability agent_invoke. Runs a one-shot, non-interactive // completion using the operator-configured "utility agent" profile (Settings // > System), so a plugin can delegate a lightweight LLM step without holding @@ -120,6 +133,18 @@ service Host { rpc SendMessage(SendMessageRequest) returns (SendMessageResponse); rpc PreviewPluginOwnedTaskTree(PreviewPluginOwnedTaskTreeRequest) returns (PreviewPluginOwnedTaskTreeResponse); rpc DeletePluginOwnedTaskTree(DeletePluginOwnedTaskTreeRequest) returns (DeletePluginOwnedTaskTreeResponse); + + // Interaction responses — capability api_write:interactions. Each routes + // through the same first-party service the native UI uses, so the agent + // unblocks, the durable record turns terminal, and every surface converges + // through the normal events. Terminal-once: an interaction that already has + // a resolution answers FailedPrecondition rather than dispatching a second + // response, and an unknown id answers NotFound — the two outcomes a + // reconciling cache needs to distinguish "someone else got there first" from + // "I am holding a stale id". + rpc RespondToPermission(RespondToPermissionRequest) returns (RespondToPermissionResponse); + rpc AnswerClarification(AnswerClarificationRequest) returns (AnswerClarificationResponse); + rpc CancelClarification(CancelClarificationRequest) returns (CancelClarificationResponse); } message Event { @@ -583,6 +608,109 @@ message ListMessagesResponse { PageInfo page_info = 2; } +// ── Pending interactions (api_read/api_write:interactions) ─────────────────── +// +// One Interaction is one thing a human still owes an answer to: a tool +// permission request, or a whole clarification bundle (its per-question rows +// collapsed into `questions`). `id` is the pending id every response RPC keys +// on, and the same id the permission_request.received / clarification.* bus +// events and the persisted request messages carry — that shared key is what an +// event-driven cache reconciles its snapshot against. +message InteractionOption { + string option_id = 1; + string label = 2; + // ACP permission-option kind ("allow_once" | "allow_always" | + // "reject_once" | "reject_always") on a permission; empty on a + // clarification option. + string kind = 3; + // Longer explanation of a clarification option; empty on a permission. + string description = 4; +} +message InteractionQuestion { + string id = 1; + string title = 2; + string prompt = 3; + repeated InteractionOption options = 4; +} +message Interaction { + string id = 1; // pending id + string kind = 2; // "permission" | "clarification" + string task_id = 3; + string session_id = 4; + string turn_id = 5; + // "pending" | "approved" | "rejected" | "answered" | "cancelled" | "expired". + // Anything other than "pending" is terminal and no longer answerable. A + // clarification bundle is only terminal once every question is. + string status = 6; + string title = 7; // permission prompt, or the bundle's first question + string context = 8; // shared clarification context; empty on a permission + string tool_call_id = 9; + string action_type = 10; + repeated InteractionOption options = 11; // permission options + repeated InteractionQuestion questions = 12; // clarification questions + // A still-pending clarification whose original waiter has gone away (the + // agent moved on, or kandev restarted). Still answerable — the answer + // resumes the session in a NEW turn — so this is not a resolution. + bool agent_disconnected = 13; + string created_at = 14; // RFC3339 + string updated_at = 15; // RFC3339 +} +message InteractionFilter { + repeated string session_ids = 1; // empty → not constrained by session + repeated string task_ids = 2; // empty → not constrained by task + repeated string kinds = 3; // empty → both kinds +} +message ListPendingInteractionsRequest { + InteractionFilter filter = 1; + Page page = 2; +} +message ListPendingInteractionsResponse { + repeated Interaction interactions = 1; + PageInfo page_info = 2; +} +message GetInteractionRequest { + string id = 1; +} +message GetInteractionResponse { + Interaction interaction = 1; +} + +// ── Interaction responses (api_write:interactions) ─────────────────────────── +message RespondToPermissionRequest { + string id = 1; // pending id + // Must name one of the interaction's declared options. kandev derives the + // approve/deny outcome from that option's kind, so a plugin cannot report an + // outcome the agent never offered. + string option_id = 2; + // Dismiss the request instead of selecting an option (the ACP "cancelled" + // outcome). option_id must be empty when this is set. + bool cancelled = 3; +} +message RespondToPermissionResponse { + Interaction interaction = 1; // the interaction in its new terminal state +} +message ClarificationAnswer { + string question_id = 1; + repeated string selected_options = 2; // option ids + string custom_text = 3; +} +message AnswerClarificationRequest { + string id = 1; // pending id + // Exactly one answer per question in the bundle; the agent stays blocked + // until every question is answered. + repeated ClarificationAnswer answers = 2; +} +message AnswerClarificationResponse { + Interaction interaction = 1; +} +message CancelClarificationRequest { + string id = 1; // pending id + string reason = 2; // surfaced to the agent as the decline reason +} +message CancelClarificationResponse { + Interaction interaction = 1; +} + // ── Utility agent invocation (agent_invoke) ────────────────────────────────── // A one-shot completion delegated to the operator-configured utility agent. // Adding fields here (e.g. a future system_prompt or max_tokens) is the diff --git a/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go b/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go index b7c92eb8a6..d39307453e 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go +++ b/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go @@ -434,12 +434,17 @@ const ( Host_ListSessions_FullMethodName = "/kandev.plugin.v1.Host/ListSessions" Host_ListSessionCodeStats_FullMethodName = "/kandev.plugin.v1.Host/ListSessionCodeStats" Host_ListMessages_FullMethodName = "/kandev.plugin.v1.Host/ListMessages" + Host_ListPendingInteractions_FullMethodName = "/kandev.plugin.v1.Host/ListPendingInteractions" + Host_GetInteraction_FullMethodName = "/kandev.plugin.v1.Host/GetInteraction" Host_InvokeUtilityAgent_FullMethodName = "/kandev.plugin.v1.Host/InvokeUtilityAgent" Host_CreateTask_FullMethodName = "/kandev.plugin.v1.Host/CreateTask" Host_UpdateTask_FullMethodName = "/kandev.plugin.v1.Host/UpdateTask" Host_SendMessage_FullMethodName = "/kandev.plugin.v1.Host/SendMessage" Host_PreviewPluginOwnedTaskTree_FullMethodName = "/kandev.plugin.v1.Host/PreviewPluginOwnedTaskTree" Host_DeletePluginOwnedTaskTree_FullMethodName = "/kandev.plugin.v1.Host/DeletePluginOwnedTaskTree" + Host_RespondToPermission_FullMethodName = "/kandev.plugin.v1.Host/RespondToPermission" + Host_AnswerClarification_FullMethodName = "/kandev.plugin.v1.Host/AnswerClarification" + Host_CancelClarification_FullMethodName = "/kandev.plugin.v1.Host/CancelClarification" ) // HostClient is the client API for Host service. @@ -512,6 +517,18 @@ type HostClient interface { // blocks are stripped, exactly like the message.added bus // event — raw system prompts are never exposed to plugins. ListMessages(ctx context.Context, in *ListMessagesRequest, opts ...grpc.CallOption) (*ListMessagesResponse, error) + // Pending agent interactions — capability api_read:interactions. The + // DURABLE record of every permission request and clarification bundle still + // owed a human response, under the same turn/session authority kandev's own + // list surfaces use. Session state alone cannot answer this: WAITING_FOR_INPUT + // also describes an ordinarily completed turn, so a state-only consumer + // reports attention that is not owed. Reading the record is what lets a + // consumer that started late, restarted, or missed an event reconcile. + // GetInteraction resolves ANY interaction — including terminal ones — so a + // replayed or missed event converges on the current result instead of + // NotFound. + ListPendingInteractions(ctx context.Context, in *ListPendingInteractionsRequest, opts ...grpc.CallOption) (*ListPendingInteractionsResponse, error) + GetInteraction(ctx context.Context, in *GetInteractionRequest, opts ...grpc.CallOption) (*GetInteractionResponse, error) // Utility agent — capability agent_invoke. Runs a one-shot, non-interactive // completion using the operator-configured "utility agent" profile (Settings // > System), so a plugin can delegate a lightweight LLM step without holding @@ -530,6 +547,17 @@ type HostClient interface { SendMessage(ctx context.Context, in *SendMessageRequest, opts ...grpc.CallOption) (*SendMessageResponse, error) PreviewPluginOwnedTaskTree(ctx context.Context, in *PreviewPluginOwnedTaskTreeRequest, opts ...grpc.CallOption) (*PreviewPluginOwnedTaskTreeResponse, error) DeletePluginOwnedTaskTree(ctx context.Context, in *DeletePluginOwnedTaskTreeRequest, opts ...grpc.CallOption) (*DeletePluginOwnedTaskTreeResponse, error) + // Interaction responses — capability api_write:interactions. Each routes + // through the same first-party service the native UI uses, so the agent + // unblocks, the durable record turns terminal, and every surface converges + // through the normal events. Terminal-once: an interaction that already has + // a resolution answers FailedPrecondition rather than dispatching a second + // response, and an unknown id answers NotFound — the two outcomes a + // reconciling cache needs to distinguish "someone else got there first" from + // "I am holding a stale id". + RespondToPermission(ctx context.Context, in *RespondToPermissionRequest, opts ...grpc.CallOption) (*RespondToPermissionResponse, error) + AnswerClarification(ctx context.Context, in *AnswerClarificationRequest, opts ...grpc.CallOption) (*AnswerClarificationResponse, error) + CancelClarification(ctx context.Context, in *CancelClarificationRequest, opts ...grpc.CallOption) (*CancelClarificationResponse, error) } type hostClient struct { @@ -750,6 +778,26 @@ func (c *hostClient) ListMessages(ctx context.Context, in *ListMessagesRequest, return out, nil } +func (c *hostClient) ListPendingInteractions(ctx context.Context, in *ListPendingInteractionsRequest, opts ...grpc.CallOption) (*ListPendingInteractionsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListPendingInteractionsResponse) + err := c.cc.Invoke(ctx, Host_ListPendingInteractions_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *hostClient) GetInteraction(ctx context.Context, in *GetInteractionRequest, opts ...grpc.CallOption) (*GetInteractionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetInteractionResponse) + err := c.cc.Invoke(ctx, Host_GetInteraction_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *hostClient) InvokeUtilityAgent(ctx context.Context, in *InvokeUtilityAgentRequest, opts ...grpc.CallOption) (*InvokeUtilityAgentResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(InvokeUtilityAgentResponse) @@ -810,6 +858,36 @@ func (c *hostClient) DeletePluginOwnedTaskTree(ctx context.Context, in *DeletePl return out, nil } +func (c *hostClient) RespondToPermission(ctx context.Context, in *RespondToPermissionRequest, opts ...grpc.CallOption) (*RespondToPermissionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RespondToPermissionResponse) + err := c.cc.Invoke(ctx, Host_RespondToPermission_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *hostClient) AnswerClarification(ctx context.Context, in *AnswerClarificationRequest, opts ...grpc.CallOption) (*AnswerClarificationResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AnswerClarificationResponse) + err := c.cc.Invoke(ctx, Host_AnswerClarification_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *hostClient) CancelClarification(ctx context.Context, in *CancelClarificationRequest, opts ...grpc.CallOption) (*CancelClarificationResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CancelClarificationResponse) + err := c.cc.Invoke(ctx, Host_CancelClarification_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // HostServer is the server API for Host service. // All implementations must embed UnimplementedHostServer // for forward compatibility. @@ -880,6 +958,18 @@ type HostServer interface { // blocks are stripped, exactly like the message.added bus // event — raw system prompts are never exposed to plugins. ListMessages(context.Context, *ListMessagesRequest) (*ListMessagesResponse, error) + // Pending agent interactions — capability api_read:interactions. The + // DURABLE record of every permission request and clarification bundle still + // owed a human response, under the same turn/session authority kandev's own + // list surfaces use. Session state alone cannot answer this: WAITING_FOR_INPUT + // also describes an ordinarily completed turn, so a state-only consumer + // reports attention that is not owed. Reading the record is what lets a + // consumer that started late, restarted, or missed an event reconcile. + // GetInteraction resolves ANY interaction — including terminal ones — so a + // replayed or missed event converges on the current result instead of + // NotFound. + ListPendingInteractions(context.Context, *ListPendingInteractionsRequest) (*ListPendingInteractionsResponse, error) + GetInteraction(context.Context, *GetInteractionRequest) (*GetInteractionResponse, error) // Utility agent — capability agent_invoke. Runs a one-shot, non-interactive // completion using the operator-configured "utility agent" profile (Settings // > System), so a plugin can delegate a lightweight LLM step without holding @@ -898,6 +988,17 @@ type HostServer interface { SendMessage(context.Context, *SendMessageRequest) (*SendMessageResponse, error) PreviewPluginOwnedTaskTree(context.Context, *PreviewPluginOwnedTaskTreeRequest) (*PreviewPluginOwnedTaskTreeResponse, error) DeletePluginOwnedTaskTree(context.Context, *DeletePluginOwnedTaskTreeRequest) (*DeletePluginOwnedTaskTreeResponse, error) + // Interaction responses — capability api_write:interactions. Each routes + // through the same first-party service the native UI uses, so the agent + // unblocks, the durable record turns terminal, and every surface converges + // through the normal events. Terminal-once: an interaction that already has + // a resolution answers FailedPrecondition rather than dispatching a second + // response, and an unknown id answers NotFound — the two outcomes a + // reconciling cache needs to distinguish "someone else got there first" from + // "I am holding a stale id". + RespondToPermission(context.Context, *RespondToPermissionRequest) (*RespondToPermissionResponse, error) + AnswerClarification(context.Context, *AnswerClarificationRequest) (*AnswerClarificationResponse, error) + CancelClarification(context.Context, *CancelClarificationRequest) (*CancelClarificationResponse, error) mustEmbedUnimplementedHostServer() } @@ -971,6 +1072,12 @@ func (UnimplementedHostServer) ListSessionCodeStats(context.Context, *ListSessio func (UnimplementedHostServer) ListMessages(context.Context, *ListMessagesRequest) (*ListMessagesResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ListMessages not implemented") } +func (UnimplementedHostServer) ListPendingInteractions(context.Context, *ListPendingInteractionsRequest) (*ListPendingInteractionsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListPendingInteractions not implemented") +} +func (UnimplementedHostServer) GetInteraction(context.Context, *GetInteractionRequest) (*GetInteractionResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetInteraction not implemented") +} func (UnimplementedHostServer) InvokeUtilityAgent(context.Context, *InvokeUtilityAgentRequest) (*InvokeUtilityAgentResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method InvokeUtilityAgent not implemented") } @@ -989,6 +1096,15 @@ func (UnimplementedHostServer) PreviewPluginOwnedTaskTree(context.Context, *Prev func (UnimplementedHostServer) DeletePluginOwnedTaskTree(context.Context, *DeletePluginOwnedTaskTreeRequest) (*DeletePluginOwnedTaskTreeResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method DeletePluginOwnedTaskTree not implemented") } +func (UnimplementedHostServer) RespondToPermission(context.Context, *RespondToPermissionRequest) (*RespondToPermissionResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method RespondToPermission not implemented") +} +func (UnimplementedHostServer) AnswerClarification(context.Context, *AnswerClarificationRequest) (*AnswerClarificationResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method AnswerClarification not implemented") +} +func (UnimplementedHostServer) CancelClarification(context.Context, *CancelClarificationRequest) (*CancelClarificationResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CancelClarification not implemented") +} func (UnimplementedHostServer) mustEmbedUnimplementedHostServer() {} func (UnimplementedHostServer) testEmbeddedByValue() {} @@ -1388,6 +1504,42 @@ func _Host_ListMessages_Handler(srv interface{}, ctx context.Context, dec func(i return interceptor(ctx, in, info, handler) } +func _Host_ListPendingInteractions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListPendingInteractionsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).ListPendingInteractions(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_ListPendingInteractions_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).ListPendingInteractions(ctx, req.(*ListPendingInteractionsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Host_GetInteraction_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetInteractionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).GetInteraction(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_GetInteraction_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).GetInteraction(ctx, req.(*GetInteractionRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _Host_InvokeUtilityAgent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(InvokeUtilityAgentRequest) if err := dec(in); err != nil { @@ -1496,6 +1648,60 @@ func _Host_DeletePluginOwnedTaskTree_Handler(srv interface{}, ctx context.Contex return interceptor(ctx, in, info, handler) } +func _Host_RespondToPermission_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RespondToPermissionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).RespondToPermission(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_RespondToPermission_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).RespondToPermission(ctx, req.(*RespondToPermissionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Host_AnswerClarification_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AnswerClarificationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).AnswerClarification(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_AnswerClarification_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).AnswerClarification(ctx, req.(*AnswerClarificationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Host_CancelClarification_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CancelClarificationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).CancelClarification(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_CancelClarification_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).CancelClarification(ctx, req.(*CancelClarificationRequest)) + } + return interceptor(ctx, in, info, handler) +} + // Host_ServiceDesc is the grpc.ServiceDesc for Host service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -1587,6 +1793,14 @@ var Host_ServiceDesc = grpc.ServiceDesc{ MethodName: "ListMessages", Handler: _Host_ListMessages_Handler, }, + { + MethodName: "ListPendingInteractions", + Handler: _Host_ListPendingInteractions_Handler, + }, + { + MethodName: "GetInteraction", + Handler: _Host_GetInteraction_Handler, + }, { MethodName: "InvokeUtilityAgent", Handler: _Host_InvokeUtilityAgent_Handler, @@ -1611,6 +1825,18 @@ var Host_ServiceDesc = grpc.ServiceDesc{ MethodName: "DeletePluginOwnedTaskTree", Handler: _Host_DeletePluginOwnedTaskTree_Handler, }, + { + MethodName: "RespondToPermission", + Handler: _Host_RespondToPermission_Handler, + }, + { + MethodName: "AnswerClarification", + Handler: _Host_AnswerClarification_Handler, + }, + { + MethodName: "CancelClarification", + Handler: _Host_CancelClarification_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "kandev/plugin/v1/plugin.proto", diff --git a/docs/decisions/0052-plugin-host-interaction-api.md b/docs/decisions/0052-plugin-host-interaction-api.md new file mode 100644 index 0000000000..de0490fcca --- /dev/null +++ b/docs/decisions/0052-plugin-host-interaction-api.md @@ -0,0 +1,141 @@ +# 0052 — Plugins reconcile pending agent interactions through a dedicated Host contract + +- Status: accepted +- Date: 2026-08-22 +- Area: backend, protocol +- Related: [0043 — Plugin host data API](0043-plugin-host-data-api.md) (the pattern + this extends), [0047 — Plugins read conversation content](0047-plugin-host-conversation-reads.md), + [2026-08-14 — Current turn owns active clarification](2026-08-14-current-turn-clarification-ownership.md) + (the authority this reuses), [docs/specs/plugins/spec.md](../specs/plugins/spec.md) + +## Context + +A cross-workspace operator plugin can already subscribe to task, session, +message, permission and clarification events, and can read session metadata +through the Host data API. It still cannot answer the one question an attention +or inbox plugin exists to answer: **does a human owe this session a response +right now?** + +Kandev settled this internally long ago. `WAITING_FOR_INPUT` also describes an +ordinarily completed turn, so session state is not evidence of an owed +response; the durable `pending_action` projection is. A live compatibility run +on Kandev 0.90.0 with `@agentclientprotocol/codex-acp` 1.6.2 produced the +concrete case: the turn completed successfully, its only permission row was +durably `approved`, and the session snapshot still read `WAITING_FOR_INPUT`. A +state-only plugin claims attention is owed; kandev's own list surfaces +correctly do not. + +The public plugin surface exposed none of that: + +- `pluginsdk.Task` and `pluginsdk.Session` carry no pending-action projection. +- `pluginsdk.Message` omits request metadata — `pending_id`, status, the + options or questions needed to render a valid response, resolution state. +- `Host.Messages().Send` can send a new prompt, but there is no capability-gated + action corresponding to the permission-response or clarification + answer/cancel paths. + +Events cannot close the gap on their own. A plugin may start after the request +was made, restart, or drop an event; without a durable read it can never +reconcile its cache against the truth. The remaining escape hatch — reading the +SQLite file directly — is the exact failure ADR 0043 exists to close. + +## Decision + +Add a dedicated **interaction** contract to `service Host`, gated by its own +`api_read:interactions` / `api_write:interactions` capabilities. + +1. **A separate `Interactions()` accessor, not a field on existing DTOs.** + An additive pending-interaction field on `Session` or `Message` would drag + raw transcript metadata into DTOs that deliberately avoid it, and would + leave the write authorization without a boundary to attach to. A dedicated + accessor keeps both concerns in one gated place. It is also deliberately + **not** folded into the plugin-managed-conversation extension: an ordinary + attention or inbox plugin must not have to own an agent conversation + lifecycle to find out that a user owes an answer. + + It is an OPTIONAL Host extension (`pluginsdk.InteractionHost`), like + `ExecutorProfileHost` and `PluginOwnedTaskTreeHost`, so every existing + Go-native `Host` implementation stays source-compatible. + +2. **One durable read, one resolve.** + - `ListPendingInteractions(filter, page)` returns every interaction still + owed a response. `Interaction` carries the pending id, kind + (`permission` | `clarification`), task/session/turn ids, normalized + status, timestamps, and the permission options or structured questions a + caller needs to render a valid response. + - `GetInteraction(id)` resolves ANY interaction, terminal ones included. + That is what makes an event-driven cache reconcilable: a replayed or + missed event converges on the current durable result instead of + disappearing into `NotFound`. + +3. **The read reuses the pending-action authority rather than re-deriving it.** + `ListPendingInteractions` is built on the same predicates as + `GetPendingActionsBySessionIDs`: only the session's current durable turn + counts, terminal sessions quarantine pending history, and only the newest + permission row of that turn is answerable. A pinning test asserts the two + reach the same verdict per session. A plugin that disagrees with kandev's + own kanban card is a bug in one of them, and this makes that a test failure + rather than a support thread. + +4. **Writes route through the first-party services the native UI drives.** + `RespondToPermission` goes through the orchestrator method the WS + `permission.respond` action calls. `AnswerClarification` and + `CancelClarification` go through the same `clarification.Handlers` instance + the REST route serves, including its durable exclusive claim, live-waiter + delivery, and detached-resume fallback. Nothing is reimplemented, so a + plugin's response is indistinguishable from a user's and every surface + converges through the normal events. + + `Cancel` is routed to the handler's **reject** path rather than its + in-memory `CancelRequest`: cancellation needs the original waiter to still + be parked, while rejection is durable-claim based and therefore also + settles a bundle whose waiter went away in a restart. That is precisely the + bundle a reconciling plugin is holding. + +5. **The host derives the outcome; the plugin only names a choice.** + A permission response must name one of the options the agent actually + offered, and kandev derives approve-versus-deny from that option's recorded + ACP kind. A plugin therefore cannot report an outcome that was never on the + menu. The target session comes from the durable record, never from the + request. + +6. **Terminal-once, with two distinguishable failures.** The first response + wins. A write against an already-resolved interaction returns + `FailedPrecondition` instead of dispatching a second response; an unknown id + returns `NotFound`. A reconciling cache needs to tell "someone else answered + first" apart from "my id is stale", and those two codes are that + distinction. The clarification handler's own conflict outcome maps onto the + same `FailedPrecondition`, so a plugin branches on one code regardless of + which layer caught the race. + +## Consequences + +- An attention or inbox plugin can declare `api_read:interactions` alone and + never gain the ability to answer on a user's behalf. +- The clarification response sequence gained an exported + `Handlers.Respond` / `Handlers.Cancel` seam; the REST route is now a thin + shell over it. Behavior is unchanged — the HTTP status vocabulary moved into + a typed `RespondError` the REST layer renders and the plugin adapter maps to + gRPC codes. +- Interaction reads and the compact `pending_action` projection are two queries + over the same durable rows. They are pinned to agree, but a future change to + one must update both; the shared predicates (`turnAuthorityPredicate`, + `nonTerminalSessionPredicate`) are the single source for the parts that + matter most. +- Only the newest permission of a turn is listed, matching the projection. If + ACP ever issues genuinely concurrent permission requests in one turn, both + this contract and the projection need widening together. + +## Alternatives considered + +- **An additive pending field on `Session`/`Message`.** Rejected: it leaks + transcript metadata into DTOs that avoid it, and leaves the write path + without a capability boundary of its own. +- **Reuse the plugin-managed-conversation extension.** Rejected: it forces an + inbox plugin to take on a conversation lifecycle it has no use for. +- **Events only, with no durable read.** Rejected: it cannot serve a plugin + that started late, restarted, or dropped an event, which is the entire + reported problem. +- **Let plugins write message rows directly.** Rejected for the same reason + ADR 0043 rejects repository access: the agent would never unblock, and no + surface would converge. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 192aee9af4..035337cd90 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -59,6 +59,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 0049 | [Fine-grained foreground-idle busy signal](0049-fine-grained-foreground-idle-busy-signal.md) | superseded by 2026-07-28-coarse-running-busy-signal | backend, frontend, protocol | 2026-07-11 | | 0050 | [Plugins provide OIDC/SAML login via a capability-gated, host-minted session](0050-plugin-external-auth-capability.md) | accepted | backend, security, protocol | 2026-07-26 | | 0051 | [PR agent notifications extend task PR automation](0051-pr-agent-notifications-extend-task-pr-automation.md) | accepted | backend, frontend, workflow, GitHub, MCP | 2026-07-23 | +| 0052 | [Plugins reconcile pending agent interactions through a dedicated Host contract](0052-plugin-host-interaction-api.md) | accepted | backend, protocol | 2026-08-22 | | 2026-07-23-opencode-review-evidence-trust | [Trusted OpenCode Review Evidence](2026-07-23-opencode-review-evidence-trust.md) | accepted | workflow, infra | 2026-07-23 | | 2026-07-23-planner-direct-small-work | [Planner Direct Small Work](2026-07-23-planner-direct-small-work.md) | superseded by 2026-07-26 | workflow | 2026-07-23 | | 2026-07-23-post-commit-hook-aware-verification | [Post-Commit Hook-Aware Verification](2026-07-23-post-commit-hook-aware-verification.md) | superseded by 2026-07-26 | workflow | 2026-07-23 | diff --git a/docs/plans/plugins/GRPC-CONTRACT.md b/docs/plans/plugins/GRPC-CONTRACT.md index df716395ee..9c01f03109 100644 --- a/docs/plans/plugins/GRPC-CONTRACT.md +++ b/docs/plans/plugins/GRPC-CONTRACT.md @@ -227,6 +227,8 @@ plugin's manifest: | `ListRepositories` | `api_read:repositories` | repositories | | `ListSessions` | `api_read:sessions` | sessions | | `ListSessionCodeStats` | `api_read:sessions` | sessions | +| `ListMessages` | `api_read:messages` | messages | +| `ListPendingInteractions` / `GetInteraction` | `api_read:interactions` | interactions | An undeclared capability returns gRPC `PermissionDenied` with message `capability 'api_read:tasks' not declared` (substituting the actual resource) — @@ -265,6 +267,37 @@ prompted, resuming the agent if its process is gone (`"sent"`); a never-started one is launched with the prompt as its first turn (`"started"`). A failed dispatch deletes the recorded message so no orphan prompt is left. +**Pending interactions** (`api_read:interactions` / `api_write:interactions`, +ADR 0052 — `docs/decisions/0052-plugin-host-interaction-api.md`) are the durable +record of every agent request still owed a human answer: a tool permission +request, or a whole clarification bundle collapsed into one `Interaction` with +its `questions`. Session state is deliberately NOT that record — +`WAITING_FOR_INPUT` also describes an ordinarily completed turn — so a plugin +that branches on state alone reports attention nobody owes. + +`ListPendingInteractions` applies the same turn/session authority Kandev's own +list surfaces use: only the session's current durable turn counts, terminal +sessions quarantine pending history, and only the newest permission row of that +turn is answerable. `GetInteraction` resolves ANY interaction by pending id, +terminal ones included, so an event-driven cache that started late, restarted, +or dropped an event converges on the current result instead of `NotFound`. + +The three writes route through the first-party services the native UI drives: +`RespondToPermission` through the orchestrator, `AnswerClarification` and +`CancelClarification` through the clarification handler (including its durable +exclusive claim and its detached-resume fallback). A permission response must +name one of the interaction's declared options — Kandev derives the +approve/deny outcome from that option's recorded ACP kind, so a plugin cannot +report an outcome the agent never offered — and the target session comes from +the durable record, never from the request. + +Writes are terminal-once: the first response wins, an already-resolved +interaction answers `FailedPrecondition`, and an unknown id answers `NotFound`. +Those two codes are the distinction a reconciling cache needs between "someone +else answered first" and "my id is stale". `CancelClarification` is delivered as +a decline rather than an in-memory cancellation, so it also settles a bundle +whose original waiter went away in a restart. + **Reads and writes go through the service layer, never a repository.** Each read handler calls the relevant internal service (task service, workflow service, the analytics service for `ListSessionCodeStats`); each write handler calls the diff --git a/docs/public/plugins-authoring.md b/docs/public/plugins-authoring.md index ef4c6ac11b..2469df6455 100644 --- a/docs/public/plugins-authoring.md +++ b/docs/public/plugins-authoring.md @@ -166,8 +166,8 @@ curated React, UI, and app-store surface. internal/... packages or call undocumented REST endpoints. - state gates Host state; secrets gates RevealSecret and plugin-owned secret methods; each api_read resource gates its reader; api_write gates - task/message mutations; agent_invoke gates utility-agent calls; and - capabilities.events controls event delivery. + task/message mutations and interaction responses; agent_invoke gates + utility-agent calls; and capabilities.events controls event delivery. - GetConfig and EmitEvent are ungated. GetConfig returns this plugin's own config, including cleartext secret fields, so do not log or commit it. - Declared webhook keys default to public. Set `webhooks[].access: authenticated` @@ -510,6 +510,8 @@ subscription vocabulary and wildcard rules are in the | Repositories | Repositories().List | api_read: repositories | List by workspace id | | Messages | Messages().List | api_read: messages | Historical user/agent content; Kandev system blocks are stripped | | Message send | Messages().Send | api_write: messages | Sends a prompt to a task session and records plugin: author | +| Interactions | Interactions().ListPending, Interactions().Get | api_read: interactions | Durable record of agent requests still owed a human answer; Get resolves resolved ones too | +| Interaction responses | Interactions().RespondToPermission, .AnswerClarification, .CancelClarification | api_write: interactions | Routed through the services the native UI drives; first terminal response wins | | Utility agent | InvokeUtilityAgent(ctx, prompt) | agent_invoke: true plus config_schema.utility_agent (format: utility-agent) | One-shot completion using the selected utility-agent ID; Kandev resolves that utility's enabled profile, permissions, and launch settings. Missing or stale bindings are FailedPrecondition | The Go signatures, filters, DTOs, and pagination types live in @@ -679,6 +681,10 @@ Declare `api_read: ["executor_profiles"]` before using this reader. A host that does not implement the extension returns `ok == false`; an implemented reader without the capability returns `PermissionDenied` from `List`. +Pending agent interactions are an additive, optional Host extension too; +discover it the same way with `pluginsdk.Interactions(host)`. See "Pending +agent interactions" below for the contract. + **Host state** is a small key/value store kandev keeps for your plugin in its own database. Each entry is addressed by a `(scope, scopeID, key)` triple and holds a JSON object (`map[string]any`): `SetState` upserts one, @@ -717,6 +723,63 @@ conversation content (capability `api_read:messages`). Filter by `SessionIDs`, `content` has kandev's injected `` blocks stripped; a plugin never sees raw system prompts. +### Pending agent interactions + +An agent sometimes stops and waits for a person: a tool-permission request, or +a structured question bundle. If your plugin surfaces attention (an inbox, a +notifier, a dashboard badge), read that from the interaction API rather than +from session state. `WAITING_FOR_INPUT` also describes an ordinarily completed +turn, so a plugin that branches on state alone tells people they owe an answer +they do not. + +```go +interactions, ok := pluginsdk.Interactions(host) +if !ok { + // Host predates the interaction API. + return nil +} +pending, _, err := interactions.ListPending(ctx, pluginsdk.InteractionFilter{ + TaskIDs: []string{taskID}, +}, pluginsdk.Page{Limit: 50}) +``` + +Each `Interaction` carries `ID` (the pending id every response keys on), `Kind` +(`permission` or `clarification`), the task, session and turn ids, a normalized +`Status`, timestamps, and everything needed to render a valid response: +`Options` for a permission, `Questions` (each with its own options) for a +clarification bundle. `AgentDisconnected` marks a still-pending clarification +whose original waiter went away; it remains answerable, and answering it +resumes the session in a new turn, so do not treat it as resolved. + +`Interactions().Get(ctx, id)` resolves any interaction, including resolved +ones. That is how an event-driven cache reconciles: an id from an event you +replayed or a snapshot you took before a restart still resolves to its current +state instead of vanishing. + +Responding requires `api_write: ["interactions"]` and goes through the same +services the native UI drives, so the agent actually unblocks: + +```go +_, err := interactions.RespondToPermission(ctx, pluginsdk.PermissionResponse{ + InteractionID: interaction.ID, + OptionID: interaction.Options[0].OptionID, +}) +``` + +`OptionID` must name one of the interaction's declared options; Kandev derives +the approve/deny outcome from that option's `Kind`, so you cannot report an +outcome the agent never offered. Set `Cancelled: true` (with an empty +`OptionID`) to dismiss the request instead. `AnswerClarification` takes one +answer per question in the bundle; `CancelClarification(ctx, id, reason)` +declines the bundle on the user's behalf and works whether or not the original +waiter is still parked. + +Writes are terminal-once. The first response wins; a later attempt against an +already-resolved interaction returns gRPC `FailedPrecondition`, and an unknown +id returns `NotFound`. Branch on those two to tell "someone else answered +first" apart from "my cached id is stale". Do not retry a +`FailedPrecondition`. + `host.InvokeUtilityAgent(ctx, prompt)` runs a one-shot, non-interactive LLM completion using the utility agent selected for this plugin in **Settings > Plugins > ``** (capability `agent_invoke`), and returns its text. Declare @@ -754,7 +817,7 @@ checked against your manifest's `capabilities` before the handler runs: `RevealSecret` require `capabilities.secrets: true`; `InvokeUtilityAgent` requires `capabilities.agent_invoke: true`; each data-reader accessor requires its resource in `capabilities.api_read` (e.g. `tasks`, `sessions`, `messages`, -`workspaces`, `workflows`, `agent_profiles`, `repositories`). +`interactions`, `workspaces`, `workflows`, `agent_profiles`, `repositories`). Calling one without the declared capability returns gRPC `PermissionDenied` with a message naming the missing capability; declare what you use. @@ -763,9 +826,11 @@ with a message naming the missing capability; declare what you use. `api_write` is live, not advisory. Declare only the exact resource you mutate: `api_write: ["tasks"]` enables `host.Tasks().Create(ctx, CreateTaskInput{...})` and `.Update(ctx, UpdateTaskInput{...})`; `api_write: ["messages"]` enables -`host.Messages().Send(ctx, taskID, sessionID, text)`. A blank `sessionID` -targets the task's primary session. Send queues behind a running session or -resumes/starts it when appropriate, returning `queued`, `sent`, or `started`. +`host.Messages().Send(ctx, taskID, sessionID, text)`; +`api_write: ["interactions"]` enables the interaction response methods above. A blank +`sessionID` targets the task's primary session. Send queues behind a running +session or resumes/starts it when appropriate, returning `queued`, `sent`, or +`started`. Task writes use Kandev's first-party service layer, so normal task events and browser updates occur. Kandev stamps the source as `plugin:` and reserves