feat(plugins): expose durable pending interactions and response actions (#2922)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-24 14:14:38 +00:00
committed by GitHub
parent ea7f257217
commit 635959f56d
30 changed files with 5333 additions and 220 deletions
@@ -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)")
@@ -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 `<expr>.<method>(...)` 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
}
@@ -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
}
}
@@ -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)
}
}
+1 -1
View File
@@ -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 {
+10
View File
@@ -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
@@ -44,6 +44,7 @@ const (
resourceExecutorProfiles = "executor_profiles"
resourceRepositories = "repositories"
resourceMessages = "messages"
resourceInteractions = "interactions"
)
// apiReadCapability formats resource as the api_read:<resource> capability
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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)
}
}
+32
View File
@@ -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,
}
}
@@ -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,
@@ -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"`
}
@@ -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)
@@ -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
}
@@ -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])
}
}
}
@@ -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, "<missing>", 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)
}
}
}
@@ -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
}
@@ -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())
}
}
+3
View File
@@ -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)
+572
View File
@@ -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)
}
@@ -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))
}
File diff suppressed because it is too large Load Diff
@@ -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
@@ -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 {
// <kandev-system> 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 {
// <kandev-system> 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",
@@ -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.
+1
View File
@@ -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 |
+33
View File
@@ -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
+71 -6
View File
@@ -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:<id> 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 `<kandev-system>` 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 > `<plugin>`** (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:<id>` and reserves