feat(plugins): expose durable pending interactions and response actions (#2922)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user