From 60aef37cbd8a043786393a56b603111eb3bd1f63 Mon Sep 17 00:00:00 2001 From: Alassane <79718216+yattdev@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:17:41 -0400 Subject: [PATCH] feat(plugins): port agent_conversation Host contract from the maintained fork (#3672) Co-authored-by: ayattara Co-authored-by: Carlos Florencio --- apps/backend/.golangci.yml | 10 + .../adapters_agent_conversations.go | 187 ++++ .../backendapp/adapters_plugin_messenger.go | 77 +- .../adapters_plugin_messenger_test.go | 99 +- apps/backend/internal/backendapp/main.go | 8 + apps/backend/internal/backendapp/services.go | 11 + apps/backend/internal/backendapp/types.go | 8 +- .../agent_conversations_uninstall_test.go | 220 ++++ apps/backend/internal/plugins/host.go | 16 + .../plugins/host_agent_conversations.go | 95 ++ .../plugins/host_agent_conversations_test.go | 112 ++ .../internal/plugins/manifest/manifest.go | 8 + apps/backend/internal/plugins/service.go | 19 + .../internal/plugins/service_install.go | 25 + .../internal/plugins/service_lifecycle.go | 11 + apps/backend/internal/plugins/state/store.go | 26 + .../internal/plugins/state/store_test.go | 56 + .../internal/task/repository/interface.go | 1 + .../task/repository/repoerrors/errors.go | 4 + .../ephemeral_tasks_all_workspaces_test.go | 70 ++ .../internal/task/repository/sqlite/errors.go | 2 +- .../internal/task/repository/sqlite/task.go | 25 + .../task/service/agent_conversations.go | 979 ++++++++++++++++++ ...gent_conversations_cleanup_hostile_test.go | 146 +++ .../agent_conversations_concurrency_test.go | 66 ++ .../agent_conversations_delete_errors_test.go | 205 ++++ .../agent_conversations_dispatch_test.go | 797 ++++++++++++++ .../agent_conversations_pagination_test.go | 205 ++++ .../agent_conversations_predicates_test.go | 58 ++ .../agent_conversations_real_repo_test.go | 185 ++++ .../service/agent_conversations_state_test.go | 78 ++ .../task/service/agent_conversations_test.go | 917 ++++++++++++++++ .../task/service/quick_chat_expiration.go | 6 + .../task/service/quick_chat_sessions.go | 7 +- .../task/service/quick_chat_sessions_test.go | 8 + apps/backend/pkg/pluginsdk/data_types.go | 89 ++ apps/backend/pkg/pluginsdk/host.go | 165 +++ .../host_agent_conversations_server_test.go | 56 + .../proto/kandev/plugin/v1/plugin.pb.go | 808 ++++++++++++--- .../proto/kandev/plugin/v1/plugin.proto | 78 ++ .../proto/kandev/plugin/v1/plugin_grpc.pb.go | 124 +++ apps/web/lib/ws/handlers/quick-chat.test.ts | 4 + apps/web/lib/ws/handlers/quick-chat.ts | 14 + .../agent-conversation-host-port/plan.md | 59 ++ .../task-01-port-agent-conversation-host.md | 80 ++ docs/public/plugins-authoring.md | 31 +- docs/public/plugins-manifest.md | 2 + docs/public/plugins.md | 9 +- 48 files changed, 6109 insertions(+), 157 deletions(-) create mode 100644 apps/backend/internal/backendapp/adapters_agent_conversations.go create mode 100644 apps/backend/internal/plugins/agent_conversations_uninstall_test.go create mode 100644 apps/backend/internal/plugins/host_agent_conversations.go create mode 100644 apps/backend/internal/plugins/host_agent_conversations_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/ephemeral_tasks_all_workspaces_test.go create mode 100644 apps/backend/internal/task/service/agent_conversations.go create mode 100644 apps/backend/internal/task/service/agent_conversations_cleanup_hostile_test.go create mode 100644 apps/backend/internal/task/service/agent_conversations_concurrency_test.go create mode 100644 apps/backend/internal/task/service/agent_conversations_delete_errors_test.go create mode 100644 apps/backend/internal/task/service/agent_conversations_dispatch_test.go create mode 100644 apps/backend/internal/task/service/agent_conversations_pagination_test.go create mode 100644 apps/backend/internal/task/service/agent_conversations_predicates_test.go create mode 100644 apps/backend/internal/task/service/agent_conversations_real_repo_test.go create mode 100644 apps/backend/internal/task/service/agent_conversations_state_test.go create mode 100644 apps/backend/internal/task/service/agent_conversations_test.go create mode 100644 apps/backend/pkg/pluginsdk/host_agent_conversations_server_test.go create mode 100644 docs/plans/agent-conversation-host-port/plan.md create mode 100644 docs/plans/agent-conversation-host-port/task-01-port-agent-conversation-host.md diff --git a/apps/backend/.golangci.yml b/apps/backend/.golangci.yml index f0687283c7..9f69eff0bd 100644 --- a/apps/backend/.golangci.yml +++ b/apps/backend/.golangci.yml @@ -57,6 +57,16 @@ linters: - dupl - goconst - maintidx + # pluginsdk host + data types — the dual-direction gRPC wire adapter + # (client + server stubs) is inherently verbose and these files are + # the single source of truth for the Host RPC contract, so they are + # exempt from the file-length limit only. Excluding them from every + # linter (a `paths:` entry) would silently drop correctness checks on + # the contract's own implementation. + - path: pkg/pluginsdk/(host|data_types)\.go$ + linters: + - revive + text: file-length-limit paths: # protoc-gen-go / protoc-gen-go-grpc output — regenerated by `make # proto`, never hand-edited, and not worth policing with hand-written diff --git a/apps/backend/internal/backendapp/adapters_agent_conversations.go b/apps/backend/internal/backendapp/adapters_agent_conversations.go new file mode 100644 index 0000000000..fb688fa05f --- /dev/null +++ b/apps/backend/internal/backendapp/adapters_agent_conversations.go @@ -0,0 +1,187 @@ +package backendapp + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + + agentsettingsmodels "github.com/kandev/kandev/internal/agent/settings/models" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/plugins/state" + taskmodels "github.com/kandev/kandev/internal/task/models" + taskservice "github.com/kandev/kandev/internal/task/service" +) + +// agentConversationTaskAdapter wraps the shared repository to satisfy the +// narrow agentConversationTaskRepo interface. The shared repository +// (repos.Task) implements repository.TaskRepository, which has the +// exact method signatures we need. +type agentConversationTaskAdapter struct { + repo interface { + GetWorkspace(ctx context.Context, id string) (*taskmodels.Workspace, error) + ListTasksByWorkspace(ctx context.Context, workspaceID, workflowID, repositoryID, query string, page, pageSize int, sort string, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) ([]*taskmodels.Task, int, error) + ListEphemeralTasksAllWorkspaces(ctx context.Context) ([]*taskmodels.Task, error) + CreateTask(ctx context.Context, task *taskmodels.Task) error + UpdateTask(ctx context.Context, task *taskmodels.Task) error + DeleteTask(ctx context.Context, taskID string) error + } +} + +func (a agentConversationTaskAdapter) GetWorkspace(ctx context.Context, id string) (*taskmodels.Workspace, error) { + return a.repo.GetWorkspace(ctx, id) +} + +func (a agentConversationTaskAdapter) ListTasksByWorkspace(ctx context.Context, workspaceID, workflowID, repositoryID, query string, page, pageSize int, sort string, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) ([]*taskmodels.Task, int, error) { + return a.repo.ListTasksByWorkspace(ctx, workspaceID, workflowID, repositoryID, query, page, pageSize, sort, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig) +} + +func (a agentConversationTaskAdapter) ListEphemeralTasksAllWorkspaces(ctx context.Context) ([]*taskmodels.Task, error) { + return a.repo.ListEphemeralTasksAllWorkspaces(ctx) +} + +func (a agentConversationTaskAdapter) CreateTask(ctx context.Context, task *taskmodels.Task) error { + return a.repo.CreateTask(ctx, task) +} + +func (a agentConversationTaskAdapter) UpdateTask(ctx context.Context, task *taskmodels.Task) error { + return a.repo.UpdateTask(ctx, task) +} + +func (a agentConversationTaskAdapter) DeleteTask(ctx context.Context, taskID string) error { + return a.repo.DeleteTask(ctx, taskID) +} + +// agentConversationSessionAdapter wraps the shared repository to satisfy the +// narrow agentConversationSessionRepo interface. +type agentConversationSessionAdapter struct { + repo interface { + GetPrimarySessionByTaskID(ctx context.Context, taskID string) (*taskmodels.TaskSession, error) + CreateTaskSession(ctx context.Context, session *taskmodels.TaskSession) error + UpdateTaskSession(ctx context.Context, session *taskmodels.TaskSession) error + } +} + +func (a agentConversationSessionAdapter) GetPrimarySessionByTaskID(ctx context.Context, taskID string) (*taskmodels.TaskSession, error) { + return a.repo.GetPrimarySessionByTaskID(ctx, taskID) +} + +func (a agentConversationSessionAdapter) CreateTaskSession(ctx context.Context, session *taskmodels.TaskSession) error { + return a.repo.CreateTaskSession(ctx, session) +} + +func (a agentConversationSessionAdapter) UpdateTaskSession(ctx context.Context, session *taskmodels.TaskSession) error { + return a.repo.UpdateTaskSession(ctx, session) +} + +// agentConversationProfileAdapter wraps the agent settings repository to +// satisfy the narrow agentConversationProfileRepo interface Ensure uses to +// validate a plugin-configured agent profile before creating (or repairing) +// any hidden task/session. GetAgentProfile already filters out soft-deleted +// rows (deleted_at IS NULL), so sql.ErrNoRows covers both "never existed" +// and "deleted". +type agentConversationProfileAdapter struct { + repo interface { + GetAgentProfile(ctx context.Context, id string) (*agentsettingsmodels.AgentProfile, error) + } +} + +func (a agentConversationProfileAdapter) GetProfile(ctx context.Context, profileID string) (taskservice.AgentConversationProfileInfo, bool, error) { + profile, err := a.repo.GetAgentProfile(ctx, profileID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return taskservice.AgentConversationProfileInfo{}, false, nil + } + return taskservice.AgentConversationProfileInfo{}, false, err + } + if profile == nil { + return taskservice.AgentConversationProfileInfo{}, false, nil + } + return taskservice.AgentConversationProfileInfo{Enabled: profile.Enabled}, true, nil +} + +// agentConversationStateStoreAdapter wraps the plugin_state SQLite store +// (internal/plugins/state.Store — already durable and shared across backend +// instances via the DB) to satisfy the narrow agentConversationStateRepo +// interface. Claim is atomic at the SQL level (INSERT ... ON CONFLICT DO +// NOTHING against a UNIQUE index), unlike a process-local map: it survives a +// backend restart and stays correct under concurrent callers, including +// separate backend processes sharing the same database. +type agentConversationStateStoreAdapter struct { + store *state.Store +} + +func (a agentConversationStateStoreAdapter) Claim(ctx context.Context, pluginID, scope, scopeID, key string, value json.RawMessage) (bool, error) { + return a.store.Claim(ctx, pluginID, scope, scopeID, key, value) +} + +func (a agentConversationStateStoreAdapter) Get(ctx context.Context, pluginID, scope, scopeID, key string) (json.RawMessage, bool, error) { + return a.store.Get(ctx, pluginID, scope, scopeID, key) +} + +func (a agentConversationStateStoreAdapter) Set(ctx context.Context, pluginID, scope, scopeID, key string, value json.RawMessage) error { + return a.store.Set(ctx, pluginID, scope, scopeID, key, value) +} + +func (a agentConversationStateStoreAdapter) Delete(ctx context.Context, pluginID, scope, scopeID, key string) error { + return a.store.Delete(ctx, pluginID, scope, scopeID, key) +} + +// agentConversationDispatcherAdapter adapts pluginsTaskMessengerAdapter's +// idempotent delivery primitive to the taskservice.AgentConversationService +// dispatcher seam, so a scheduled wake reaches the same real +// orchestrator-backed delivery path as the plugin Host's SendMessage RPC — +// launching a never-started session or prompting/resuming an idle one — +// instead of only persisting a message row nothing ever executes. +type agentConversationDispatcherAdapter struct { + messenger pluginsTaskMessengerAdapter +} + +func (a agentConversationDispatcherAdapter) Deliver(ctx context.Context, taskID string, session *taskmodels.TaskSession, text, source, idempotencyID string) (string, error) { + return a.messenger.StartOrPromptIdempotent(ctx, taskID, session, text, source, idempotencyID) +} + +// NewAgentConversationService creates the managed conversation service wired +// to the shared repository, agent settings repository, plugin state store, +// and event bus. Called during boot after the task service, repository, and +// event bus are all available. Its runtime dispatcher is wired later, once +// the orchestrator exists — see SetAgentConversationsDispatcher. +func NewAgentConversationService( + taskRepo interface { + GetWorkspace(ctx context.Context, id string) (*taskmodels.Workspace, error) + ListTasksByWorkspace(ctx context.Context, workspaceID, workflowID, repositoryID, query string, page, pageSize int, sort string, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) ([]*taskmodels.Task, int, error) + ListEphemeralTasksAllWorkspaces(ctx context.Context) ([]*taskmodels.Task, error) + CreateTask(ctx context.Context, task *taskmodels.Task) error + UpdateTask(ctx context.Context, task *taskmodels.Task) error + DeleteTask(ctx context.Context, taskID string) error + GetPrimarySessionByTaskID(ctx context.Context, taskID string) (*taskmodels.TaskSession, error) + CreateTaskSession(ctx context.Context, session *taskmodels.TaskSession) error + UpdateTaskSession(ctx context.Context, session *taskmodels.TaskSession) error + }, + profileRepo interface { + GetAgentProfile(ctx context.Context, id string) (*agentsettingsmodels.AgentProfile, error) + }, + stateStore *state.Store, + eventBus bus.EventBus, +) *taskservice.AgentConversationService { + return taskservice.NewAgentConversationService( + agentConversationTaskAdapter{repo: taskRepo}, + agentConversationSessionAdapter{repo: taskRepo}, + agentConversationProfileAdapter{repo: profileRepo}, + agentConversationStateStoreAdapter{store: stateStore}, + eventBus, + ) +} + +// SetAgentConversationsDispatcher wires the live orchestrator-backed +// dispatcher onto an already-constructed AgentConversationService. Deferred +// to main.go's post-orchestrator wiring block (alongside SetWriteDeps) for +// the same boot-ordering reason: the orchestrator is constructed after +// StartActivePlugins spawns boot-active plugins, so it does not exist yet +// when NewAgentConversationService runs during service construction. +func SetAgentConversationsDispatcher(svc *taskservice.AgentConversationService, tasks messengerTaskSvc, orch messengerOrch, log *logger.Logger) { + svc.SetDispatcher(agentConversationDispatcherAdapter{ + messenger: pluginsTaskMessengerAdapter{tasks: tasks, orch: orch, log: log}, + }) +} diff --git a/apps/backend/internal/backendapp/adapters_plugin_messenger.go b/apps/backend/internal/backendapp/adapters_plugin_messenger.go index a2839f8dcd..63fa840b3c 100644 --- a/apps/backend/internal/backendapp/adapters_plugin_messenger.go +++ b/apps/backend/internal/backendapp/adapters_plugin_messenger.go @@ -2,8 +2,10 @@ package backendapp import ( "context" + "database/sql" "errors" "fmt" + "time" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -27,6 +29,8 @@ type messengerTaskSvc interface { GetTaskSession(ctx context.Context, sessionID string) (*taskmodels.TaskSession, error) GetPrimarySession(ctx context.Context, taskID string) (*taskmodels.TaskSession, error) CreateMessage(ctx context.Context, req *taskservice.CreateMessageRequest) (*taskmodels.Message, error) + CreateMessageIdempotent(ctx context.Context, id string, req *taskservice.CreateMessageRequest) (*taskmodels.Message, error) + GetMessageWithPromptIndex(ctx context.Context, id string) (*taskmodels.Message, error) DeleteMessage(ctx context.Context, id string) error WaitForSessionReady(ctx context.Context, sessionID string) error } @@ -117,18 +121,77 @@ func (a pluginsTaskMessengerAdapter) startOrPromptSession(ctx context.Context, t recorded := a.recordUserMessage(ctx, taskID, session.ID, text, metadata) if shouldStartMessagedSession(session) { if _, err := a.orch.StartCreatedSession(ctx, taskID, session.ID, session.AgentProfileID, text, true, false, true, nil, nil); err != nil { - a.deleteRecordedMessage(ctx, recorded) + _ = a.deleteRecordedMessage(ctx, recorded) return plugins.PluginMessageResult{}, fmt.Errorf("failed to start session: %w", err) } return plugins.PluginMessageResult{SessionID: session.ID, Status: "started"}, nil } if err := a.promptWithResume(ctx, taskID, session.ID, text); err != nil { - a.deleteRecordedMessage(ctx, recorded) + _ = a.deleteRecordedMessage(ctx, recorded) return plugins.PluginMessageResult{}, err } return plugins.PluginMessageResult{SessionID: session.ID, Status: "sent"}, nil } +// StartOrPromptIdempotent is the agent-conversation dispatcher's delivery +// primitive (see internal/task/service's agentConversationDispatcher +// interface): it starts a never-launched session or prompts/resumes an idle +// one, exactly like startOrPromptSession, but records the user message with +// a caller-supplied idempotencyID via CreateMessageIdempotent instead of +// always minting a new one. A durable occurrence claim means a successful +// dispatch never reaches this method twice. An existing message row therefore +// identifies a failed marker compensation; it is removed before retrying the +// runtime delivery. The caller (AgentConversationService) has already +// confirmed the session is not RUNNING/STARTING before calling this — session +// is passed in rather than re-resolved. +func (a pluginsTaskMessengerAdapter) StartOrPromptIdempotent(ctx context.Context, taskID string, session *taskmodels.TaskSession, text, source, idempotencyID string) (string, error) { + // A committed row for this id means another caller already recorded this + // occurrence. Normally the durable occurrence claim prevents this path + // after a successful dispatch. If a prior dispatch failed while compensating + // its marker, remove that stale marker before retrying the delivery. + existing, err := a.tasks.GetMessageWithPromptIndex(ctx, idempotencyID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return "", fmt.Errorf("check existing dispatch message: %w", err) + } + if err == nil && existing != nil { + if err := a.deleteRecordedMessage(ctx, existing); err != nil { + return "", fmt.Errorf("reconcile failed dispatch message: %w", err) + } + } + + metadata := map[string]interface{}{"source": source} + recorded, err := a.tasks.CreateMessageIdempotent(ctx, idempotencyID, &taskservice.CreateMessageRequest{ + TaskSessionID: session.ID, + TaskID: taskID, + Content: text, + AuthorType: "user", + Metadata: metadata, + }) + if err != nil { + return "", fmt.Errorf("failed to record idempotent message: %w", err) + } + if shouldStartMessagedSession(session) { + if _, err := a.orch.StartCreatedSession(ctx, taskID, session.ID, session.AgentProfileID, text, true, false, true, nil, nil); err != nil { + if cleanupErr := a.deleteRecordedMessage(ctx, recorded); cleanupErr != nil { + return "", fmt.Errorf("failed to start session: %w; failed to remove dispatch marker: %v", err, cleanupErr) + } + return "", fmt.Errorf("failed to start session: %w", err) + } + return "started", nil + } + if err := a.promptWithResume(ctx, taskID, session.ID, text); err != nil { + var accepted interface{ DetachedResumeAccepted() bool } + if errors.As(err, &accepted) && accepted.DetachedResumeAccepted() { + return "sent", nil + } + if cleanupErr := a.deleteRecordedMessage(ctx, recorded); cleanupErr != nil { + return "", fmt.Errorf("%w; failed to remove dispatch marker: %v", err, cleanupErr) + } + return "", err + } + return "sent", nil +} + // promptWithResume dispatches the prompt, resuming the agent process first when // its execution has gone (e.g. after a backend restart), mirroring the MCP // message_task path's auto-resume. @@ -173,13 +236,17 @@ func (a pluginsTaskMessengerAdapter) recordUserMessage(ctx context.Context, task return message } -func (a pluginsTaskMessengerAdapter) deleteRecordedMessage(ctx context.Context, message *taskmodels.Message) { +func (a pluginsTaskMessengerAdapter) deleteRecordedMessage(ctx context.Context, message *taskmodels.Message) error { if message == nil { - return + return nil } - if err := a.tasks.DeleteMessage(ctx, message.ID); err != nil { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if err := a.tasks.DeleteMessage(cleanupCtx, message.ID); err != nil { a.log.Warn("plugins: failed to delete recorded message after failed SendMessage") + return err } + return nil } // shouldStartMessagedSession reports whether a message targets a session that diff --git a/apps/backend/internal/backendapp/adapters_plugin_messenger_test.go b/apps/backend/internal/backendapp/adapters_plugin_messenger_test.go index 2d4a7e37d3..8054b56178 100644 --- a/apps/backend/internal/backendapp/adapters_plugin_messenger_test.go +++ b/apps/backend/internal/backendapp/adapters_plugin_messenger_test.go @@ -2,6 +2,7 @@ package backendapp import ( "context" + "database/sql" "errors" "testing" @@ -21,12 +22,15 @@ import ( ) type fakeMessengerTaskSvc struct { - primary *taskmodels.TaskSession - primaryErr error - byID map[string]*taskmodels.TaskSession - created *taskmodels.Message - deleted []string - waitErr error + primary *taskmodels.TaskSession + primaryErr error + byID map[string]*taskmodels.TaskSession + created *taskmodels.Message + deleted []string + waitErr error + idempotentMessagePresent bool + deleteRequiresLiveCtx bool + deleteErr error } func (f *fakeMessengerTaskSvc) GetTaskSession(_ context.Context, id string) (*taskmodels.TaskSession, error) { @@ -46,11 +50,38 @@ func (f *fakeMessengerTaskSvc) CreateMessage(_ context.Context, req *taskservice return f.created, nil } -func (f *fakeMessengerTaskSvc) DeleteMessage(_ context.Context, id string) error { +func (f *fakeMessengerTaskSvc) CreateMessageIdempotent(_ context.Context, id string, req *taskservice.CreateMessageRequest) (*taskmodels.Message, error) { + f.created = &taskmodels.Message{ID: id, TaskSessionID: req.TaskSessionID, TaskID: req.TaskID, Content: req.Content} + f.idempotentMessagePresent = true + return f.created, nil +} + +func (f *fakeMessengerTaskSvc) GetMessageWithPromptIndex(_ context.Context, id string) (*taskmodels.Message, error) { + if f.idempotentMessagePresent && f.created != nil && f.created.ID == id { + return f.created, nil + } + return nil, sql.ErrNoRows +} + +func (f *fakeMessengerTaskSvc) DeleteMessage(ctx context.Context, id string) error { + if f.deleteRequiresLiveCtx && ctx.Err() != nil { + return ctx.Err() + } f.deleted = append(f.deleted, id) + if f.deleteErr != nil { + return f.deleteErr + } + if f.created != nil && f.created.ID == id { + f.idempotentMessagePresent = false + } return nil } +type acceptedPromptError struct{ err error } + +func (e acceptedPromptError) Error() string { return e.err.Error() } +func (acceptedPromptError) DetachedResumeAccepted() bool { return true } + func (f *fakeMessengerTaskSvc) WaitForSessionReady(_ context.Context, _ string) error { return f.waitErr } @@ -64,6 +95,7 @@ type fakeMessengerOrch struct { queueStatusCtx context.Context promptErr error promptFailFirstOnly bool + promptHook func() } func (f *fakeMessengerOrch) GetMessageQueue() *messagequeue.Service { return f.queue } @@ -80,6 +112,9 @@ func (f *fakeMessengerOrch) StartCreatedSession(_ context.Context, _, _, _, _ st func (f *fakeMessengerOrch) PromptTask(_ context.Context, _, _, _, _ string, _ bool, _ []v1.MessageAttachment, _ bool) (*orchestrator.PromptResult, error) { f.promptCalls++ + if f.promptHook != nil { + f.promptHook() + } retriedAfterResume := f.promptFailFirstOnly && f.promptCalls > 1 if f.promptErr != nil && !retriedAfterResume { return nil, f.promptErr @@ -200,6 +235,56 @@ func TestPluginsMessenger_PromptFailureDeletesRecordedMessage(t *testing.T) { require.Equal(t, []string{"msg-1"}, tasks.deleted, "a failed dispatch must not leave an orphan message") } +func TestPluginsMessenger_IdempotentFailureDeletesMessageAfterRequestCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + tasks := &fakeMessengerTaskSvc{ + primary: &taskmodels.TaskSession{ID: "s1", TaskID: "t1", State: taskmodels.TaskSessionStateWaitingForInput, AgentExecutionID: "exec-1"}, + deleteRequiresLiveCtx: true, + } + orch := &fakeMessengerOrch{ + promptErr: errors.New("dispatch boom"), + promptHook: cancel, + } + a := newMessengerAdapter(t, tasks, orch) + + _, err := a.StartOrPromptIdempotent(ctx, "t1", tasks.primary, "nope", "plugin:p", "occurrence-message") + require.Error(t, err) + require.False(t, tasks.idempotentMessagePresent, "a failed dispatch must not leave an idempotent message that suppresses the retry") +} + +func TestPluginsMessenger_IdempotentRetryReconcilesMarkerAfterDeleteFailure(t *testing.T) { + tasks := &fakeMessengerTaskSvc{ + primary: &taskmodels.TaskSession{ID: "s1", TaskID: "t1", State: taskmodels.TaskSessionStateWaitingForInput, AgentExecutionID: "exec-1"}, + deleteErr: errors.New("marker store unavailable"), + } + orch := &fakeMessengerOrch{promptErr: errors.New("dispatch boom")} + a := newMessengerAdapter(t, tasks, orch) + + _, err := a.StartOrPromptIdempotent(context.Background(), "t1", tasks.primary, "wake", "plugin:p", "occurrence-message") + require.Error(t, err) + require.True(t, tasks.idempotentMessagePresent, "a failed marker delete leaves a reconciliable pending delivery") + + tasks.deleteErr = nil + orch.promptErr = nil + status, err := a.StartOrPromptIdempotent(context.Background(), "t1", tasks.primary, "wake", "plugin:p", "occurrence-message") + require.NoError(t, err) + require.Equal(t, "sent", status) + require.Equal(t, 2, orch.promptCalls, "retry must deliver instead of treating the orphan marker as success") + require.Len(t, tasks.deleted, 2, "retry removes the stale marker before recording its new dispatch") +} + +func TestPluginsMessenger_IdempotentAcceptedPromptErrorKeepsOccurrenceMarker(t *testing.T) { + tasks := &fakeMessengerTaskSvc{primary: &taskmodels.TaskSession{ID: "s1", TaskID: "t1", State: taskmodels.TaskSessionStateWaitingForInput, AgentExecutionID: "exec-1"}} + orch := &fakeMessengerOrch{promptErr: acceptedPromptError{err: errors.New("publication failed after acceptance")}} + a := newMessengerAdapter(t, tasks, orch) + + status, err := a.StartOrPromptIdempotent(context.Background(), "t1", tasks.primary, "wake", "plugin:p", "occurrence-message") + require.NoError(t, err, "accepted prompt must not be replayed") + require.Equal(t, "sent", status) + require.True(t, tasks.idempotentMessagePresent, "accepted prompt marker must remain durable") + require.Empty(t, tasks.deleted, "accepted prompt marker must not be compensated away") +} + func TestPluginsMessenger_ExplicitSessionMustBelongToTask(t *testing.T) { tasks := &fakeMessengerTaskSvc{byID: map[string]*taskmodels.TaskSession{ "s1": {ID: "s1", TaskID: "other-task", State: taskmodels.TaskSessionStateRunning}, diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 2e7a4bc8f6..a8122a9324 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -1108,6 +1108,14 @@ func startGatewayAndServe( services.Plugins.SetWriteDeps(messenger, pluginsTaskStarterAdapter{orch: orchestratorSvc, log: log}) } + // Wire the managed conversation dispatcher, for the same boot-ordering + // reason as SetWriteDeps just above: AgentConversations was constructed + // during service initialization, but its dispatch path needs the + // orchestrator, which exists only here. + if services.AgentConversations != nil { + SetAgentConversationsDispatcher(services.AgentConversations, services.Task, orchestratorSvc, log) + } + // ============================================ // OFFICE FEATURES + GLOBAL RUN SCHEDULING // ============================================ diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index 23f8f9c211..9b7e5b2eb3 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -303,6 +303,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories } return nil, nil, fmt.Errorf("initialize plugins: %w", recordErr) } + var agentConversationsSvc *taskservice.AgentConversationService if pluginsSvc != nil { // The ldflags-injected build version, so Install can enforce a // package's manifest.min_kandev_version. This is the only production @@ -310,6 +311,15 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories // 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, taskSvc, pluginsTaskWriterAdapter{svc: taskSvc}) + // Wire the managed agent conversation service for the agent_conversation + // Host capability. Wired here (not at boot time in main.go) because the + // task service, shared repository, agent settings repository, and + // plugin state store are all available at this point. Its runtime + // dispatcher is wired later, once the orchestrator exists — see + // SetAgentConversationsDispatcher in main.go. + agentConversationsSvc = NewAgentConversationService(repos.Task, repos.AgentSettings, pluginsSvc.StateStore(), eventBus) + agentConversationsSvc.SetTaskDeleter(taskSvc) + pluginsSvc.SetAgentConversations(agentConversationsSvc) // Separate from SetDataSources: githubSvc is optional (nil when github // is unconfigured), and a nil source leaves tasks with no PullRequests // rather than failing every task read. @@ -437,6 +447,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories Share: shareHTTP, Automation: automationComponents, Plugins: pluginsSvc, + AgentConversations: agentConversationsSvc, PluginsCleanup: pluginsCleanup, Canvas: canvasSvc, CanvasDistribution: canvasDistributionSvc, diff --git a/apps/backend/internal/backendapp/types.go b/apps/backend/internal/backendapp/types.go index 20201af0d2..152d0e619c 100644 --- a/apps/backend/internal/backendapp/types.go +++ b/apps/backend/internal/backendapp/types.go @@ -131,8 +131,12 @@ type Services struct { // Plugins is the extensible plugin system service (registration // registry, event delivery, health monitoring). Always constructed // (non-nil) when initialization succeeds. - Plugins *plugins.Service - PluginsCleanup func() error + Plugins *plugins.Service + // AgentConversations is the managed workspace agent conversation + // service behind the agent_conversation Host capability. Nil when the + // plugins service itself is unavailable. + AgentConversations *taskservice.AgentConversationService + PluginsCleanup func() error // Canvas is the gated lifecycle service for agent-authored plugin web // applications. It is nil while features.canvases is disabled. Canvas *canvasservice.Service diff --git a/apps/backend/internal/plugins/agent_conversations_uninstall_test.go b/apps/backend/internal/plugins/agent_conversations_uninstall_test.go new file mode 100644 index 0000000000..5eeb684bba --- /dev/null +++ b/apps/backend/internal/plugins/agent_conversations_uninstall_test.go @@ -0,0 +1,220 @@ +package plugins + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + + "github.com/kandev/kandev/internal/plugins/store" + "github.com/kandev/kandev/pkg/pluginsdk" +) + +// fakeAgentConversationCleanup is a minimal AgentConversationService double +// tracking, per plugin ID, how many managed conversations it "owns" across +// however many workspaces — exactly the shape DeleteAllForPlugin needs to +// prove without depending on the real internal/task/service implementation +// (that cross-workspace/provenance behavior is covered directly in +// internal/task/service/agent_conversations_dispatch_test.go). Ensure/ +// Dispatch/Delete are unused by these tests and panic if called, so a test +// exercising the wrong method fails loudly instead of silently no-op'ing. +type fakeAgentConversationCleanup struct { + mu sync.Mutex + owned map[string]int // pluginID -> conversation count + deleteErr error + calls int +} + +func newFakeAgentConversationCleanup() *fakeAgentConversationCleanup { + return &fakeAgentConversationCleanup{owned: map[string]int{}} +} + +func (f *fakeAgentConversationCleanup) Ensure(context.Context, string, pluginsdk.AgentConversationSpec) (pluginsdk.AgentConversationDescriptor, string, error) { + panic("fakeAgentConversationCleanup.Ensure: not exercised by uninstall tests") +} + +func (f *fakeAgentConversationCleanup) Dispatch(context.Context, string, string, string, string, string) (pluginsdk.AgentConversationDispatch, error) { + panic("fakeAgentConversationCleanup.Dispatch: not exercised by uninstall tests") +} + +func (f *fakeAgentConversationCleanup) Delete(context.Context, string, string, string) (int32, error) { + panic("fakeAgentConversationCleanup.Delete: not exercised by uninstall tests") +} + +func (f *fakeAgentConversationCleanup) DeleteAllForPlugin(_ context.Context, pluginID string) (int32, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls++ + if f.deleteErr != nil { + return 0, f.deleteErr + } + count := f.owned[pluginID] + delete(f.owned, pluginID) + return int32(count), nil +} + +func (f *fakeAgentConversationCleanup) seed(pluginID string, count int) { + f.mu.Lock() + defer f.mu.Unlock() + f.owned[pluginID] = count +} + +func (f *fakeAgentConversationCleanup) remaining(pluginID string) int { + f.mu.Lock() + defer f.mu.Unlock() + return f.owned[pluginID] +} + +// TestServiceUninstallDeletesAgentConversations pins criterion 15: uninstall +// must remove every managed conversation the plugin owns (across whatever +// workspaces DeleteAllForPlugin found them in), not just plugin_state/ +// user_state/secrets. +func TestServiceUninstallDeletesAgentConversations(t *testing.T) { + svc, _, _ := newTestService(t) + cleanup := newFakeAgentConversationCleanup() + cleanup.seed("kandev-plugin-coordinator", 2) // e.g. one conversation per workspace + svc.SetAgentConversations(cleanup) + installTestPlugin(t, svc, "kandev-plugin-coordinator") + + if err := svc.Uninstall(context.Background(), "kandev-plugin-coordinator"); err != nil { + t.Fatalf("Uninstall() unexpected error: %v", err) + } + + if cleanup.calls != 1 { + t.Fatalf("DeleteAllForPlugin calls = %d, want 1", cleanup.calls) + } + if got := cleanup.remaining("kandev-plugin-coordinator"); got != 0 { + t.Fatalf("remaining owned conversations after uninstall = %d, want 0", got) + } +} + +func TestServiceDisableDeletesAgentConversations(t *testing.T) { + svc, _, _ := newTestService(t) + cleanup := newFakeAgentConversationCleanup() + cleanup.seed("kandev-plugin-coordinator", 2) + svc.SetAgentConversations(cleanup) + installTestPlugin(t, svc, "kandev-plugin-coordinator") + + if err := svc.Disable("kandev-plugin-coordinator"); err != nil { + t.Fatalf("Disable() unexpected error: %v", err) + } + if cleanup.calls != 1 { + t.Fatalf("DeleteAllForPlugin calls = %d, want 1", cleanup.calls) + } + if got := cleanup.remaining("kandev-plugin-coordinator"); got != 0 { + t.Fatalf("remaining owned conversations after disable = %d, want 0", got) + } +} + +func TestServiceDisableFailsVisibleWhenConversationCleanupFails(t *testing.T) { + svc, _, _ := newTestService(t) + cleanup := newFakeAgentConversationCleanup() + cleanup.seed("kandev-plugin-coordinator", 1) + cleanup.deleteErr = errors.New("conversation store unavailable") + svc.SetAgentConversations(cleanup) + installTestPlugin(t, svc, "kandev-plugin-coordinator") + + err := svc.Disable("kandev-plugin-coordinator") + if err == nil || !strings.Contains(err.Error(), cleanup.deleteErr.Error()) { + t.Fatalf("Disable() error = %v, want cleanup failure", err) + } + rec, err := svc.Get("kandev-plugin-coordinator") + if err != nil { + t.Fatalf("Get(): %v", err) + } + if rec.Status != StatusError { + t.Fatalf("status after failed cleanup = %q, want %q", rec.Status, StatusError) + } +} + +// TestServiceUninstallLeavesOtherPluginsConversationsAlone is the +// provenance-safety regression at the plugin-lifecycle layer: uninstalling +// one plugin must call DeleteAllForPlugin scoped to ITS OWN id only, never +// touching another installed plugin's managed conversations. +func TestServiceUninstallLeavesOtherPluginsConversationsAlone(t *testing.T) { + svc, _, _ := newTestService(t) + cleanup := newFakeAgentConversationCleanup() + cleanup.seed("kandev-plugin-coordinator", 1) + cleanup.seed("kandev-plugin-other", 3) + svc.SetAgentConversations(cleanup) + installTestPlugin(t, svc, "kandev-plugin-coordinator") + installTestPlugin(t, svc, "kandev-plugin-other") + + if err := svc.Uninstall(context.Background(), "kandev-plugin-coordinator"); err != nil { + t.Fatalf("Uninstall() unexpected error: %v", err) + } + + if got := cleanup.remaining("kandev-plugin-coordinator"); got != 0 { + t.Fatalf("kandev-plugin-coordinator remaining = %d, want 0", got) + } + if got := cleanup.remaining("kandev-plugin-other"); got != 3 { + t.Fatalf("kandev-plugin-other remaining = %d, want 3 (untouched)", got) + } +} + +// TestServiceUninstallFailsClosedWhenAgentConversationCleanupFails mirrors +// TestServiceUninstallFailsClosedWhenUserStateCleanupFails: a failed +// managed-conversation cleanup must abort uninstall (fail-visible — "failure +// is reported rather than silently orphaning data", criterion 15) rather +// than proceeding to remove the package/record and orphan the hidden +// conversations. The plugin stays installed and stopped so an operator can +// retry. +func TestServiceUninstallFailsClosedWhenAgentConversationCleanupFails(t *testing.T) { + svc, fsStore, rt := newTestService(t) + cleanup := newFakeAgentConversationCleanup() + cleanup.seed("kandev-plugin-coordinator", 1) + svc.SetAgentConversations(cleanup) + rec := installTestPlugin(t, svc, "kandev-plugin-coordinator") + + cleanupErr := errors.New("agent conversation store unavailable") + cleanup.deleteErr = cleanupErr + deliverer := &fakeDeliverer{} + svc.SetDeliverer(deliverer) + + err := svc.Uninstall(context.Background(), rec.ID) + if err == nil || !strings.Contains(err.Error(), cleanupErr.Error()) { + t.Fatalf("Uninstall() error = %v, want agent-conversation cleanup failure", err) + } + if cleanup.calls != 1 { + t.Fatalf("DeleteAllForPlugin calls after failed uninstall = %d, want 1", cleanup.calls) + } + if !rt.stopped(rec.ID) { + t.Fatal("Uninstall() did not stop the runtime before cleanup failure") + } + if _, err := svc.Get(rec.ID); err != nil { + t.Fatalf("Get() after failed uninstall: %v, want installed record", err) + } + if _, err := fsStore.Get(rec.ID); err != nil { + t.Fatalf("store.Get() after failed uninstall: %v, want installed record", err) + } + + // Retry after the underlying failure resolves: uninstall completes and + // the conversations are actually removed. + cleanup.deleteErr = nil + if err := svc.Uninstall(context.Background(), rec.ID); err != nil { + t.Fatalf("retry Uninstall() error: %v", err) + } + if cleanup.calls != 2 { + t.Fatalf("DeleteAllForPlugin calls after retry = %d, want 2", cleanup.calls) + } + if got := cleanup.remaining(rec.ID); got != 0 { + t.Fatalf("remaining owned conversations after successful retry = %d, want 0", got) + } + if _, err := svc.Get(rec.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("Get() after successful retry = %v, want store.ErrNotFound", err) + } +} + +// TestServiceUninstallWithoutAgentConversationsWiredIsNoop proves a bare +// service (agent_conversation never wired — e.g. a Kandev boot where the +// capability is unavailable) still uninstalls cleanly rather than panicking +// or erroring on a nil dependency. +func TestServiceUninstallWithoutAgentConversationsWiredIsNoop(t *testing.T) { + svc, _, _ := newTestService(t) + rec := installTestPlugin(t, svc, "kandev-plugin-notes") + + if err := svc.Uninstall(context.Background(), rec.ID); err != nil { + t.Fatalf("Uninstall() unexpected error with no agent-conversation service wired: %v", err) + } +} diff --git a/apps/backend/internal/plugins/host.go b/apps/backend/internal/plugins/host.go index 272a9c56ce..bc600ca20d 100644 --- a/apps/backend/internal/plugins/host.go +++ b/apps/backend/internal/plugins/host.go @@ -100,6 +100,13 @@ type pluginHost struct { // effect without a plugin restart. nil on a bare test host. // See host_utility.go. utilityDeps func() (utilityDefaultProfileSource, agentProfileSource, utilityRunner) + + // agentConversations provides the managed workspace agent conversation + // operations — EnsureAgentConversation / DispatchAgentConversation / + // DeleteAgentConversation. Wired by backendapp via SetAgentConversations + // and read through agentConversationsDeps (live, not snapshotted at + // hostForPlugin time, for the same late-wiring reason as writeDeps). + agentConversations func() AgentConversationService } var _ pluginsdk.Host = (*pluginHost)(nil) @@ -315,6 +322,15 @@ func (h *pluginHost) EmitEvent(ctx context.Context, name string, payload map[str return h.bus.Publish(ctx, subject, event) } +// AgentConversations returns the agent conversation manager. It always +// returns a usable manager — an undeclared capability or missing wiring is +// denied per call with a typed error (see pluginHostAgentConversationManager. +// resolve), because the gRPC server adapter calls methods on it directly and +// a nil interface would panic the host server. +func (h *pluginHost) AgentConversations() pluginsdk.AgentConversationManager { + return &pluginHostAgentConversationManager{host: h} +} + // unmarshalStateValue decodes a plugin_state row's JSON value into a // Go-native map, matching pluginsdk's Struct<->map[string]any convention. func unmarshalStateValue(raw json.RawMessage) (map[string]any, error) { diff --git a/apps/backend/internal/plugins/host_agent_conversations.go b/apps/backend/internal/plugins/host_agent_conversations.go new file mode 100644 index 0000000000..37b0eb36ca --- /dev/null +++ b/apps/backend/internal/plugins/host_agent_conversations.go @@ -0,0 +1,95 @@ +package plugins + +import ( + "context" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/kandev/kandev/pkg/pluginsdk" +) + +// AgentConversationService is the narrow slice of the task service that +// manages hidden workflowless ephemeral agent conversations. It is satisfied +// by the adapter in internal/backendapp, avoiding an import cycle with +// internal/task/service. +type AgentConversationService interface { + // Ensure creates or repairs one conversation per + // (pluginID, workspaceID, conversationKey). Returns the existing + // descriptor when one already exists. Returns status "configuration_required" + // when the referenced agent profile is missing, disabled, or incompatible. + Ensure(ctx context.Context, pluginID string, spec pluginsdk.AgentConversationSpec) (pluginsdk.AgentConversationDescriptor, string, error) + + // Dispatch sends text to an ensured conversation. occurrenceKey provides + // stable idempotency. Returns status "duplicate_occurrence" when + // occurrence_key matches a previously dispatched occurrence, and + // "skipped_busy" when the session is mid-turn. + Dispatch(ctx context.Context, pluginID, workspaceID, conversationKey, text, occurrenceKey string) (pluginsdk.AgentConversationDispatch, error) + + // Delete removes all conversations owned by pluginID matching workspaceID + // and conversationKey. + Delete(ctx context.Context, pluginID, workspaceID, conversationKey string) (int32, error) + + // DeleteAllForPlugin removes every managed conversation owned by pluginID + // across every workspace and conversation key. Unlike Delete/Ensure/ + // Dispatch, this is not part of pluginsdk.AgentConversationManager — it is + // never callable by the plugin's own gRPC requests, only by the host's + // Uninstall lifecycle (Service.Uninstall), which calls it directly through + // this interface. Returns the number of managed conversations removed. + DeleteAllForPlugin(ctx context.Context, pluginID string) (int32, error) +} + +// pluginHostAgentConversationManager implements pluginsdk.AgentConversationManager, +// wrapping the service layer with the plugin's identity for ownership checks. +// Every method re-checks the agent_conversation capability and the live +// service wiring, so an undeclared capability is denied with a typed +// PermissionDenied rather than crashing the host. +type pluginHostAgentConversationManager struct { + host *pluginHost +} + +// resolve returns the owning plugin id and the live conversation service, or +// a typed error when the plugin did not declare agent_conversation (denied) +// or the service is not wired yet (unavailable). +func (m *pluginHostAgentConversationManager) resolve() (string, AgentConversationService, error) { + h := m.host + if h == nil || !h.capabilities.AgentConversation { + return "", nil, permissionDenied("agent_conversation") + } + if h.agentConversations == nil { + return "", nil, errAgentConversationsUnavailable() + } + svc := h.agentConversations() + if svc == nil { + return "", nil, errAgentConversationsUnavailable() + } + return h.pluginID, svc, nil +} + +func errAgentConversationsUnavailable() error { + return status.Error(codes.Unavailable, "agent conversations are not available on this host") +} + +func (m *pluginHostAgentConversationManager) Ensure(ctx context.Context, spec pluginsdk.AgentConversationSpec) (pluginsdk.AgentConversationDescriptor, string, error) { + pluginID, svc, err := m.resolve() + if err != nil { + return pluginsdk.AgentConversationDescriptor{}, "", err + } + return svc.Ensure(ctx, pluginID, spec) +} + +func (m *pluginHostAgentConversationManager) Dispatch(ctx context.Context, workspaceID, conversationKey, text, occurrenceKey string) (pluginsdk.AgentConversationDispatch, error) { + pluginID, svc, err := m.resolve() + if err != nil { + return pluginsdk.AgentConversationDispatch{}, err + } + return svc.Dispatch(ctx, pluginID, workspaceID, conversationKey, text, occurrenceKey) +} + +func (m *pluginHostAgentConversationManager) Delete(ctx context.Context, workspaceID, conversationKey string) (int32, error) { + pluginID, svc, err := m.resolve() + if err != nil { + return 0, err + } + return svc.Delete(ctx, pluginID, workspaceID, conversationKey) +} diff --git a/apps/backend/internal/plugins/host_agent_conversations_test.go b/apps/backend/internal/plugins/host_agent_conversations_test.go new file mode 100644 index 0000000000..afa161fe76 --- /dev/null +++ b/apps/backend/internal/plugins/host_agent_conversations_test.go @@ -0,0 +1,112 @@ +package plugins + +import ( + "context" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/pkg/pluginsdk" +) + +// stubAgentConversationService records the plugin id every call is stamped +// with, so ownership can be asserted without a real task service. +type stubAgentConversationService struct { + lastPluginID string +} + +func (s *stubAgentConversationService) Ensure(_ context.Context, pluginID string, spec pluginsdk.AgentConversationSpec) (pluginsdk.AgentConversationDescriptor, string, error) { + s.lastPluginID = pluginID + return pluginsdk.AgentConversationDescriptor{TaskID: "task-1", WorkspaceID: spec.WorkspaceID}, "created", nil +} + +func (s *stubAgentConversationService) Dispatch(_ context.Context, pluginID, _, _, _, _ string) (pluginsdk.AgentConversationDispatch, error) { + s.lastPluginID = pluginID + return pluginsdk.AgentConversationDispatch{Status: "sent"}, nil +} + +func (s *stubAgentConversationService) Delete(_ context.Context, pluginID, _, _ string) (int32, error) { + s.lastPluginID = pluginID + return 1, nil +} + +func (s *stubAgentConversationService) DeleteAllForPlugin(_ context.Context, pluginID string) (int32, error) { + s.lastPluginID = pluginID + return 1, nil +} + +func agentConversationHost(capability bool, svc AgentConversationService) *pluginHost { + return &pluginHost{ + pluginID: "p1", + capabilities: manifest.Capabilities{AgentConversation: capability}, + agentConversations: func() AgentConversationService { return svc }, + } +} + +func assertGRPCCode(t *testing.T, err error, want codes.Code) { + t.Helper() + if err == nil { + t.Fatalf("expected error with code %s, got nil", want) + } + if got := status.Code(err); got != want { + t.Fatalf("expected code %s, got %s (%v)", want, got, err) + } +} + +// A plugin that never declared agent_conversation must be denied on every +// method — and must not panic. AgentConversations() previously returned a nil +// interface here, which the gRPC host server dereferenced. +func TestAgentConversationsDeniedWithoutCapability(t *testing.T) { + h := agentConversationHost(false, &stubAgentConversationService{}) + + manager := h.AgentConversations() + if manager == nil { + t.Fatal("AgentConversations() must never return nil; the gRPC server calls methods on it directly") + } + + _, _, err := manager.Ensure(context.Background(), pluginsdk.AgentConversationSpec{WorkspaceID: "ws1", ConversationKey: "coordinator"}) + assertGRPCCode(t, err, codes.PermissionDenied) + + _, err = manager.Dispatch(context.Background(), "ws1", "coordinator", "hello", "occ-1") + assertGRPCCode(t, err, codes.PermissionDenied) + + _, err = manager.Delete(context.Background(), "ws1", "coordinator") + assertGRPCCode(t, err, codes.PermissionDenied) +} + +// The capability alone is not enough: an unwired service must report +// Unavailable rather than crashing. +func TestAgentConversationsUnavailableWhenNotWired(t *testing.T) { + h := &pluginHost{pluginID: "p1", capabilities: manifest.Capabilities{AgentConversation: true}} + + _, _, err := h.AgentConversations().Ensure(context.Background(), pluginsdk.AgentConversationSpec{WorkspaceID: "ws1", ConversationKey: "coordinator"}) + assertGRPCCode(t, err, codes.Unavailable) + + nilSvc := agentConversationHost(true, nil) + _, err = nilSvc.AgentConversations().Dispatch(context.Background(), "ws1", "coordinator", "hello", "occ-1") + assertGRPCCode(t, err, codes.Unavailable) +} + +// A declared capability reaches the service, stamped with the calling +// plugin's own id so one plugin can never act on another's conversation. +func TestAgentConversationsStampCallingPluginID(t *testing.T) { + svc := &stubAgentConversationService{} + h := agentConversationHost(true, svc) + + if _, _, err := h.AgentConversations().Ensure(context.Background(), pluginsdk.AgentConversationSpec{WorkspaceID: "ws1", ConversationKey: "coordinator"}); err != nil { + t.Fatalf("Ensure: %v", err) + } + if svc.lastPluginID != "p1" { + t.Fatalf("expected plugin id %q, got %q", "p1", svc.lastPluginID) + } + + svc.lastPluginID = "" + if _, err := h.AgentConversations().Delete(context.Background(), "ws1", "coordinator"); err != nil { + t.Fatalf("Delete: %v", err) + } + if svc.lastPluginID != "p1" { + t.Fatalf("expected plugin id %q, got %q", "p1", svc.lastPluginID) + } +} diff --git a/apps/backend/internal/plugins/manifest/manifest.go b/apps/backend/internal/plugins/manifest/manifest.go index 1b2a8e6bff..c905fd9fc8 100644 --- a/apps/backend/internal/plugins/manifest/manifest.go +++ b/apps/backend/internal/plugins/manifest/manifest.go @@ -154,6 +154,14 @@ type Capabilities struct { // browser session's user with no Go backend of its own (Approach D1, // docs/decisions/2026-08-01-per-user-plugin-storage.md). UserState bool `yaml:"user_state,omitempty" json:"user_state,omitempty"` + // AgentConversation gates the plugin's access to managed workspace agent + // conversations (EnsureAgentConversation / DispatchAgentConversation / + // DeleteAgentConversation Host RPCs). A plugin holding this capability can + // create, prompt, and delete a hidden workflowless ephemeral task/session + // per (plugin_id, workspace_id, conversation_key). The host owns identity, + // visibility, and lifecycle — the plugin never receives raw database or + // user identity through this surface. + AgentConversation bool `yaml:"agent_conversation,omitempty" json:"agent_conversation,omitempty"` } // AuthProvider is a login option a plugin contributes to the pre-auth login diff --git a/apps/backend/internal/plugins/service.go b/apps/backend/internal/plugins/service.go index 55dbba6828..21238ed0cc 100644 --- a/apps/backend/internal/plugins/service.go +++ b/apps/backend/internal/plugins/service.go @@ -107,6 +107,7 @@ type Service struct { eventHub *webapp.EventHub eventSubscription bus.Subscription userStateCleanup userStateCleanupStore + agentConvs AgentConversationService eventBus bus.EventBus conversationTokens *conversationTokenManager sessionEvents *SessionEventLog @@ -584,6 +585,23 @@ func (s *Service) SetWriteDeps(messenger taskMessenger, starter taskStarter) { s.taskStarter = starter } +// SetAgentConversations wires the managed agent conversation service +// (AgentConversationService). Wired by backendapp, guarded by s.mu against +// concurrent reads. +func (s *Service) SetAgentConversations(svc AgentConversationService) { + s.mu.Lock() + defer s.mu.Unlock() + s.agentConvs = svc +} + +// agentConversationDeps returns the wired agent conversation service, read +// live (not snapshotted at hostForPlugin time). Guarded by s.mu. +func (s *Service) agentConversationDeps() AgentConversationService { + s.mu.Lock() + defer s.mu.Unlock() + return s.agentConvs +} + // writeDependencies returns the currently-wired task messenger and task // starter. Read live (not snapshotted at hostForPlugin time) so a plugin // spawned before SetWriteDeps still resolves them once it is called. Guarded by @@ -861,6 +879,7 @@ func (s *Service) hostForPlugin(pluginID string) pluginsdk.Host { utilityDeps: s.utilityAgentDeps, writeDeps: s.writeDependencies, interactionDeps: s.interactionResponderDep, + agentConversations: s.agentConversationDeps, } } diff --git a/apps/backend/internal/plugins/service_install.go b/apps/backend/internal/plugins/service_install.go index 40e1b84e01..09536e09f2 100644 --- a/apps/backend/internal/plugins/service_install.go +++ b/apps/backend/internal/plugins/service_install.go @@ -402,6 +402,10 @@ func (s *Service) Uninstall(ctx context.Context, id string) error { s.reconcileAbortedUninstall(id, wasRunning) return fmt.Errorf("plugins: uninstall aborted, could not purge plugin user state: %w", err) } + if err := s.deletePluginAgentConversations(ctx, id); err != nil { + s.reconcileAbortedUninstall(id, wasRunning) + return fmt.Errorf("plugins: uninstall aborted, could not purge plugin agent conversations: %w", err) + } if err := pkgtar.Remove(s.pluginsDir, id); err != nil { return fmt.Errorf("plugins: remove installed package: %w", err) } @@ -476,3 +480,24 @@ func (s *Service) deletePluginUserState(ctx context.Context, id string) error { } return s.userStateCleanup.DeleteAllForPlugin(ctx, id) } + +// deletePluginAgentConversations removes every managed agent conversation +// (hidden ephemeral task + session) id owns, across every workspace and +// conversation key. Provenance-safe: the underlying DeleteAllForPlugin only +// matches ephemeral tasks stamped with id's own plugin_id metadata, so +// ordinary user tasks and other plugins' managed conversations are never +// reachable from here. A nil agentConvs (agent_conversation was never wired, +// or a narrowly constructed test host) is a no-op — a plugin that never used +// the capability has nothing to clean up. Unlike deletePluginState +// (plugin_state, best-effort), this is fail-visible: an error aborts the +// uninstall rather than silently orphaning hidden conversations, matching +// deletePluginSecrets/deletePluginUserState's ordering (nothing destructive +// to the package/record has happened yet, so a retry is safe). +func (s *Service) deletePluginAgentConversations(ctx context.Context, id string) error { + svc := s.agentConversationDeps() + if svc == nil { + return nil + } + _, err := svc.DeleteAllForPlugin(ctx, id) + return err +} diff --git a/apps/backend/internal/plugins/service_lifecycle.go b/apps/backend/internal/plugins/service_lifecycle.go index e73a8d66bc..56620f970d 100644 --- a/apps/backend/internal/plugins/service_lifecycle.go +++ b/apps/backend/internal/plugins/service_lifecycle.go @@ -61,6 +61,17 @@ func (s *Service) Disable(id string) error { if s.runtime != nil { s.runtime.Stop(id) } + if err := s.deletePluginAgentConversations(context.Background(), id); err != nil { + // The plugin is already stopped, so leaving its record active would + // advertise a runtime that cannot serve requests. Keep the failed cleanup + // visible and let a later Disable retry remove the remaining conversations. + if setErr := s.SetStatus(id, StatusError); setErr != nil { + s.log.Warn("plugins: could not mark plugin errored after disable cleanup failure", + zap.String("plugin_id", id), zap.Error(setErr)) + } + s.notifyDeliverer() + return fmt.Errorf("plugins: disable aborted, could not purge plugin agent conversations: %w", err) + } if err := s.SetStatus(id, StatusDisabled); err != nil { return err } diff --git a/apps/backend/internal/plugins/state/store.go b/apps/backend/internal/plugins/state/store.go index 83bba06e66..edd38d6fbf 100644 --- a/apps/backend/internal/plugins/state/store.go +++ b/apps/backend/internal/plugins/state/store.go @@ -96,6 +96,32 @@ func (s *Store) Set(ctx context.Context, pluginID, scope, scopeID, key string, v return err } +// Claim inserts the row only if none exists yet, using +// INSERT ... ON CONFLICT DO NOTHING against the table's UNIQUE index. +// Unlike Get-then-Set, this closes the race between two callers — +// concurrent goroutines, separate processes, or a caller retrying after a +// backend restart — who both observe "not claimed" and both attempt to +// claim: SQLite's unique-constraint check serializes the two INSERTs, so +// only one affects a row. Returns claimed=true when this call performed +// the insert (it won the claim), false when a row already existed (a prior +// caller won; the existing value is left untouched). +func (s *Store) Claim(ctx context.Context, pluginID, scope, scopeID, key string, value json.RawMessage) (bool, error) { + now := time.Now().UTC().Format(time.RFC3339) + res, err := s.db.ExecContext(ctx, s.db.Rebind(` + INSERT INTO plugin_state (id, plugin_id, scope, scope_id, state_key, value_json, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(plugin_id, scope, scope_id, state_key) DO NOTHING + `), uuid.New().String(), pluginID, scope, scopeID, key, string(value), now) + if err != nil { + return false, err + } + affected, err := res.RowsAffected() + if err != nil { + return false, err + } + return affected > 0, nil +} + // Delete removes the row for the given plugin/scope/scopeID/key. It is not // an error if no matching row exists. func (s *Store) Delete(ctx context.Context, pluginID, scope, scopeID, key string) error { diff --git a/apps/backend/internal/plugins/state/store_test.go b/apps/backend/internal/plugins/state/store_test.go index d99bd715db..22b2038669 100644 --- a/apps/backend/internal/plugins/state/store_test.go +++ b/apps/backend/internal/plugins/state/store_test.go @@ -3,6 +3,8 @@ package state import ( "context" "encoding/json" + "path/filepath" + "sync" "testing" "time" @@ -80,6 +82,60 @@ func TestStoreSetUpsertsOnRepeatedWrite(t *testing.T) { } } +// TestStoreClaimIsAtomicAcrossConnections is QA contract coverage for the +// occurrence-key primitive. Two backend connections sharing one database must +// not both win the same claim. +func TestStoreClaimIsAtomicAcrossConnections(t *testing.T) { + dsn := filepath.Join(t.TempDir(), "plugin-state.db") + "?_busy_timeout=5000&_journal_mode=WAL" + stores := make([]*Store, 2) + for i := range stores { + conn, err := sqlx.Open("sqlite3", dsn) + if err != nil { + t.Fatalf("open connection %d: %v", i, err) + } + t.Cleanup(func() { _ = conn.Close() }) + stores[i], err = NewStore(db.NewPool(conn, conn)) + if err != nil { + t.Fatalf("new store %d: %v", i, err) + } + } + + start := make(chan struct{}) + claimed := make([]bool, len(stores)) + errs := make([]error, len(stores)) + var wg sync.WaitGroup + for i, store := range stores { + wg.Add(1) + go func(index int, candidate *Store) { + defer wg.Done() + <-start + claimed[index], errs[index] = candidate.Claim( + context.Background(), + "kandev-plugin-coordinator", + "agent_conversation_occurrence", + "workspace/conversation", + "wake:cycle", + json.RawMessage(`{"claimed":true}`), + ) + }(i, store) + } + close(start) + wg.Wait() + + winners := 0 + for i := range stores { + if errs[i] != nil { + t.Fatalf("claim %d: %v", i, errs[i]) + } + if claimed[i] { + winners++ + } + } + if winners != 1 { + t.Fatalf("winning claims = %d, want 1 (%v)", winners, claimed) + } +} + // TestStoreInstanceScopeUpsertsWithEmptyScopeID pins scope_id NULL handling // for instance-scoped state (scope_id == ""). SQLite's UNIQUE index treats // each NULL as distinct, so a naive INSERT ... ON CONFLICT with a literal diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index b96d9620d7..cdb44ad6f8 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -15,6 +15,7 @@ import ( var ErrWorkspaceNameMismatch = repoerrors.ErrWorkspaceNameMismatch var ErrWorkspaceNotFound = repoerrors.ErrWorkspaceNotFound var ErrTaskNotFound = repoerrors.ErrTaskNotFound +var ErrNoPrimarySession = repoerrors.ErrNoPrimarySession var ErrTaskParentMismatch = repoerrors.ErrTaskParentMismatch var ErrTaskPlanNotFound = repoerrors.ErrTaskPlanNotFound var ErrTaskPlanCommentsChanged = repoerrors.ErrTaskPlanCommentsChanged diff --git a/apps/backend/internal/task/repository/repoerrors/errors.go b/apps/backend/internal/task/repository/repoerrors/errors.go index 7f20352d07..4dbcc2f299 100644 --- a/apps/backend/internal/task/repository/repoerrors/errors.go +++ b/apps/backend/internal/task/repository/repoerrors/errors.go @@ -12,6 +12,10 @@ var ErrWorkspaceNotFound = errors.New("workspace not found") // ErrTaskNotFound reports that no task row matched the supplied id. var ErrTaskNotFound = errors.New("task not found") +// ErrNoPrimarySession reports that a task exists but has no primary session. +// Callers can repair that state without hiding other repository failures. +var ErrNoPrimarySession = errors.New("no primary session") + // ErrInitialTaskBriefStale reports that a prepared task's description changed // before its first direct message could be admitted. var ErrInitialTaskBriefStale = errors.New("initial task brief is stale") diff --git a/apps/backend/internal/task/repository/sqlite/ephemeral_tasks_all_workspaces_test.go b/apps/backend/internal/task/repository/sqlite/ephemeral_tasks_all_workspaces_test.go new file mode 100644 index 0000000000..fde0190c76 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/ephemeral_tasks_all_workspaces_test.go @@ -0,0 +1,70 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + v1 "github.com/kandev/kandev/pkg/api/v1" + + "github.com/kandev/kandev/internal/task/models" +) + +// TestListEphemeralTasksAllWorkspacesSpansWorkspaces pins the whole reason +// this method exists over ListTasksByWorkspace: a plugin's managed +// conversations can live in multiple workspaces, and uninstall must find all +// of them in one query rather than iterating every workspace by hand. +func TestListEphemeralTasksAllWorkspacesSpansWorkspaces(t *testing.T) { + repo := newRepoForEntityTests(t) + ctx := context.Background() + seedWorkspace(t, repo, "ws-a") + seedWorkspace(t, repo, "ws-b") + + ephemeralA := ephemeralTaskFixture("task-ephemeral-a", "ws-a") + ephemeralB := ephemeralTaskFixture("task-ephemeral-b", "ws-b") + ordinaryA := ephemeralTaskFixture("task-ordinary-a", "ws-a") + ordinaryA.IsEphemeral = false + + for _, task := range []*models.Task{ephemeralA, ephemeralB, ordinaryA} { + if err := repo.CreateTask(ctx, task); err != nil { + t.Fatalf("create task %s: %v", task.ID, err) + } + } + + got, err := repo.ListEphemeralTasksAllWorkspaces(ctx) + if err != nil { + t.Fatalf("ListEphemeralTasksAllWorkspaces: %v", err) + } + + ids := make(map[string]bool) + for _, task := range got { + ids[task.ID] = true + } + if !ids["task-ephemeral-a"] { + t.Error("expected task-ephemeral-a (ws-a) in results") + } + if !ids["task-ephemeral-b"] { + t.Error("expected task-ephemeral-b (ws-b), a different workspace, in results") + } + if ids["task-ordinary-a"] { + t.Error("expected the non-ephemeral task to be excluded") + } +} + +func ephemeralTaskFixture(id, workspaceID string) *models.Task { + now := time.Now().UTC() + return &models.Task{ + ID: id, + WorkspaceID: workspaceID, + Title: "Managed Conversation - " + id, + State: v1.TaskStateCreated, + Priority: "medium", + IsEphemeral: true, + Origin: models.TaskOriginManual, + Metadata: map[string]interface{}{ + "kandev.plugin_id": "plugin-coordinator", + }, + CreatedAt: now, + UpdatedAt: now, + } +} diff --git a/apps/backend/internal/task/repository/sqlite/errors.go b/apps/backend/internal/task/repository/sqlite/errors.go index 9c23fe75b8..143cec81df 100644 --- a/apps/backend/internal/task/repository/sqlite/errors.go +++ b/apps/backend/internal/task/repository/sqlite/errors.go @@ -38,7 +38,7 @@ var errDetachedWorkspaceTransferNotApplicable = errors.New("detached workspace s // ErrNoPrimarySession is returned by GetPrimarySessionByTaskID when the task // has no primary session row. Callers should use errors.Is to distinguish this // "not found" case from genuine backend/DB errors. -var ErrNoPrimarySession = errors.New("no primary session") +var ErrNoPrimarySession = repoerrors.ErrNoPrimarySession // ErrExternalIDConflict is returned by CreateTask (and its // admission/capacity variants) when the insert violates diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index aec2f46eb8..523026f781 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -3351,6 +3351,31 @@ func (r *Repository) ListTasksByWorkspace(ctx context.Context, workspaceID, work return r.ListTasksByWorkspaceWithArchiveMode(ctx, workspaceID, workflowID, repositoryID, query, page, pageSize, sort, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig, false) } +// ListEphemeralTasksAllWorkspaces returns every ephemeral task across every +// workspace, unpaginated. Used by plugin uninstall (Service.DeleteAllForPlugin) +// to find every managed conversation a plugin owns regardless of which +// workspace it lives in — ListTasksByWorkspace cannot answer that because it +// always scopes to one workspace_id. Filtering on the boolean is_ephemeral +// column (rather than a JSON metadata predicate) keeps this portable across +// SQLite and Postgres; callers narrow further (e.g. by plugin ownership +// metadata) in application code. +func (r *Repository) ListEphemeralTasksAllWorkspaces(ctx context.Context) ([]*models.Task, error) { + ctx, span := tracing.Tracer("kandev-db").Start(ctx, "db.ListEphemeralTasksAllWorkspaces") + defer span.End() + + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + SELECT `+taskSelectColumns("t")+` + FROM tasks t + WHERE t.is_ephemeral = 1`+andNotAutomationOriginT+` + `)) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + return r.scanTasks(rows) +} + // ListTasksByWorkspaceWithArchiveMode is the additive workspace-list contract // used by the sidebar archive view. onlyArchived takes precedence over // includeArchived when both are true. diff --git a/apps/backend/internal/task/service/agent_conversations.go b/apps/backend/internal/task/service/agent_conversations.go new file mode 100644 index 0000000000..d806426604 --- /dev/null +++ b/apps/backend/internal/task/service/agent_conversations.go @@ -0,0 +1,979 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + "github.com/google/uuid" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" + taskrepo "github.com/kandev/kandev/internal/task/repository" + "github.com/kandev/kandev/pkg/api/v1" + "github.com/kandev/kandev/pkg/pluginsdk" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// Managed conversation metadata keys, stamped server-side on the backing +// task's Metadata map. +const ( + metaKeyPluginID = "kandev.plugin_id" + metaKeyWorkspaceID = "kandev.workspace_id" + metaKeyConversationKey = "kandev.conversation_key" + metaKeyEphemeral = "kandev.ephemeral" + metaKeyManagedByPlugin = "kandev.managed_by_plugin" + metaKeyInstructionVer = "kandev.instruction_version" +) + +// defaultAgentConversationTitle is the title for managed conversation +// backing tasks. Visible only in diagnostic/admin views, never on the +// task board. +const defaultAgentConversationTitle = "Managed Conversation" + +// agentConversationOccurrenceScope namespaces occurrence-key claims within +// the shared state store, separate from any plugin_state key a plugin +// writes itself through the ordinary State() RPCs. +const agentConversationOccurrenceScope = "agent_conversation_occurrence" + +const ( + agentConversationOccurrencePending = "pending" + agentConversationOccurrenceAccepted = "accepted" +) + +// Ensure's status results. +const ( + // AgentConversationStatusCreated is returned when Ensure created a brand + // new managed conversation. + AgentConversationStatusCreated = "created" + // AgentConversationStatusExists is returned when Ensure found (and, if + // needed, repaired) an already-managed conversation. + AgentConversationStatusExists = "exists" + // AgentConversationStatusConfigurationRequired is returned from Ensure + // when the requested agent profile is missing, disabled, deleted, or + // otherwise cannot back a new conversation. No task or session row is + // created. + AgentConversationStatusConfigurationRequired = "configuration_required" +) + +// agentConversationTaskRepo is the narrow task-repository interface for +// managed conversation operations. +type agentConversationTaskRepo interface { + GetWorkspace(ctx context.Context, id string) (*models.Workspace, error) + ListTasksByWorkspace(ctx context.Context, workspaceID, workflowID, repositoryID, query string, page, pageSize int, sort string, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) ([]*models.Task, int, error) + // ListEphemeralTasksAllWorkspaces returns every ephemeral task across + // every workspace, unpaginated. Used only by DeleteAllForPlugin (plugin + // uninstall) — ListTasksByWorkspace cannot answer a cross-workspace query + // because it always scopes to one workspace_id. + ListEphemeralTasksAllWorkspaces(ctx context.Context) ([]*models.Task, error) + CreateTask(ctx context.Context, task *models.Task) error + UpdateTask(ctx context.Context, task *models.Task) error + DeleteTask(ctx context.Context, taskID string) error +} + +// agentConversationSessionRepo is the narrow session-repository interface +// for creating and querying primary sessions on managed conversation tasks. +type agentConversationSessionRepo interface { + GetPrimarySessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) + CreateTaskSession(ctx context.Context, session *models.TaskSession) error + UpdateTaskSession(ctx context.Context, session *models.TaskSession) error +} + +// AgentConversationProfileInfo is the minimal agent-profile information +// Ensure needs to gate hidden-conversation creation on a usable profile. +type AgentConversationProfileInfo struct { + Enabled bool +} + +// agentConversationProfileRepo is the narrow read-only interface Ensure uses +// to validate a plugin-configured agent profile before creating (or +// repairing) any hidden task/session. Implemented by a backendapp adapter +// over the agent settings repository, avoiding an +// internal/task/service → internal/agent/settings import. ok is false when +// profileID does not resolve to any usable profile (never existed, or +// soft-deleted — the settings repository's GetAgentProfile already filters +// deleted_at IS NULL, so "not found" covers both "missing" and "deleted"). +type agentConversationProfileRepo interface { + GetProfile(ctx context.Context, profileID string) (AgentConversationProfileInfo, bool, error) +} + +// agentConversationStateRepo is the narrow state-storage interface for +// durable, atomic occurrence-key deduplication. Claim must be backed by a +// real uniqueness constraint (not a check-then-write pair) so it stays +// correct across concurrent callers, a backend restart, and — since the +// backing store is a shared SQLite/Postgres table, not a process-local +// map — multiple backend instances. +type agentConversationStateRepo interface { + Get(ctx context.Context, pluginID, scope, scopeID, key string) (json.RawMessage, bool, error) + Set(ctx context.Context, pluginID, scope, scopeID, key string, value json.RawMessage) error + Claim(ctx context.Context, pluginID, scope, scopeID, key string, value json.RawMessage) (claimed bool, err error) + Delete(ctx context.Context, pluginID, scope, scopeID, key string) error +} + +// agentConversationTaskDeleter performs lifecycle-aware task deletion. The +// task repository's bare DeleteTask only removes rows; managed conversation +// cleanup must use the full task-service path when it is available so agent +// processes, worktrees, and cleanup jobs are handled consistently. +type agentConversationTaskDeleter interface { + DeleteTask(ctx context.Context, id string) error +} + +// agentConversationEventBus is the narrow event-bus interface for +// publishing task.created events. Matches bus.EventBus.Publish. +type agentConversationEventBus interface { + Publish(ctx context.Context, subject string, event *bus.Event) error +} + +// agentConversationDispatcher performs the real orchestrator-backed message +// delivery a scheduled wake needs: start a never-launched (CREATED) session, +// or prompt an idle one (resuming its agent process first if it has gone, +// e.g. after a backend restart) — the same delivery path the plugin Host's +// SendMessage RPC uses. Implemented by a backendapp adapter wrapping the +// task service and orchestrator, avoiding an +// internal/task/service → internal/orchestrator import. +// +// Dispatch only calls Deliver once it has confirmed the session is not +// RUNNING/STARTING (that check, and the "skipped_busy" result, stay in this +// package); Deliver itself is responsible for idempotent message recording +// keyed by idempotencyID, so a retried occurrence cannot create a second +// turn or double-dispatch to the agent. +type agentConversationDispatcher interface { + Deliver(ctx context.Context, taskID string, session *models.TaskSession, text, source, idempotencyID string) (status string, err error) +} + +// AgentConversationService implements the managed conversation lifecycle: +// Ensure (create-or-repair), Dispatch, and Delete. It uses narrow +// repository interfaces to avoid depending on the full task/Service. +// +// Ensure creates or repairs exactly one hidden, workflowless, ephemeral +// backing task and primary session per (pluginID, workspaceID, conversationKey). +// Dispatch sends prompt text to an ensured session with stable +// occurrence-key idempotency and busy-session coalescing. +// Delete removes all conversations for the given (pluginID, workspaceID, key). +type AgentConversationService struct { + tasks agentConversationTaskRepo + sess agentConversationSessionRepo + profile agentConversationProfileRepo + state agentConversationStateRepo + eventer agentConversationEventBus + deleter agentConversationTaskDeleter + + // dispatcher delivers Dispatch's text to the real agent runtime. It is + // wired late (SetDispatcher), after the orchestrator exists — mirroring + // pluginHost's writeDeps/utilityDeps "read live, not snapshotted" + // pattern (internal/plugins/host.go) for the same boot-ordering reason: + // the orchestrator is constructed after this service. Guarded by mu so + // concurrent Dispatch calls observe a consistent value. + mu sync.RWMutex + dispatcher agentConversationDispatcher + + dispatchLocksMu sync.Mutex + dispatchLocks map[string]*sync.Mutex + + // ensureLocksMu/ensureLocks serialize same-process Ensure calls while the + // deterministic task id provides the cross-process uniqueness boundary. + // Entries are never evicted — the key space is bounded by the number of + // distinct managed conversations, which is small and long-lived for the + // lifetime of the process. + ensureLocksMu sync.Mutex + ensureLocks map[string]*sync.Mutex +} + +// NewAgentConversationService creates a new service with the given dependencies. +// Call SetDispatcher once the orchestrator-backed dispatcher is available; +// until then, Dispatch returns Unavailable. +func NewAgentConversationService( + tasks agentConversationTaskRepo, + sess agentConversationSessionRepo, + profile agentConversationProfileRepo, + state agentConversationStateRepo, + eventer agentConversationEventBus, +) *AgentConversationService { + return &AgentConversationService{ + tasks: tasks, + sess: sess, + profile: profile, + state: state, + eventer: eventer, + } +} + +// SetDispatcher wires the live orchestrator-backed dispatcher. See the +// dispatcher field's doc comment for why this is deferred past construction. +func (s *AgentConversationService) SetDispatcher(d agentConversationDispatcher) { + s.mu.Lock() + defer s.mu.Unlock() + s.dispatcher = d +} + +// SetTaskDeleter wires the lifecycle-aware delete path used by the main task +// service. When unset, tests and bare setups fall back to the repository's +// DeleteTask. +func (s *AgentConversationService) SetTaskDeleter(d agentConversationTaskDeleter) { + s.mu.Lock() + defer s.mu.Unlock() + s.deleter = d +} + +func (s *AgentConversationService) getDispatcher() agentConversationDispatcher { + s.mu.RLock() + defer s.mu.RUnlock() + return s.dispatcher +} + +func (s *AgentConversationService) getTaskDeleter() agentConversationTaskDeleter { + s.mu.RLock() + defer s.mu.RUnlock() + return s.deleter +} + +// lockEnsureKey serializes Ensure calls for one (pluginID, workspaceID, +// conversationKey) tuple. See the ensureLocks field doc comment. +func (s *AgentConversationService) lockEnsureKey(key string) func() { + s.ensureLocksMu.Lock() + if s.ensureLocks == nil { + s.ensureLocks = make(map[string]*sync.Mutex) + } + l, ok := s.ensureLocks[key] + if !ok { + l = &sync.Mutex{} + s.ensureLocks[key] = l + } + s.ensureLocksMu.Unlock() + l.Lock() + return l.Unlock +} + +// lockDispatchSession keeps the observation that a conversation is idle and +// its delivery in one ownership window. The runtime changes session state +// asynchronously, so observing an idle row alone cannot prevent two distinct +// occurrences from both starting a turn. +func (s *AgentConversationService) lockDispatchSession(sessionID string) func() { + s.dispatchLocksMu.Lock() + if s.dispatchLocks == nil { + s.dispatchLocks = make(map[string]*sync.Mutex) + } + l, ok := s.dispatchLocks[sessionID] + if !ok { + l = &sync.Mutex{} + s.dispatchLocks[sessionID] = l + } + s.dispatchLocksMu.Unlock() + l.Lock() + return l.Unlock +} + +// Ensure creates or repairs exactly one managed conversation per +// (pluginID, workspaceID, conversationKey). The status string returned is +// one of "created", "exists", or AgentConversationStatusConfigurationRequired +// (a missing, disabled, or deleted agent profile — no task or session row is +// created or repaired in that case). +func (s *AgentConversationService) Ensure(ctx context.Context, pluginID string, spec pluginsdk.AgentConversationSpec) (pluginsdk.AgentConversationDescriptor, string, error) { + if pluginID == "" || spec.WorkspaceID == "" || spec.ConversationKey == "" { + return pluginsdk.AgentConversationDescriptor{}, "", status.Error(codes.InvalidArgument, "plugin_id, workspace_id, and conversation_key are required") + } + + unlock := s.lockEnsureKey(pluginID + "/" + spec.WorkspaceID + "/" + spec.ConversationKey) + defer unlock() + + // 1. Resolve and validate the effective agent profile BEFORE creating + // any row. Plugins may deliberately leave AgentProfileID empty to use the + // workspace default, but the managed session must still be born with a + // concrete, usable profile. A missing, disabled, or deleted effective + // profile returns configuration_required without partial task/session rows. + effectiveProfileID, ok, err := s.resolveEffectiveProfile(ctx, spec.WorkspaceID, spec.AgentProfileID) + if err != nil { + return pluginsdk.AgentConversationDescriptor{}, "", err + } + if !ok { + return pluginsdk.AgentConversationDescriptor{}, AgentConversationStatusConfigurationRequired, nil + } + spec.AgentProfileID = effectiveProfileID + + // 2. Check for an existing conversation. + existing, err := s.findManagedConversation(ctx, pluginID, spec.WorkspaceID, spec.ConversationKey) + if err != nil { + return pluginsdk.AgentConversationDescriptor{}, "", err + } + if existing != nil { + return s.repairIfNeeded(ctx, existing, spec) + } + + // 3. Create the backing task. + metadata := map[string]interface{}{ + metaKeyPluginID: pluginID, + metaKeyWorkspaceID: spec.WorkspaceID, + metaKeyConversationKey: spec.ConversationKey, + metaKeyEphemeral: true, + metaKeyManagedByPlugin: pluginID, + } + if spec.AgentProfileID != "" { + metadata[models.MetaKeyAgentProfileID] = spec.AgentProfileID + } + if spec.BasePrompt != "" { + metadata["kandev.base_prompt"] = spec.BasePrompt + } + + task := &models.Task{ + ID: conversationTaskID(pluginID, spec.WorkspaceID, spec.ConversationKey), + WorkspaceID: spec.WorkspaceID, + Title: defaultAgentConversationTitle + " - " + spec.ConversationKey, + State: v1.TaskStateCreated, + Priority: "medium", + IsEphemeral: true, + Origin: models.TaskOriginManual, + Metadata: metadata, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + + if err := s.tasks.CreateTask(ctx, task); err != nil { + // The deterministic backing-task id is the cross-process uniqueness + // boundary. A competing backend can win the insert after this call's + // metadata scan but before its insert; reload and repair that row rather + // than surfacing a duplicate-key error or creating a second conversation. + existing, findErr := s.findManagedConversation(ctx, pluginID, spec.WorkspaceID, spec.ConversationKey) + if findErr == nil && existing != nil { + return s.repairIfNeeded(ctx, existing, spec) + } + return pluginsdk.AgentConversationDescriptor{}, "", fmt.Errorf("failed to insert conversation task: %w", err) + } + + // 4. Create the primary session. + primary, err := s.createPrimarySession(ctx, task.ID, spec.AgentProfileID) + if err != nil { + return pluginsdk.AgentConversationDescriptor{}, "", fmt.Errorf("failed to create conversation session: %w", err) + } + + // 5. Publish task.created (best-effort, non-blocking). + s.publishTaskCreated(ctx, task) + + return pluginsdk.AgentConversationDescriptor{ + TaskID: task.ID, + SessionID: primary.ID, + WorkspaceID: spec.WorkspaceID, + ConversationKey: spec.ConversationKey, + AgentProfileID: spec.AgentProfileID, + }, AgentConversationStatusCreated, nil +} + +// resolveEffectiveProfile converts an optional plugin profile into a concrete +// usable profile. An empty plugin value intentionally means the workspace +// default, not an unconfigured session: the real turn path requires an agent +// profile before it can start the primary session. +func (s *AgentConversationService) resolveEffectiveProfile(ctx context.Context, workspaceID, profileID string) (string, bool, error) { + if profileID == "" { + workspace, err := s.tasks.GetWorkspace(ctx, workspaceID) + if err != nil { + return "", false, fmt.Errorf("failed to resolve workspace default agent profile: %w", err) + } + if workspace == nil || workspace.DefaultAgentProfileID == nil { + return "", false, nil + } + profileID = *workspace.DefaultAgentProfileID + } + if profileID == "" { + return "", false, nil + } + ok, err := s.validateProfile(ctx, profileID) + if err != nil || !ok { + return "", ok, err + } + return profileID, true, nil +} + +// validateProfile reports whether a concrete profile can back a new or +// repaired conversation. It must resolve to an existing, enabled row. +func (s *AgentConversationService) validateProfile(ctx context.Context, profileID string) (bool, error) { + if profileID == "" { + return false, nil + } + if s.profile == nil { + // No validator wired (e.g. a bare test service): fail open only when + // the caller genuinely has no way to validate. Production wiring + // (backendapp) always sets this. + return true, nil + } + info, ok, err := s.profile.GetProfile(ctx, profileID) + if err != nil { + return false, fmt.Errorf("failed to resolve agent profile: %w", err) + } + if !ok || !info.Enabled { + return false, nil + } + return true, nil +} + +// repairIfNeeded repairs a managed conversation whose primary session is +// missing (e.g. after a partial creation failure) and returns "exists". If +// the task's bound agent profile is no longer usable, repair is refused with +// AgentConversationStatusConfigurationRequired rather than creating a new +// session against an invalid profile. +func (s *AgentConversationService) repairIfNeeded(ctx context.Context, existing *models.Task, spec pluginsdk.AgentConversationSpec) (pluginsdk.AgentConversationDescriptor, string, error) { + // Ensure resolved and validated this concrete profile before locating the + // existing conversation. Treat it as authoritative on every call so a + // changed plugin setting is reconciled instead of silently continuing with + // an old (or subsequently disabled) binding. + profileID := spec.AgentProfileID + primary, err := s.ensureConversationPrimarySession(ctx, existing.ID, profileID) + if err != nil { + return pluginsdk.AgentConversationDescriptor{}, "", err + } + if err := s.reconcileConversationTask(ctx, existing, profileID, spec.BasePrompt); err != nil { + return pluginsdk.AgentConversationDescriptor{}, "", err + } + + return pluginsdk.AgentConversationDescriptor{ + TaskID: existing.ID, + SessionID: primary.ID, + WorkspaceID: existing.WorkspaceID, + ConversationKey: spec.ConversationKey, + AgentProfileID: profileID, + }, AgentConversationStatusExists, nil +} + +func (s *AgentConversationService) ensureConversationPrimarySession(ctx context.Context, taskID, profileID string) (*models.TaskSession, error) { + primary, err := s.sess.GetPrimarySessionByTaskID(ctx, taskID) + if errors.Is(err, taskrepo.ErrNoPrimarySession) { + // The task row can survive a partial creation failure. Treat the typed + // repository result as a repairable absence, while preserving all other + // repository errors as failures. + primary = nil + } else if err != nil { + return nil, fmt.Errorf("failed to check existing conversation session: %w", err) + } + if primary == nil { + primary, err = s.createPrimarySession(ctx, taskID, profileID) + if err != nil { + return nil, fmt.Errorf("failed to repair conversation session: %w", err) + } + return primary, nil + } + if primary.AgentProfileID == profileID { + return primary, nil + } + if primary.State == models.TaskSessionStateRunning || primary.State == models.TaskSessionStateStarting || primary.AgentExecutionID != "" { + return nil, status.Error(codes.FailedPrecondition, "conversation session has a live agent execution; delete and recreate it before changing agent profile") + } + primary.AgentProfileID = profileID + primary.UpdatedAt = time.Now().UTC() + if err := s.sess.UpdateTaskSession(ctx, primary); err != nil { + return nil, fmt.Errorf("failed to reconcile conversation session profile: %w", err) + } + return primary, nil +} + +// reconcileConversationTask refreshes the mutable, server-owned config +// stamped on an existing backing task. BasePrompt is deliberately replaced +// even when it becomes empty so a settings save can remove a previously +// configured instruction. +func (s *AgentConversationService) reconcileConversationTask(ctx context.Context, task *models.Task, profileID, basePrompt string) error { + if task.Metadata == nil { + task.Metadata = make(map[string]interface{}) + } + changed := false + if current, _ := task.Metadata[models.MetaKeyAgentProfileID].(string); current != profileID { + task.Metadata[models.MetaKeyAgentProfileID] = profileID + changed = true + } + if current, _ := task.Metadata["kandev.base_prompt"].(string); current != basePrompt { + if basePrompt == "" { + delete(task.Metadata, "kandev.base_prompt") + } else { + task.Metadata["kandev.base_prompt"] = basePrompt + } + changed = true + } + if !changed { + return nil + } + task.UpdatedAt = time.Now().UTC() + if err := s.tasks.UpdateTask(ctx, task); err != nil { + return fmt.Errorf("failed to reconcile conversation task configuration: %w", err) + } + return nil +} + +// createPrimarySession creates a primary session row for the given task. +func (s *AgentConversationService) createPrimarySession(ctx context.Context, taskID, agentProfileID string) (*models.TaskSession, error) { + primary := &models.TaskSession{ + ID: conversationPrimarySessionID(taskID), + TaskID: taskID, + AgentProfileID: agentProfileID, + State: models.TaskSessionStateCreated, + IsPrimary: true, + StartedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + if err := s.sess.CreateTaskSession(ctx, primary); err != nil { + existing, findErr := s.sess.GetPrimarySessionByTaskID(ctx, taskID) + if findErr == nil && existing != nil { + return existing, nil + } + return nil, err + } + return primary, nil +} + +func conversationTaskID(pluginID, workspaceID, conversationKey string) string { + return conversationIdentity("task", pluginID, workspaceID, conversationKey) +} + +func conversationPrimarySessionID(taskID string) string { + return conversationIdentity("primary-session", taskID) +} + +func conversationIdentity(parts ...string) string { + encoded, _ := json.Marshal(parts) + return uuid.NewSHA1(uuid.NameSpaceOID, encoded).String() +} + +// publishTaskCreated publishes a task.created event for the managed +// conversation, best-effort and non-blocking. +func (s *AgentConversationService) publishTaskCreated(ctx context.Context, task *models.Task) { + if s.eventer == nil { + return + } + eventCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + event := bus.NewEvent(events.TaskCreated, "agent-conversation-service", map[string]interface{}{ + "task_id": task.ID, + "workspace_id": task.WorkspaceID, + "title": task.Title, + "is_ephemeral": true, + }) + _ = s.eventer.Publish(eventCtx, events.TaskCreated, event) +} + +// Dispatch delivers text to an ensured conversation through the real agent +// runtime. occurrenceKey, when non-empty, provides durable, atomic +// idempotency: a retried occurrence (same plugin/workspace/key/occurrence, +// whether re-sent by a concurrent caller or after a backend restart) is +// claimed exactly once and returns "duplicate_occurrence" for every other +// caller. When the session is mid-turn (RUNNING or STARTING), Dispatch +// returns "skipped_busy" without touching the runtime, so a busy session +// never accumulates queued heartbeats. +func (s *AgentConversationService) Dispatch(ctx context.Context, pluginID, workspaceID, conversationKey, text, occurrenceKey string) (pluginsdk.AgentConversationDispatch, error) { + if pluginID == "" || workspaceID == "" || conversationKey == "" { + return pluginsdk.AgentConversationDispatch{}, status.Error(codes.InvalidArgument, "plugin_id, workspace_id, and conversation_key are required") + } + + unlock := s.lockDispatchSession(pluginID + "/" + workspaceID + "/" + conversationKey) + defer unlock() + + existing, primary, busy, err := s.dispatchTarget(ctx, pluginID, workspaceID, conversationKey) + if err != nil { + return pluginsdk.AgentConversationDispatch{}, err + } + if busy != nil { + return *busy, nil + } + + // 3. Resolve the runtime before consuming a durable occurrence key. A + // restart may leave this dependency temporarily unavailable; that must not + // turn a later retry into duplicate_occurrence. + dispatcher := s.getDispatcher() + if dispatcher == nil { + return pluginsdk.AgentConversationDispatch{}, status.Error(codes.Unavailable, "agent conversation dispatch is not ready yet") + } + + // 4. Atomically claim only after every precondition which cannot deliver + // has passed. The claim is released if Deliver fails, so a transient + // runtime error can safely retry the same scheduled occurrence. + idempotencyID, claimedOccurrence, duplicate, err := s.claimDispatchOccurrence(ctx, pluginID, workspaceID, conversationKey, occurrenceKey) + if err != nil { + return pluginsdk.AgentConversationDispatch{}, err + } + if duplicate { + return agentConversationDispatch(existing, primary, workspaceID, conversationKey, "duplicate_occurrence"), nil + } + + // 5. Deliver through the real agent runtime (start a never-launched + // session, or prompt/resume an idle one). + deliverStatus, err := s.deliverConversationPrompt(ctx, dispatcher, existing, primary, text, pluginID, workspaceID, conversationKey, idempotencyID, claimedOccurrence, occurrenceKey) + if err != nil { + return pluginsdk.AgentConversationDispatch{}, err + } + if claimedOccurrence { + if err := s.acceptOccurrenceKey(ctx, pluginID, workspaceID, conversationKey, occurrenceKey); err != nil { + return pluginsdk.AgentConversationDispatch{}, fmt.Errorf("conversation prompt delivered but failed to record accepted occurrence: %w", err) + } + } + + return agentConversationDispatch(existing, primary, workspaceID, conversationKey, deliverStatus), nil +} + +func agentConversationDispatch(task *models.Task, session *models.TaskSession, workspaceID, conversationKey, dispatchStatus string) pluginsdk.AgentConversationDispatch { + return pluginsdk.AgentConversationDispatch{ + SessionID: session.ID, + Status: dispatchStatus, + Descriptor: pluginsdk.AgentConversationDescriptor{ + TaskID: task.ID, + SessionID: session.ID, + WorkspaceID: workspaceID, + ConversationKey: conversationKey, + }, + } +} + +func (s *AgentConversationService) dispatchTarget(ctx context.Context, pluginID, workspaceID, conversationKey string) (*models.Task, *models.TaskSession, *pluginsdk.AgentConversationDispatch, error) { + existing, err := s.findManagedConversation(ctx, pluginID, workspaceID, conversationKey) + if err != nil { + return nil, nil, nil, err + } + if existing == nil { + return nil, nil, nil, status.Error(codes.NotFound, "conversation not found, call Ensure first") + } + primary, err := s.sess.GetPrimarySessionByTaskID(ctx, existing.ID) + if err != nil { + return nil, nil, nil, err + } + if primary == nil { + return nil, nil, nil, status.Error(codes.NotFound, "conversation has no session, call Ensure first") + } + if primary.State != models.TaskSessionStateRunning && primary.State != models.TaskSessionStateStarting { + return existing, primary, nil, nil + } + return nil, nil, &pluginsdk.AgentConversationDispatch{ + SessionID: primary.ID, + Status: "skipped_busy", + Descriptor: pluginsdk.AgentConversationDescriptor{ + TaskID: existing.ID, + SessionID: primary.ID, + WorkspaceID: workspaceID, + ConversationKey: conversationKey, + }, + }, nil +} + +func (s *AgentConversationService) claimDispatchOccurrence(ctx context.Context, pluginID, workspaceID, conversationKey, occurrenceKey string) (string, bool, bool, error) { + if occurrenceKey == "" { + return uuid.New().String(), false, false, nil + } + alreadyClaimed, err := s.claimOccurrenceKey(ctx, pluginID, workspaceID, conversationKey, occurrenceKey) + if err != nil { + return "", false, false, fmt.Errorf("failed to claim occurrence key: %w", err) + } + idempotencyID := deriveOccurrenceMessageID(pluginID, workspaceID, conversationKey, occurrenceKey) + if !alreadyClaimed { + return idempotencyID, true, false, nil + } + + scopeID := workspaceID + "/" + conversationKey + raw, found, err := s.state.Get(ctx, pluginID, agentConversationOccurrenceScope, scopeID, occurrenceKey) + if err != nil { + return "", false, false, fmt.Errorf("failed to inspect occurrence key: %w", err) + } + if !found { + // A failed delivery may have released the row between Claim and Get. + // Re-run the atomic insert once so that a retry can own the occurrence + // without treating a transient absence as a duplicate. + claimed, claimErr := s.claimOccurrenceKey(ctx, pluginID, workspaceID, conversationKey, occurrenceKey) + if claimErr != nil { + return "", false, false, fmt.Errorf("failed to reclaim occurrence key: %w", claimErr) + } + if claimed { + return idempotencyID, true, false, nil + } + return "", false, true, nil + } + if occurrenceStatus(raw) == agentConversationOccurrencePending { + // The previous owner persisted intent but did not persist acceptance. + // Re-enter delivery with the same deterministic message identity. No + // expiry is used, so an accepted occurrence cannot be reclaimed blindly. + return idempotencyID, true, false, nil + } + return "", false, true, nil +} + +func (s *AgentConversationService) deliverConversationPrompt(ctx context.Context, dispatcher agentConversationDispatcher, task *models.Task, primary *models.TaskSession, text, pluginID, workspaceID, conversationKey, idempotencyID string, claimedOccurrence bool, occurrenceKey string) (string, error) { + deliverStatus, err := dispatcher.Deliver(ctx, task.ID, primary, composeConversationPrompt(task, text), "plugin:"+pluginID, idempotencyID) + if err == nil { + return deliverStatus, nil + } + if !claimedOccurrence { + return "", fmt.Errorf("failed to deliver message: %w", err) + } + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if releaseErr := s.releaseOccurrenceKey(cleanupCtx, pluginID, workspaceID, conversationKey, occurrenceKey); releaseErr != nil { + return "", fmt.Errorf("failed to deliver message: %w; failed to release occurrence key: %v", err, releaseErr) + } + return "", fmt.Errorf("failed to deliver message: %w", err) +} + +func composeConversationPrompt(task *models.Task, text string) string { + if task == nil || task.Metadata == nil { + return text + } + basePrompt, _ := task.Metadata["kandev.base_prompt"].(string) + if basePrompt == "" { + return text + } + if text == "" { + return basePrompt + } + return basePrompt + "\n\n" + text +} + +// deriveOccurrenceMessageID derives a stable, collision-free message id from +// the full occurrence coordinates, so the same occurrence always maps to the +// same message-table primary key (across plugins/workspaces/keys, whose +// occurrenceKey strings may otherwise collide) and repeated calls for the +// same occurrence are naturally idempotent at the storage layer too. +func deriveOccurrenceMessageID(pluginID, workspaceID, conversationKey, occurrenceKey string) string { + return conversationIdentity("occurrence-message", pluginID, workspaceID, conversationKey, occurrenceKey) +} + +// Delete removes all managed conversations owned by pluginID matching +// workspaceID and conversationKey. Returns the count of deleted tasks. +func (s *AgentConversationService) Delete(ctx context.Context, pluginID, workspaceID, conversationKey string) (int32, error) { + if pluginID == "" || workspaceID == "" || conversationKey == "" { + return 0, status.Error(codes.InvalidArgument, "plugin_id, workspace_id, and conversation_key are required") + } + tasks, err := s.listManagedConversations(ctx, pluginID, workspaceID, conversationKey) + if err != nil { + return 0, err + } + var count int32 + deleter := s.getTaskDeleter() + for _, t := range tasks { + if err := s.deleteManagedConversationTask(ctx, deleter, t.ID); err != nil { + // Already gone is the goal state here for the same reason it is in + // DeleteAllForPlugin: the listing above is not held under a lock, so + // an uninstall cleanup or a retried delete can remove the row in + // between. A caller that asked for a conversation to be gone and + // finds it gone has not failed, and a plugin already treats "no + // conversation matched" as success. + if errors.Is(err, taskrepo.ErrTaskNotFound) { + continue + } + return count, fmt.Errorf("failed to delete managed conversation task %s: %w", t.ID, err) + } + count++ + } + return count, nil +} + +// DeleteAllForPlugin removes every managed conversation owned by pluginID, +// across every workspace and conversation key. Called by the plugin system's +// Uninstall lifecycle (never by the plugin's own gRPC requests — see the +// AgentConversationService interface doc in internal/plugins), so a plugin's +// hidden conversations are never orphaned after uninstall. Provenance-safe: +// isManagedConversationOwnedByPlugin only matches ephemeral tasks whose +// stamped kandev.plugin_id metadata equals pluginID, so ordinary user tasks +// and other plugins' managed conversations are never touched. Returns the +// number of conversations removed and the first deletion error encountered +// (deletion keeps going after an error so one bad row cannot block cleanup +// of the rest; the caller reports the error rather than treating a partial +// failure as success — see criterion 15's "failure is reported rather than +// silently orphaning data"). +func (s *AgentConversationService) DeleteAllForPlugin(ctx context.Context, pluginID string) (int32, error) { + if pluginID == "" { + return 0, status.Error(codes.InvalidArgument, "plugin_id is required") + } + tasks, err := s.tasks.ListEphemeralTasksAllWorkspaces(ctx) + if err != nil { + return 0, fmt.Errorf("failed to list ephemeral tasks for plugin uninstall cleanup: %w", err) + } + var count int32 + var firstErr error + deleter := s.getTaskDeleter() + for _, task := range tasks { + if !isManagedConversationOwnedByPlugin(task, pluginID) { + continue + } + if err := s.deleteManagedConversationTask(ctx, deleter, task.ID); err != nil { + // The conversation being gone already is the goal state, not a + // failure: the listing above is not held under a lock, so a + // concurrent cleanup (or a retried uninstall racing itself) can + // remove a row between the list and this delete. Reporting that + // as an error would abort an uninstall that actually succeeded, + // since Service.Uninstall is fail-visible on this path. + if errors.Is(err, taskrepo.ErrTaskNotFound) { + continue + } + if firstErr == nil { + firstErr = fmt.Errorf("failed to delete managed conversation task %s: %w", task.ID, err) + } + continue + } + count++ + } + return count, firstErr +} + +func (s *AgentConversationService) deleteManagedConversationTask(ctx context.Context, deleter agentConversationTaskDeleter, taskID string) error { + if deleter != nil { + return deleter.DeleteTask(ctx, taskID) + } + return s.tasks.DeleteTask(ctx, taskID) +} + +// managedConversationPageSize is the page size used when scanning a +// workspace's ephemeral tasks for managed conversations. The repository has +// no metadata predicate for the provenance keys, so the scan pages through +// the whole ephemeral set rather than sampling the first page: quick chats +// and other ephemeral tasks share this list, and a workspace that +// accumulates more than one page of them would otherwise hide an existing +// conversation, making Ensure create a duplicate and Dispatch report +// NotFound. +const managedConversationPageSize = 100 + +// findManagedConversation returns the first managed conversation matching +// the given (pluginID, workspaceID, conversationKey), or nil when none +// exists. +func (s *AgentConversationService) findManagedConversation(ctx context.Context, pluginID, workspaceID, conversationKey string) (*models.Task, error) { + var found *models.Task + err := s.eachManagedConversation(ctx, pluginID, workspaceID, conversationKey, func(task *models.Task) bool { + found = task + return false + }) + if err != nil { + return nil, err + } + return found, nil +} + +// listManagedConversations returns all managed conversations matching the +// given (pluginID, workspaceID, conversationKey). +func (s *AgentConversationService) listManagedConversations(ctx context.Context, pluginID, workspaceID, conversationKey string) ([]*models.Task, error) { + var out []*models.Task + err := s.eachManagedConversation(ctx, pluginID, workspaceID, conversationKey, func(task *models.Task) bool { + out = append(out, task) + return true + }) + if err != nil { + return nil, err + } + return out, nil +} + +// eachManagedConversation pages through the workspace's ephemeral tasks and +// invokes visit for every managed conversation matching the given +// coordinates. visit returns false to stop early. +func (s *AgentConversationService) eachManagedConversation(ctx context.Context, pluginID, workspaceID, conversationKey string, visit func(*models.Task) bool) error { + for page := 1; ; page++ { + tasks, total, err := s.tasks.ListTasksByWorkspace(ctx, workspaceID, "", "", "", page, managedConversationPageSize, "", false, true, true, false) + if err != nil { + return err + } + for _, task := range tasks { + if isManagedConversation(task, pluginID, workspaceID, conversationKey) { + if !visit(task) { + return nil + } + } + } + scanned := page * managedConversationPageSize + if len(tasks) < managedConversationPageSize || scanned >= total { + return nil + } + } +} + +// isManagedConversation checks whether task's metadata identifies it as a +// managed conversation owned by pluginID for the given workspace and key. +func isManagedConversation(task *models.Task, pluginID, workspaceID, conversationKey string) bool { + if task == nil || task.Metadata == nil { + return false + } + pID, _ := task.Metadata[metaKeyPluginID].(string) + wID, _ := task.Metadata[metaKeyWorkspaceID].(string) + cKey, _ := task.Metadata[metaKeyConversationKey].(string) + ephemeral, _ := task.Metadata[metaKeyEphemeral].(bool) + return pID == pluginID && wID == workspaceID && cKey == conversationKey && ephemeral +} + +// isManagedConversationOwnedByPlugin is isManagedConversation without the +// workspace/conversationKey narrowing — used by DeleteAllForPlugin, which +// must find every managed conversation a plugin owns regardless of which +// workspace or conversation key it was created under. +func isManagedConversationOwnedByPlugin(task *models.Task, pluginID string) bool { + if task == nil || task.Metadata == nil { + return false + } + pID, _ := task.Metadata[metaKeyPluginID].(string) + ephemeral, _ := task.Metadata[metaKeyEphemeral].(bool) + return pID == pluginID && ephemeral +} + +// claimOccurrenceKey atomically claims an occurrence key for +// (pluginID, workspaceID, conversationKey), durably (survives a backend +// restart) and atomically (safe under concurrent callers, including +// multiple backend instances sharing the same store). Returns true when the +// key was already claimed by a prior call (duplicate), false when this call +// claimed it. +func (s *AgentConversationService) claimOccurrenceKey(ctx context.Context, pluginID, workspaceID, conversationKey, occurrenceKey string) (bool, error) { + scopeID := workspaceID + "/" + conversationKey + value, _ := json.Marshal(map[string]interface{}{ + "status": agentConversationOccurrencePending, + "claimed": true, + "plugin_id": pluginID, + "workspace_id": workspaceID, + "conversation_key": conversationKey, + }) + claimed, err := s.state.Claim(ctx, pluginID, agentConversationOccurrenceScope, scopeID, occurrenceKey, value) + if err != nil { + return false, err + } + return !claimed, nil +} + +func (s *AgentConversationService) releaseOccurrenceKey(ctx context.Context, pluginID, workspaceID, conversationKey, occurrenceKey string) error { + scopeID := workspaceID + "/" + conversationKey + return s.state.Delete(ctx, pluginID, agentConversationOccurrenceScope, scopeID, occurrenceKey) +} + +func (s *AgentConversationService) acceptOccurrenceKey(ctx context.Context, pluginID, workspaceID, conversationKey, occurrenceKey string) error { + if occurrenceKey == "" { + return nil + } + scopeID := workspaceID + "/" + conversationKey + value, err := json.Marshal(map[string]interface{}{ + "status": agentConversationOccurrenceAccepted, + "plugin_id": pluginID, + "workspace_id": workspaceID, + "conversation_key": conversationKey, + }) + if err != nil { + return err + } + return s.state.Set(ctx, pluginID, agentConversationOccurrenceScope, scopeID, occurrenceKey, value) +} + +func occurrenceStatus(raw json.RawMessage) string { + var value struct { + Status string `json:"status"` + } + if err := json.Unmarshal(raw, &value); err != nil { + // Rows written by older releases have no status. Treat malformed or + // legacy rows as accepted so a migration never replays an occurrence. + return agentConversationOccurrenceAccepted + } + if value.Status == "" { + return agentConversationOccurrenceAccepted + } + return value.Status +} + +// IsManagedConversationTask is a public predicate that reports whether a +// task is a managed conversation backing task. Used by Quick Chat +// restoration and expiration to exclude these conversations. +func IsManagedConversationTask(task *models.Task) bool { + if task == nil || task.Metadata == nil { + return false + } + _, hasPlugin := task.Metadata[metaKeyPluginID].(string) + isEphemeral := task.IsEphemeral + if !isEphemeral { + if e, ok := task.Metadata[metaKeyEphemeral].(bool); ok { + isEphemeral = e + } + } + return isEphemeral && hasPlugin +} diff --git a/apps/backend/internal/task/service/agent_conversations_cleanup_hostile_test.go b/apps/backend/internal/task/service/agent_conversations_cleanup_hostile_test.go new file mode 100644 index 0000000000..2258ff8594 --- /dev/null +++ b/apps/backend/internal/task/service/agent_conversations_cleanup_hostile_test.go @@ -0,0 +1,146 @@ +package service + +import ( + "context" + "sync" + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +// Uninstall can race a plugin still doing work, and two uninstall attempts can +// overlap on retry. Neither may double-count, panic, or delete anything the +// plugin does not own. +func TestDeleteAllForPluginConcurrentCallsOverRealRepository(t *testing.T) { + svc, repo := newAgentConversationServiceOverRealRepo(t) + ctx := context.Background() + seedConversationWorkspace(t, repo, "ws-one") + for _, key := range []string{"a", "b", "c", "d", "e"} { + ensureConversation(t, svc, "plugin-coordinator", "ws-one", key) + } + survivor := ensureConversation(t, svc, "plugin-other", "ws-one", "keep") + + var wg sync.WaitGroup + counts := make([]int32, 4) + errs := make([]error, 4) + for i := 0; i < 4; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + counts[i], errs[i] = svc.DeleteAllForPlugin(ctx, "plugin-coordinator") + }(i) + } + wg.Wait() + + var total int32 + for i, c := range counts { + // A conversation another caller already removed is the goal state, not + // a failure. Reporting it as one would abort an uninstall that + // succeeded, because Service.Uninstall aborts on any error here. + if errs[i] != nil { + t.Errorf("concurrent cleanup call %d reported an error for work another caller completed: %v", i, errs[i]) + } + total += c + } + if total != 5 { + t.Errorf("concurrent cleanup reported %d deletions across all callers, want exactly 5 (no double counting, none lost)", total) + } + remaining, err := repo.ListEphemeralTasksAllWorkspaces(ctx) + if err != nil { + t.Fatalf("list after cleanup: %v", err) + } + for _, task := range remaining { + if isManagedConversationOwnedByPlugin(task, "plugin-coordinator") { + t.Errorf("conversation %s survived concurrent cleanup", task.ID) + } + } + if !taskExists(t, repo, survivor.TaskID) { + t.Error("another plugin's conversation was deleted by concurrent cleanup") + } +} + +// Metadata is free-form JSON. A task carrying hostile or wrong-typed +// provenance must never be matched as an uninstalling plugin's conversation. +func TestDeleteAllForPluginIgnoresMalformedProvenance(t *testing.T) { + svc, repo := newAgentConversationServiceOverRealRepo(t) + ctx := context.Background() + seedConversationWorkspace(t, repo, "ws-one") + + decoys := []*models.Task{ + {ID: "decoy-ephemeral-string", WorkspaceID: "ws-one", Title: "d1", Priority: "medium", + IsEphemeral: true, Origin: models.TaskOriginManual, + Metadata: map[string]interface{}{"kandev.plugin_id": "plugin-coordinator", "kandev.ephemeral": "true"}}, + {ID: "decoy-plugin-id-number", WorkspaceID: "ws-one", Title: "d2", Priority: "medium", + IsEphemeral: true, Origin: models.TaskOriginManual, + Metadata: map[string]interface{}{"kandev.plugin_id": 42, "kandev.ephemeral": true}}, + {ID: "decoy-no-provenance", WorkspaceID: "ws-one", Title: "d3", Priority: "medium", + IsEphemeral: true, Origin: models.TaskOriginManual, + Metadata: map[string]interface{}{"kandev.ephemeral": true}}, + {ID: "decoy-other-plugin", WorkspaceID: "ws-one", Title: "d4", Priority: "medium", + IsEphemeral: true, Origin: models.TaskOriginManual, + Metadata: map[string]interface{}{"kandev.plugin_id": "plugin-coordinator-2", "kandev.ephemeral": true}}, + } + for _, task := range decoys { + if err := repo.CreateTask(ctx, task); err != nil { + t.Fatalf("create %s: %v", task.ID, err) + } + } + real := ensureConversation(t, svc, "plugin-coordinator", "ws-one", "coordinator") + + count, err := svc.DeleteAllForPlugin(ctx, "plugin-coordinator") + if err != nil { + t.Fatalf("DeleteAllForPlugin: %v", err) + } + if count != 1 { + t.Errorf("DeleteAllForPlugin removed %d tasks, want exactly 1 (only the genuine conversation)", count) + } + if taskExists(t, repo, real.TaskID) { + t.Error("the genuine conversation was not deleted") + } + for _, task := range decoys { + if !taskExists(t, repo, task.ID) { + t.Errorf("%s was deleted despite not being a conversation owned by the uninstalled plugin", task.ID) + } + } +} + +// A plugin's own Delete races the same way uninstall cleanup does: the listing +// is not held under a lock, so an overlapping uninstall or a retried delete can +// remove the row in between. A caller that asked for a conversation to be gone +// and finds it gone has not failed. +func TestDeleteConcurrentCallsOverRealRepository(t *testing.T) { + svc, repo := newAgentConversationServiceOverRealRepo(t) + ctx := context.Background() + seedConversationWorkspace(t, repo, "ws-one") + victim := ensureConversation(t, svc, "plugin-coordinator", "ws-one", "coordinator") + survivor := ensureConversation(t, svc, "plugin-coordinator", "ws-one", "other-key") + + var wg sync.WaitGroup + counts := make([]int32, 4) + errs := make([]error, 4) + for i := 0; i < 4; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + counts[i], errs[i] = svc.Delete(ctx, "plugin-coordinator", "ws-one", "coordinator") + }(i) + } + wg.Wait() + + var total int32 + for i, c := range counts { + if errs[i] != nil { + t.Errorf("concurrent Delete call %d reported an error for work another caller completed: %v", i, errs[i]) + } + total += c + } + if total != 1 { + t.Errorf("concurrent Delete reported %d deletions across all callers, want exactly 1", total) + } + if taskExists(t, repo, victim.TaskID) { + t.Error("the targeted conversation survived concurrent Delete") + } + if !taskExists(t, repo, survivor.TaskID) { + t.Error("a conversation under a different key was deleted by Delete") + } +} diff --git a/apps/backend/internal/task/service/agent_conversations_concurrency_test.go b/apps/backend/internal/task/service/agent_conversations_concurrency_test.go new file mode 100644 index 0000000000..be44ae21eb --- /dev/null +++ b/apps/backend/internal/task/service/agent_conversations_concurrency_test.go @@ -0,0 +1,66 @@ +package service + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/pkg/pluginsdk" +) + +type barrierCreateTaskRepo struct { + *acFakeTaskRepo + arrived chan struct{} + release chan struct{} +} + +func (r *barrierCreateTaskRepo) CreateTask(ctx context.Context, task *models.Task) error { + r.arrived <- struct{}{} + <-r.release + return r.acFakeTaskRepo.CreateTask(ctx, task) +} + +func TestEnsureUsesOneConversationAcrossServiceInstances(t *testing.T) { + tasks := &barrierCreateTaskRepo{ + acFakeTaskRepo: &acFakeTaskRepo{}, + arrived: make(chan struct{}, 2), + release: make(chan struct{}), + } + sessions := newACFakeSessionRepo() + profiles := newACFakeProfileRepo() + state := newACFakeStateRepo() + first := NewAgentConversationService(tasks, sessions, profiles, state, &acFakeEventBus{}) + second := NewAgentConversationService(tasks, sessions, profiles, state, &acFakeEventBus{}) + first.SetDispatcher(newACFakeDispatcher()) + second.SetDispatcher(newACFakeDispatcher()) + + type result struct { + desc pluginsdk.AgentConversationDescriptor + err error + } + results := make(chan result, 2) + ensure := func(svc *AgentConversationService) { + desc, _, err := svc.Ensure(context.Background(), "plugin-coordinator", pluginsdk.AgentConversationSpec{WorkspaceID: "ws-1", ConversationKey: "coordinator"}) + results <- result{desc: desc, err: err} + } + go ensure(first) + go ensure(second) + <-tasks.arrived + <-tasks.arrived + close(tasks.release) + + firstResult := <-results + secondResult := <-results + if firstResult.err != nil || secondResult.err != nil { + t.Fatalf("concurrent Ensure errors = %v, %v", firstResult.err, secondResult.err) + } + if firstResult.desc.TaskID != secondResult.desc.TaskID { + t.Fatalf("task IDs = %q, %q; two service instances must converge on one backing task", firstResult.desc.TaskID, secondResult.desc.TaskID) + } + if got := tasks.count(); got != 1 { + t.Fatalf("task count = %d, want one shared backing task", got) + } + if got := sessions.count(); got != 1 { + t.Fatalf("primary session count = %d, want one shared primary session", got) + } +} diff --git a/apps/backend/internal/task/service/agent_conversations_delete_errors_test.go b/apps/backend/internal/task/service/agent_conversations_delete_errors_test.go new file mode 100644 index 0000000000..350d610a3f --- /dev/null +++ b/apps/backend/internal/task/service/agent_conversations_delete_errors_test.go @@ -0,0 +1,205 @@ +package service + +import ( + "context" + "errors" + "fmt" + "testing" + + taskrepo "github.com/kandev/kandev/internal/task/repository" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" +) + +// Both delete paths absorb ErrTaskNotFound so a benign cleanup race cannot +// abort an uninstall or fail a plugin's own delete. That absorption is only +// safe if it is narrow: a repository that is genuinely broken (disk error, +// constraint violation, cleanup barrier held) must still be reported, and a +// conversation it could not delete must not be counted as deleted. These +// tests pin the boundary from the failure side, which the hand-written fakes +// cannot reach — acFakeTaskRepo.DeleteTask returns nil for a task it does not +// have, so under a fake every delete looks like a success. + +// acFailingDeleteRepo wraps a task repo and substitutes a chosen error for +// DeleteTask on one specific task id, leaving every other operation real. +type acFailingDeleteRepo struct { + agentConversationTaskRepo + failTaskID string + failWith error + calls int +} + +func (r *acFailingDeleteRepo) DeleteTask(ctx context.Context, taskID string) error { + if taskID == r.failTaskID { + r.calls++ + return r.failWith + } + return r.agentConversationTaskRepo.DeleteTask(ctx, taskID) +} + +// A repository error that is not "already gone" must still reach the caller. +// Service.Uninstall is fail-visible on DeleteAllForPlugin precisely so a real +// failure is not silently swallowed into an orphaned conversation. +func TestDeleteAllForPluginReportsNonNotFoundErrors(t *testing.T) { + realRepo, victim, other := newRepoWithTwoConversations(t) + diskErr := errors.New("disk I/O error") + failing := &acFailingDeleteRepo{ + agentConversationTaskRepo: realRepo, + failTaskID: victim, + failWith: diskErr, + } + svc := NewAgentConversationService(failing, realRepo, nil, newACFakeStateRepo(), nil) + svc.SetDispatcher(newACFakeDispatcher()) + + count, err := svc.DeleteAllForPlugin(context.Background(), "plugin-coordinator") + if err == nil { + t.Fatal("a genuine repository failure was reported as cleanup success; uninstall would orphan the conversation") + } + if !errors.Is(err, diskErr) { + t.Errorf("error did not wrap the underlying repository failure: %v", err) + } + // Cleanup keeps going after a failure so one bad row cannot block the + // rest, so the healthy conversation is still removed and still counted. + if count != 1 { + t.Errorf("count = %d, want 1 (the conversation that really was deleted)", count) + } + if !taskExists(t, realRepo, victim) { + t.Error("the conversation whose delete failed was counted as gone") + } + if taskExists(t, realRepo, other) { + t.Error("cleanup stopped at the failure instead of continuing past it") + } +} + +// The plugin-facing Delete has the same boundary: already-gone is success, +// genuinely broken is not. +func TestDeleteReportsNonNotFoundErrors(t *testing.T) { + realRepo, victim, _ := newRepoWithTwoConversations(t) + constraintErr := errors.New("FOREIGN KEY constraint failed") + failing := &acFailingDeleteRepo{ + agentConversationTaskRepo: realRepo, + failTaskID: victim, + failWith: constraintErr, + } + svc := NewAgentConversationService(failing, realRepo, nil, newACFakeStateRepo(), nil) + svc.SetDispatcher(newACFakeDispatcher()) + + count, err := svc.Delete(context.Background(), "plugin-coordinator", "ws-one", "coordinator") + if err == nil { + t.Fatal("a genuine repository failure was reported to the plugin as a successful delete") + } + if !errors.Is(err, constraintErr) { + t.Errorf("error did not wrap the underlying repository failure: %v", err) + } + if count != 0 { + t.Errorf("count = %d, want 0 (nothing was actually deleted)", count) + } + if !taskExists(t, realRepo, victim) { + t.Error("the conversation whose delete failed was counted as gone") + } +} + +// The guard must key on the sentinel, not on the words in the message. An +// unrelated failure whose text happens to read like "task not found" is still +// a failure, and a string-matching regression here would silently turn real +// errors into success on the uninstall path. +func TestDeletePathsMatchSentinelNotErrorText(t *testing.T) { + for _, tc := range []struct { + name string + err error + }{ + {"plain text lookalike", errors.New("task not found: upstream service unavailable")}, + {"wrapped text lookalike", fmt.Errorf("scheduler: %w", errors.New("task not found"))}, + } { + t.Run(tc.name, func(t *testing.T) { + realRepo, victim, _ := newRepoWithTwoConversations(t) + failing := &acFailingDeleteRepo{ + agentConversationTaskRepo: realRepo, + failTaskID: victim, + failWith: tc.err, + } + svc := NewAgentConversationService(failing, realRepo, nil, newACFakeStateRepo(), nil) + svc.SetDispatcher(newACFakeDispatcher()) + + if _, err := svc.Delete(context.Background(), "plugin-coordinator", "ws-one", "coordinator"); err == nil { + t.Error("Delete absorbed an error that only looks like the not-found sentinel") + } + if _, err := svc.DeleteAllForPlugin(context.Background(), "plugin-coordinator"); err == nil { + t.Error("DeleteAllForPlugin absorbed an error that only looks like the not-found sentinel") + } + }) + } +} + +// The real sentinel, however wrapped, is absorbed on both paths. +func TestDeletePathsAbsorbWrappedNotFoundSentinel(t *testing.T) { + realRepo, victim, _ := newRepoWithTwoConversations(t) + failing := &acFailingDeleteRepo{ + agentConversationTaskRepo: realRepo, + failTaskID: victim, + failWith: fmt.Errorf("delete task %s: %w", victim, taskrepo.ErrTaskNotFound), + } + svc := NewAgentConversationService(failing, realRepo, nil, newACFakeStateRepo(), nil) + svc.SetDispatcher(newACFakeDispatcher()) + + count, err := svc.Delete(context.Background(), "plugin-coordinator", "ws-one", "coordinator") + if err != nil { + t.Fatalf("a wrapped not-found sentinel was reported as a failure: %v", err) + } + if count != 0 { + t.Errorf("count = %d, want 0: a row this caller did not delete must not be counted", count) + } + if failing.calls != 1 { + t.Errorf("DeleteTask calls on the raced task = %d, want 1", failing.calls) + } +} + +// newRepoWithTwoConversations seeds one workspace holding two managed +// conversations for plugin-coordinator under different keys, and returns the +// repository plus the task ids of the "coordinator" and "second" ones. +func newRepoWithTwoConversations(t *testing.T) (repo *sqliterepo.Repository, victimID, otherID string) { + t.Helper() + svc, real := newAgentConversationServiceOverRealRepo(t) + seedConversationWorkspace(t, real, "ws-one") + victim := ensureConversation(t, svc, "plugin-coordinator", "ws-one", "coordinator") + other := ensureConversation(t, svc, "plugin-coordinator", "ws-one", "second") + return real, victim.TaskID, other.TaskID +} + +// compile-time guard: the wrapper must satisfy the interface it embeds. +var _ agentConversationTaskRepo = (*acFailingDeleteRepo)(nil) + +type acRecordingTaskDeleter struct { + delete func(context.Context, string) error + calls []string + err error +} + +func (d *acRecordingTaskDeleter) DeleteTask(ctx context.Context, id string) error { + d.calls = append(d.calls, id) + if d.delete != nil { + return d.delete(ctx, id) + } + return d.err +} + +func TestDeleteUsesLifecycleAwareTaskDeleterWhenAvailable(t *testing.T) { + realRepo, victim, _ := newRepoWithTwoConversations(t) + svc := NewAgentConversationService(realRepo, realRepo, nil, newACFakeStateRepo(), nil) + svc.SetDispatcher(newACFakeDispatcher()) + deleter := &acRecordingTaskDeleter{delete: realRepo.DeleteTask} + svc.SetTaskDeleter(deleter) + + count, err := svc.Delete(context.Background(), "plugin-coordinator", "ws-one", "coordinator") + if err != nil { + t.Fatalf("Delete() error = %v", err) + } + if count != 1 { + t.Fatalf("count = %d, want 1", count) + } + if len(deleter.calls) != 1 || deleter.calls[0] != victim { + t.Fatalf("deleter calls = %#v, want [%q]", deleter.calls, victim) + } + if taskExists(t, realRepo, victim) { + t.Fatal("lifecycle deleter did not remove the managed conversation") + } +} diff --git a/apps/backend/internal/task/service/agent_conversations_dispatch_test.go b/apps/backend/internal/task/service/agent_conversations_dispatch_test.go new file mode 100644 index 0000000000..03f5b90aab --- /dev/null +++ b/apps/backend/internal/task/service/agent_conversations_dispatch_test.go @@ -0,0 +1,797 @@ +package service + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/pkg/pluginsdk" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type cancellationAwareStateRepo struct { + *acFakeStateRepo +} + +func (r cancellationAwareStateRepo) Delete(ctx context.Context, pluginID, scope, scopeID, key string) error { + if err := ctx.Err(); err != nil { + return err + } + return r.acFakeStateRepo.Delete(ctx, pluginID, scope, scopeID, key) +} + +type cancelingErrorDispatcher struct { + cancel context.CancelFunc +} + +func (d cancelingErrorDispatcher) Deliver(context.Context, string, *models.TaskSession, string, string, string) (string, error) { + d.cancel() + return "", errors.New("delivery failed") +} + +type blockingConversationDispatcher struct { + entered chan struct{} + release chan struct{} + onRelease func() + mu sync.Mutex + calls int +} + +func (d *blockingConversationDispatcher) Deliver(_ context.Context, _ string, session *models.TaskSession, _ string, _ string, _ string) (string, error) { + d.mu.Lock() + d.calls++ + d.mu.Unlock() + d.entered <- struct{}{} + <-d.release + if d.onRelease != nil { + d.onRelease() + } + if session.State == models.TaskSessionStateCreated { + return "started", nil + } + return "sent", nil +} + +// ── Profile validation tests (blocker: Ensure must gate hidden task/session +// creation on a resolved, enabled profile — zero partial rows otherwise) ── + +func TestEnsureRejectsMissingProfileWithZeroPartialRows(t *testing.T) { + svc, deps := newACTestService() + deps.profiles.markMissing("ghost-profile") + + spec := pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", + ConversationKey: "coordinator", + AgentProfileID: "ghost-profile", + } + + desc, statusStr, err := svc.Ensure(context.Background(), "plugin-coordinator", spec) + if err != nil { + t.Fatalf("Ensure: %v", err) + } + if statusStr != AgentConversationStatusConfigurationRequired { + t.Fatalf("status = %q, want %q", statusStr, AgentConversationStatusConfigurationRequired) + } + if desc.TaskID != "" || desc.SessionID != "" { + t.Fatalf("expected empty descriptor, got %+v", desc) + } + if got := deps.tasks.count(); got != 0 { + t.Fatalf("expected zero task rows, got %d", got) + } + if got := deps.sess.count(); got != 0 { + t.Fatalf("expected zero session rows, got %d", got) + } + if published := deps.eventer.getPublished(); len(published) != 0 { + t.Fatalf("expected no task.created event, got %d", len(published)) + } +} + +func TestEnsureRejectsDisabledProfileWithZeroPartialRows(t *testing.T) { + svc, deps := newACTestService() + deps.profiles.markDisabled("disabled-profile") + + spec := pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", + ConversationKey: "coordinator", + AgentProfileID: "disabled-profile", + } + + _, statusStr, err := svc.Ensure(context.Background(), "plugin-coordinator", spec) + if err != nil { + t.Fatalf("Ensure: %v", err) + } + if statusStr != AgentConversationStatusConfigurationRequired { + t.Fatalf("status = %q, want %q", statusStr, AgentConversationStatusConfigurationRequired) + } + if got := deps.tasks.count(); got != 0 { + t.Fatalf("expected zero task rows, got %d", got) + } +} + +func TestEnsureProfileValidatorReceivesConfiguredID(t *testing.T) { + svc, deps := newACTestService() + + spec := pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", + ConversationKey: "coordinator", + AgentProfileID: "profile-gpt4", + } + if _, statusStr, err := svc.Ensure(context.Background(), "plugin-coordinator", spec); err != nil || statusStr != AgentConversationStatusCreated { + t.Fatalf("Ensure: status=%q err=%v", statusStr, err) + } + + deps.profiles.mu.Lock() + calls := append([]string(nil), deps.profiles.calls...) + deps.profiles.mu.Unlock() + if len(calls) != 1 || calls[0] != "profile-gpt4" { + t.Fatalf("profile validator calls = %v, want [profile-gpt4]", calls) + } +} + +func TestEnsureWithoutProfileConfiguredUsesWorkspaceDefault(t *testing.T) { + svc, deps := newACTestService() + + spec := pluginsdk.AgentConversationSpec{WorkspaceID: "ws-1", ConversationKey: "coordinator"} + desc, statusStr, err := svc.Ensure(context.Background(), "plugin-coordinator", spec) + if err != nil { + t.Fatalf("Ensure: %v", err) + } + if statusStr != AgentConversationStatusCreated { + t.Fatalf("status = %q, want created", statusStr) + } + deps.profiles.mu.Lock() + calls := len(deps.profiles.calls) + deps.profiles.mu.Unlock() + if calls != 1 { + t.Fatalf("expected one workspace-default profile validation call, got %d", calls) + } + if desc.AgentProfileID != "profile-workspace-default" { + t.Fatalf("descriptor profile = %q, want workspace default", desc.AgentProfileID) + } +} + +func TestEnsureRepairRefusedWhenBoundProfileBecomesInvalid(t *testing.T) { + svc, deps := newACTestService() + + spec := pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", + ConversationKey: "coordinator", + AgentProfileID: "profile-gpt4", + } + desc, statusStr, err := svc.Ensure(context.Background(), "plugin-coordinator", spec) + if err != nil || statusStr != AgentConversationStatusCreated { + t.Fatalf("first Ensure: status=%q err=%v", statusStr, err) + } + + // Simulate a partial-creation repair scenario, with the bound profile + // having since become invalid (disabled or deleted after the task was + // created). + deps.sess.mu.Lock() + delete(deps.sess.sessions, desc.SessionID) + deps.sess.mu.Unlock() + deps.profiles.markMissing("profile-gpt4") + + desc2, statusStr2, err := svc.Ensure(context.Background(), "plugin-coordinator", spec) + if err != nil { + t.Fatalf("second Ensure: %v", err) + } + if statusStr2 != AgentConversationStatusConfigurationRequired { + t.Fatalf("status = %q, want %q", statusStr2, AgentConversationStatusConfigurationRequired) + } + if desc2.SessionID != "" { + t.Fatalf("expected no session to be created during a refused repair, got %+v", desc2) + } + if got := deps.sess.count(); got != 0 { + t.Fatalf("expected zero session rows after refused repair, got %d", got) + } +} + +// ── Dispatch tests (blocker: Dispatch must reach the real agent runtime, +// not just persist a message) ────────────────────────────────────────── + +func TestDispatchDeliversThroughRuntime(t *testing.T) { + svc, deps := newACTestService() + + spec := pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", + ConversationKey: "coordinator", + } + desc, _, err := svc.Ensure(context.Background(), "plugin-coordinator", spec) + if err != nil { + t.Fatalf("Ensure: %v", err) + } + + dispatch, err := svc.Dispatch(context.Background(), "plugin-coordinator", "ws-1", "coordinator", "Check the board", "") + if err != nil { + t.Fatalf("Dispatch: %v", err) + } + // A freshly ensured session is CREATED (never launched); a real + // dispatch through the runtime starts it. The pre-fix implementation + // always reported "sent" because it only ever inserted a message row — + // this assertion is the regression guard for that gap. + if dispatch.Status != "started" { + t.Fatalf("status = %q, want started", dispatch.Status) + } + if dispatch.Descriptor.TaskID == "" { + t.Fatal("expected descriptor TaskID") + } + + if deps.dispatcher.callCount() != 1 { + t.Fatalf("expected 1 call to reach the runtime dispatcher, got %d", deps.dispatcher.callCount()) + } + call := deps.dispatcher.lastCall() + if call.taskID != desc.TaskID { + t.Fatalf("dispatcher taskID = %q, want %q", call.taskID, desc.TaskID) + } + if call.sessionID != desc.SessionID { + t.Fatalf("dispatcher sessionID = %q, want %q", call.sessionID, desc.SessionID) + } + if call.text != "Check the board" { + t.Fatalf("dispatcher text = %q", call.text) + } + if call.source != "plugin:plugin-coordinator" { + t.Fatalf("dispatcher source = %q", call.source) + } +} + +func TestDispatchWithIdleSessionSendsRatherThanStarts(t *testing.T) { + svc, deps := newACTestService() + + spec := pluginsdk.AgentConversationSpec{WorkspaceID: "ws-1", ConversationKey: "coordinator"} + desc, _, err := svc.Ensure(context.Background(), "plugin-coordinator", spec) + if err != nil { + t.Fatalf("Ensure: %v", err) + } + deps.sess.setState(desc.SessionID, models.TaskSessionStateWaitingForInput) + + dispatch, err := svc.Dispatch(context.Background(), "plugin-coordinator", "ws-1", "coordinator", "Check again", "") + if err != nil { + t.Fatalf("Dispatch: %v", err) + } + if dispatch.Status != "sent" { + t.Fatalf("status = %q, want sent", dispatch.Status) + } +} + +func TestDispatchCoalescesDistinctConcurrentOccurrencesForAnIdleSession(t *testing.T) { + svc, deps := newACTestService() + desc, _, err := svc.Ensure(context.Background(), "plugin-coordinator", pluginsdk.AgentConversationSpec{WorkspaceID: "ws-1", ConversationKey: "coordinator"}) + if err != nil { + t.Fatalf("Ensure: %v", err) + } + deps.sess.setState(desc.SessionID, models.TaskSessionStateWaitingForInput) + + dispatcher := &blockingConversationDispatcher{ + entered: make(chan struct{}, 2), + release: make(chan struct{}), + onRelease: func() { + deps.sess.setState(desc.SessionID, models.TaskSessionStateRunning) + }, + } + svc.SetDispatcher(dispatcher) + results := make(chan pluginsdk.AgentConversationDispatch, 2) + errs := make(chan error, 2) + startDispatch := func(key string) { + go func(occurrenceKey string) { + result, dispatchErr := svc.Dispatch(context.Background(), "plugin-coordinator", "ws-1", "coordinator", "wake", occurrenceKey) + results <- result + errs <- dispatchErr + }(key) + } + startDispatch("occurrence-a") + + <-dispatcher.entered + startDispatch("occurrence-b") + select { + case <-dispatcher.entered: + close(dispatcher.release) + t.Fatal("a second distinct occurrence reached the runtime while the first dispatch still owned the idle session") + case <-time.After(time.Second): + } + close(dispatcher.release) + skippedBusy := 0 + for range 2 { + if dispatchErr := <-errs; dispatchErr != nil { + t.Fatalf("Dispatch: %v", dispatchErr) + } + if result := <-results; result.Status == "skipped_busy" { + skippedBusy++ + } + } + if skippedBusy != 1 { + t.Fatalf("skipped_busy results = %d, want one coalesced occurrence", skippedBusy) + } +} + +func TestDispatchWithoutDispatcherReturnsUnavailable(t *testing.T) { + tasks := &acFakeTaskRepo{} + sess := newACFakeSessionRepo() + profiles := newACFakeProfileRepo() + state := newACFakeStateRepo() + eventer := &acFakeEventBus{} + svc := NewAgentConversationService(tasks, sess, profiles, state, eventer) + // Deliberately do not call SetDispatcher — models a not-yet-wired boot + // window (backendapp wires this only once the orchestrator exists). + + spec := pluginsdk.AgentConversationSpec{WorkspaceID: "ws-1", ConversationKey: "coordinator"} + if _, _, err := svc.Ensure(context.Background(), "plugin-coordinator", spec); err != nil { + t.Fatalf("Ensure: %v", err) + } + + _, err := svc.Dispatch(context.Background(), "plugin-coordinator", "ws-1", "coordinator", "Check", "") + if err == nil { + t.Fatal("expected error") + } + if code := status.Code(err); code != codes.Unavailable { + t.Fatalf("got code %v, want Unavailable", code) + } +} + +func TestDispatchBeforeEnsure(t *testing.T) { + svc, _ := newACTestService() + + _, err := svc.Dispatch(context.Background(), "plugin-coordinator", "ws-1", "coordinator", "Hello", "") + if err == nil { + t.Fatal("expected error") + } + if code := status.Code(err); code != codes.NotFound { + t.Fatalf("got code %v, want NotFound", code) + } +} + +func TestDispatchWithOccurrenceKeyDeduplicates(t *testing.T) { + svc, deps := newACTestService() + + spec := pluginsdk.AgentConversationSpec{WorkspaceID: "ws-1", ConversationKey: "coordinator"} + if _, _, err := svc.Ensure(context.Background(), "plugin-coordinator", spec); err != nil { + t.Fatalf("Ensure: %v", err) + } + + key := "wake:cycle:2026-08-17T10:00:00Z" + first, err := svc.Dispatch(context.Background(), "plugin-coordinator", "ws-1", "coordinator", "Check", key) + if err != nil { + t.Fatalf("first Dispatch: %v", err) + } + if first.Status != "started" { + t.Fatalf("first status = %q, want started", first.Status) + } + + second, err := svc.Dispatch(context.Background(), "plugin-coordinator", "ws-1", "coordinator", "Check again", key) + if err != nil { + t.Fatalf("second Dispatch: %v", err) + } + if second.Status != "duplicate_occurrence" { + t.Fatalf("second status = %q, want duplicate_occurrence", second.Status) + } + if second.SessionID != first.SessionID || second.Descriptor != first.Descriptor { + t.Fatalf("duplicate dispatch identity = %#v, want %#v", second, first) + } + + // Only one call must have reached the runtime — a duplicate occurrence + // must never fire the agent twice. + if deps.dispatcher.callCount() != 1 { + t.Fatalf("expected 1 runtime dispatch, got %d", deps.dispatcher.callCount()) + } +} + +func TestDispatchDelimiterCoordinatesUseDistinctMessageIDs(t *testing.T) { + if left, right := deriveOccurrenceMessageID("plugin-coordinator", "ws-1", "a|b", "c"), deriveOccurrenceMessageID("plugin-coordinator", "ws-1", "a", "b|c"); left == right { + t.Fatalf("delimiter coordinates collided at message ID %q", left) + } + + svc, deps := newACTestService() + for _, key := range []string{"a|b", "a"} { + if _, _, err := svc.Ensure(context.Background(), "plugin-coordinator", pluginsdk.AgentConversationSpec{WorkspaceID: "ws-1", ConversationKey: key}); err != nil { + t.Fatalf("Ensure(%q): %v", key, err) + } + } + + first, err := svc.Dispatch(context.Background(), "plugin-coordinator", "ws-1", "a|b", "first", "c") + if err != nil || first.Status != "started" { + t.Fatalf("first Dispatch: status=%q err=%v", first.Status, err) + } + second, err := svc.Dispatch(context.Background(), "plugin-coordinator", "ws-1", "a", "second", "b|c") + if err != nil || second.Status != "started" { + t.Fatalf("second Dispatch: status=%q err=%v", second.Status, err) + } + if deps.dispatcher.callCount() != 2 { + t.Fatalf("runtime calls = %d, want 2 distinct conversations", deps.dispatcher.callCount()) + } +} + +func TestDispatchRetriesOccurrenceAfterCancelledDelivery(t *testing.T) { + tasks := &acFakeTaskRepo{} + sessions := newACFakeSessionRepo() + profiles := newACFakeProfileRepo() + state := cancellationAwareStateRepo{acFakeStateRepo: newACFakeStateRepo()} + svc := NewAgentConversationService(tasks, sessions, profiles, state, &acFakeEventBus{}) + ctx, cancel := context.WithCancel(context.Background()) + svc.SetDispatcher(cancelingErrorDispatcher{cancel: cancel}) + + if _, _, err := svc.Ensure(ctx, "plugin-coordinator", pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", ConversationKey: "coordinator", + }); err != nil { + t.Fatalf("Ensure: %v", err) + } + const occurrenceKey = "wake:cancelled-delivery" + if _, err := svc.Dispatch(ctx, "plugin-coordinator", "ws-1", "coordinator", "Check", occurrenceKey); err == nil { + t.Fatal("first Dispatch succeeded, want delivery failure") + } + + svc.SetDispatcher(newACFakeDispatcher()) + retry, err := svc.Dispatch(context.Background(), "plugin-coordinator", "ws-1", "coordinator", "Check", occurrenceKey) + if err != nil { + t.Fatalf("retry Dispatch: %v", err) + } + if retry.Status != "started" { + t.Fatalf("retry status = %q, want started", retry.Status) + } +} + +func TestIsManagedConversationTaskHonorsEphemeralMetadata(t *testing.T) { + task := &models.Task{ + Metadata: map[string]interface{}{ + metaKeyPluginID: "plugin-coordinator", + metaKeyEphemeral: true, + }, + } + + if !IsManagedConversationTask(task) { + t.Fatal("managed conversation metadata should preserve exclusion when the projected field is stale") + } +} + +func TestDispatchOccurrenceKeysAreScopedPerConversation(t *testing.T) { + svc, deps := newACTestService() + + specA := pluginsdk.AgentConversationSpec{WorkspaceID: "ws-1", ConversationKey: "coordinator"} + specB := pluginsdk.AgentConversationSpec{WorkspaceID: "ws-2", ConversationKey: "coordinator"} + if _, _, err := svc.Ensure(context.Background(), "plugin-coordinator", specA); err != nil { + t.Fatalf("Ensure ws-1: %v", err) + } + if _, _, err := svc.Ensure(context.Background(), "plugin-coordinator", specB); err != nil { + t.Fatalf("Ensure ws-2: %v", err) + } + + key := "wake:cycle:same-timestamp" + dispatchA, err := svc.Dispatch(context.Background(), "plugin-coordinator", "ws-1", "coordinator", "A", key) + if err != nil || dispatchA.Status != "started" { + t.Fatalf("dispatch ws-1: status=%q err=%v", dispatchA.Status, err) + } + dispatchB, err := svc.Dispatch(context.Background(), "plugin-coordinator", "ws-2", "coordinator", "B", key) + if err != nil || dispatchB.Status != "started" { + t.Fatalf("dispatch ws-2 (same occurrence key, different workspace) should not be treated as a duplicate: status=%q err=%v", dispatchB.Status, err) + } + if deps.dispatcher.callCount() != 2 { + t.Fatalf("expected 2 runtime dispatches, got %d", deps.dispatcher.callCount()) + } +} + +func TestDispatchConcurrentOccurrenceClaimIsAtomic(t *testing.T) { + svc, deps := newACTestService() + + spec := pluginsdk.AgentConversationSpec{WorkspaceID: "ws-1", ConversationKey: "coordinator"} + if _, _, err := svc.Ensure(context.Background(), "plugin-coordinator", spec); err != nil { + t.Fatalf("Ensure: %v", err) + } + + const attempts = 20 + key := "wake:cycle:concurrent" + results := make([]pluginsdk.AgentConversationDispatch, attempts) + var wg sync.WaitGroup + for i := range attempts { + wg.Add(1) + go func(idx int) { + defer wg.Done() + d, err := svc.Dispatch(context.Background(), "plugin-coordinator", "ws-1", "coordinator", "Check", key) + if err != nil { + t.Errorf("dispatch %d: %v", idx, err) + return + } + results[idx] = d + }(i) + } + wg.Wait() + + started := 0 + duplicate := 0 + for _, r := range results { + switch r.Status { + case "started": + started++ + case "duplicate_occurrence": + duplicate++ + default: + t.Fatalf("unexpected status %q", r.Status) + } + } + if started != 1 { + t.Fatalf("expected exactly 1 winning dispatch, got %d", started) + } + if duplicate != attempts-1 { + t.Fatalf("expected %d duplicates, got %d", attempts-1, duplicate) + } + if deps.dispatcher.callCount() != 1 { + t.Fatalf("expected exactly 1 runtime dispatch under concurrency, got %d", deps.dispatcher.callCount()) + } +} + +func TestDispatchWithBusySession(t *testing.T) { + svc, deps := newACTestService() + + spec := pluginsdk.AgentConversationSpec{WorkspaceID: "ws-1", ConversationKey: "coordinator"} + desc, _, err := svc.Ensure(context.Background(), "plugin-coordinator", spec) + if err != nil { + t.Fatalf("Ensure: %v", err) + } + deps.sess.setState(desc.SessionID, models.TaskSessionStateRunning) + + dispatch, err := svc.Dispatch(context.Background(), "plugin-coordinator", "ws-1", "coordinator", "Check", "") + if err != nil { + t.Fatalf("Dispatch: %v", err) + } + if dispatch.Status != "skipped_busy" { + t.Fatalf("status = %q, want skipped_busy", dispatch.Status) + } + if deps.dispatcher.callCount() != 0 { + t.Fatal("a busy session must never reach the runtime dispatcher") + } +} + +func TestDispatchWithStartingSessionIsAlsoBusy(t *testing.T) { + svc, deps := newACTestService() + + spec := pluginsdk.AgentConversationSpec{WorkspaceID: "ws-1", ConversationKey: "coordinator"} + desc, _, err := svc.Ensure(context.Background(), "plugin-coordinator", spec) + if err != nil { + t.Fatalf("Ensure: %v", err) + } + deps.sess.setState(desc.SessionID, models.TaskSessionStateStarting) + + dispatch, err := svc.Dispatch(context.Background(), "plugin-coordinator", "ws-1", "coordinator", "Check", "") + if err != nil { + t.Fatalf("Dispatch: %v", err) + } + if dispatch.Status != "skipped_busy" { + t.Fatalf("status = %q, want skipped_busy", dispatch.Status) + } + if deps.dispatcher.callCount() != 0 { + t.Fatal("a starting session must never reach the runtime dispatcher") + } +} + +func TestDispatchRejectsEmptyArguments(t *testing.T) { + svc, _ := newACTestService() + + tests := []struct { + name, plugin, ws, key, text string + }{ + {"empty plugin", "", "ws-1", "key", "hello"}, + {"empty workspace", "p", "", "key", "hello"}, + {"empty key", "p", "ws-1", "", "hello"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := svc.Dispatch(context.Background(), tt.plugin, tt.ws, tt.key, tt.text, "") + if err == nil { + t.Fatal("expected error") + } + if code := status.Code(err); code != codes.InvalidArgument { + t.Fatalf("got %v, want InvalidArgument", code) + } + }) + } +} + +func TestDispatchPropagatesRuntimeDeliveryFailure(t *testing.T) { + svc, deps := newACTestService() + + spec := pluginsdk.AgentConversationSpec{WorkspaceID: "ws-1", ConversationKey: "coordinator"} + if _, _, err := svc.Ensure(context.Background(), "plugin-coordinator", spec); err != nil { + t.Fatalf("Ensure: %v", err) + } + deps.dispatcher.err = errors.New("agent runtime unavailable") + + _, err := svc.Dispatch(context.Background(), "plugin-coordinator", "ws-1", "coordinator", "Check", "") + if err == nil { + t.Fatal("expected error") + } +} + +// ── Delete tests ──────────────────────────────────────────────────────── + +func TestDeleteRemovesConversation(t *testing.T) { + svc, _ := newACTestService() + + spec := pluginsdk.AgentConversationSpec{WorkspaceID: "ws-1", ConversationKey: "coordinator"} + if _, _, err := svc.Ensure(context.Background(), "plugin-coordinator", spec); err != nil { + t.Fatalf("Ensure: %v", err) + } + + count, err := svc.Delete(context.Background(), "plugin-coordinator", "ws-1", "coordinator") + if err != nil { + t.Fatalf("Delete: %v", err) + } + if count != 1 { + t.Fatalf("deleted count = %d, want 1", count) + } + + _, statusStr, err := svc.Ensure(context.Background(), "plugin-coordinator", spec) + if err != nil { + t.Fatalf("Ensure after delete: %v", err) + } + if statusStr != AgentConversationStatusCreated { + t.Fatalf("status = %q, want created", statusStr) + } +} + +func TestDeleteOnlyOwnedConversations(t *testing.T) { + svc, _ := newACTestService() + + spec := pluginsdk.AgentConversationSpec{WorkspaceID: "ws-1", ConversationKey: "coordinator"} + if _, _, err := svc.Ensure(context.Background(), "plugin-a", spec); err != nil { + t.Fatalf("plugin-a Ensure: %v", err) + } + if _, _, err := svc.Ensure(context.Background(), "plugin-b", spec); err != nil { + t.Fatalf("plugin-b Ensure: %v", err) + } + + count, err := svc.Delete(context.Background(), "plugin-a", "ws-1", "coordinator") + if err != nil { + t.Fatalf("Delete: %v", err) + } + if count != 1 { + t.Fatalf("deleted = %d, want 1", count) + } + + _, statusStr, err := svc.Ensure(context.Background(), "plugin-b", spec) + if err != nil { + t.Fatalf("plugin-b Ensure: %v", err) + } + if statusStr != AgentConversationStatusExists { + t.Fatalf("plugin-b status = %q, want exists", statusStr) + } +} + +func TestDeleteIdempotent(t *testing.T) { + svc, _ := newACTestService() + + count, err := svc.Delete(context.Background(), "plugin-nonexistent", "ws-1", "key") + if err != nil { + t.Fatalf("Delete: %v", err) + } + if count != 0 { + t.Fatalf("deleted = %d, want 0", count) + } +} + +// ── DeleteAllForPlugin (uninstall cleanup) tests ─────────────────────────── +// +// Uninstall must find and remove every managed conversation a plugin owns +// regardless of workspace (Delete/Ensure/Dispatch are scoped to one +// workspace+key), while never touching another plugin's managed +// conversations or ordinary (non-ephemeral) user tasks. These pin exactly +// that contract. + +func TestDeleteAllForPluginSpansWorkspaces(t *testing.T) { + svc, deps := newACTestService() + + if _, _, err := svc.Ensure(context.Background(), "plugin-coordinator", pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", ConversationKey: "coordinator", + }); err != nil { + t.Fatalf("Ensure ws-1: %v", err) + } + if _, _, err := svc.Ensure(context.Background(), "plugin-coordinator", pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-2", ConversationKey: "coordinator", + }); err != nil { + t.Fatalf("Ensure ws-2: %v", err) + } + + count, err := svc.DeleteAllForPlugin(context.Background(), "plugin-coordinator") + if err != nil { + t.Fatalf("DeleteAllForPlugin: %v", err) + } + if count != 2 { + t.Fatalf("deleted = %d, want 2 (one per workspace)", count) + } + if got := deps.tasks.count(); got != 0 { + t.Fatalf("remaining task rows = %d, want 0", got) + } +} + +// TestDeleteAllForPluginLeavesOtherPluginsUntouched is the provenance-safety +// regression: uninstalling one plugin must never delete another plugin's +// managed conversation, even though both are ephemeral tasks that would +// otherwise look alike. +func TestDeleteAllForPluginLeavesOtherPluginsUntouched(t *testing.T) { + svc, deps := newACTestService() + + if _, _, err := svc.Ensure(context.Background(), "plugin-coordinator", pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", ConversationKey: "coordinator", + }); err != nil { + t.Fatalf("Ensure plugin-coordinator: %v", err) + } + otherDesc, _, err := svc.Ensure(context.Background(), "plugin-other", pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", ConversationKey: "coordinator", + }) + if err != nil { + t.Fatalf("Ensure plugin-other: %v", err) + } + + count, err := svc.DeleteAllForPlugin(context.Background(), "plugin-coordinator") + if err != nil { + t.Fatalf("DeleteAllForPlugin: %v", err) + } + if count != 1 { + t.Fatalf("deleted = %d, want 1 (only plugin-coordinator's own conversation)", count) + } + + remaining, _, _ := deps.tasks.ListTasksByWorkspace(context.Background(), "ws-1", "", "", "", 1, 100, "", false, true, true, false) + if len(remaining) != 1 || remaining[0].ID != otherDesc.TaskID { + t.Fatalf("expected only plugin-other's conversation (%s) to survive, got %+v", otherDesc.TaskID, remaining) + } +} + +// TestDeleteAllForPluginLeavesOrdinaryUserTasksUntouched proves the other +// half of provenance safety: an ordinary (non-ephemeral, no plugin +// provenance) user task in the same workspace is never reachable by +// DeleteAllForPlugin, even one created by the same plugin ID acting through +// a normal (non-managed-conversation) path. +func TestDeleteAllForPluginLeavesOrdinaryUserTasksUntouched(t *testing.T) { + svc, deps := newACTestService() + + if _, _, err := svc.Ensure(context.Background(), "plugin-coordinator", pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", ConversationKey: "coordinator", + }); err != nil { + t.Fatalf("Ensure: %v", err) + } + + userTask := &models.Task{ + WorkspaceID: "ws-1", + Title: "Fix the login bug", + IsEphemeral: false, + Metadata: map[string]interface{}{"kandev.plugin_id": "plugin-coordinator"}, + } + if err := deps.tasks.CreateTask(context.Background(), userTask); err != nil { + t.Fatalf("seed ordinary user task: %v", err) + } + + count, err := svc.DeleteAllForPlugin(context.Background(), "plugin-coordinator") + if err != nil { + t.Fatalf("DeleteAllForPlugin: %v", err) + } + if count != 1 { + t.Fatalf("deleted = %d, want 1 (the managed conversation only)", count) + } + + remaining, _, _ := deps.tasks.ListTasksByWorkspace(context.Background(), "ws-1", "", "", "", 1, 100, "", true, true, false, false) + if len(remaining) != 1 || remaining[0].ID != userTask.ID { + t.Fatalf("expected the ordinary user task to survive untouched, got %+v", remaining) + } +} + +func TestDeleteAllForPluginRejectsEmptyPluginID(t *testing.T) { + svc, _ := newACTestService() + _, err := svc.DeleteAllForPlugin(context.Background(), "") + if err == nil { + t.Fatal("expected error") + } + if code := status.Code(err); code != codes.InvalidArgument { + t.Fatalf("got %v, want InvalidArgument", code) + } +} + +func TestDeleteAllForPluginNothingOwnedIsNoop(t *testing.T) { + svc, _ := newACTestService() + count, err := svc.DeleteAllForPlugin(context.Background(), "plugin-never-installed") + if err != nil { + t.Fatalf("DeleteAllForPlugin: %v", err) + } + if count != 0 { + t.Fatalf("deleted = %d, want 0", count) + } +} diff --git a/apps/backend/internal/task/service/agent_conversations_pagination_test.go b/apps/backend/internal/task/service/agent_conversations_pagination_test.go new file mode 100644 index 0000000000..024b0c1c34 --- /dev/null +++ b/apps/backend/internal/task/service/agent_conversations_pagination_test.go @@ -0,0 +1,205 @@ +package service + +import ( + "context" + "sort" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/pkg/pluginsdk" +) + +// acPagingTaskRepo is a task repo fake that honours page/pageSize the way the +// real SQLite repository does, so the managed-conversation lookup is exercised +// against a workspace holding more ephemeral tasks than one page. +type acPagingTaskRepo struct { + mu sync.Mutex + tasks []*models.Task +} + +func (f *acPagingTaskRepo) GetWorkspace(_ context.Context, id string) (*models.Workspace, error) { + profileID := "profile-workspace-default" + return &models.Workspace{ID: id, DefaultAgentProfileID: &profileID}, nil +} + +func (f *acPagingTaskRepo) ListTasksByWorkspace(_ context.Context, workspaceID, _, _, _ string, page, pageSize int, _ string, _, includeEphemeral, onlyEphemeral, _ bool) ([]*models.Task, int, error) { + f.mu.Lock() + defer f.mu.Unlock() + var filtered []*models.Task + for _, t := range f.tasks { + if t.WorkspaceID != workspaceID { + continue + } + if onlyEphemeral && !t.IsEphemeral { + continue + } + if !includeEphemeral && t.IsEphemeral { + continue + } + filtered = append(filtered, t) + } + // The real repository defaults to updated_at DESC, so the most recently + // touched ephemeral tasks fill the first pages and an older managed + // conversation drifts onto a later one. + sort.SliceStable(filtered, func(i, j int) bool { + return filtered[i].UpdatedAt.After(filtered[j].UpdatedAt) + }) + total := len(filtered) + offset := (page - 1) * pageSize + if offset >= total { + return nil, total, nil + } + end := offset + pageSize + if end > total { + end = total + } + return filtered[offset:end], total, nil +} + +func (f *acPagingTaskRepo) ListEphemeralTasksAllWorkspaces(_ context.Context) ([]*models.Task, error) { + f.mu.Lock() + defer f.mu.Unlock() + var filtered []*models.Task + for _, t := range f.tasks { + if t.IsEphemeral { + filtered = append(filtered, t) + } + } + return filtered, nil +} + +func (f *acPagingTaskRepo) CreateTask(_ context.Context, task *models.Task) error { + f.mu.Lock() + defer f.mu.Unlock() + f.tasks = append(f.tasks, task) + return nil +} + +func (f *acPagingTaskRepo) UpdateTask(_ context.Context, task *models.Task) error { + f.mu.Lock() + defer f.mu.Unlock() + for i, existing := range f.tasks { + if existing.ID == task.ID { + f.tasks[i] = task + return nil + } + } + return nil +} + +func (f *acPagingTaskRepo) DeleteTask(_ context.Context, taskID string) error { + f.mu.Lock() + defer f.mu.Unlock() + for i, t := range f.tasks { + if t.ID == taskID { + f.tasks = append(f.tasks[:i], f.tasks[i+1:]...) + return nil + } + } + return nil +} + +// fillWithOtherEphemeralTasks appends n unrelated ephemeral tasks (quick +// chats, automation runs) so the managed conversation is pushed off the first +// page of the workspace listing. +func fillWithOtherEphemeralTasks(repo *acPagingTaskRepo, workspaceID string, n int) { + base := time.Now().UTC().Add(time.Hour) + for i := 0; i < n; i++ { + repo.tasks = append(repo.tasks, &models.Task{ + ID: uuid.New().String(), + WorkspaceID: workspaceID, + IsEphemeral: true, + UpdatedAt: base.Add(time.Duration(i) * time.Second), + }) + } +} + +func newACPagingService(repo *acPagingTaskRepo) (*AgentConversationService, *acFakeDispatcher) { + dispatcher := newACFakeDispatcher() + svc := NewAgentConversationService(repo, newACFakeSessionRepo(), newACFakeProfileRepo(), newACFakeStateRepo(), &acFakeEventBus{}) + svc.SetDispatcher(dispatcher) + return svc, dispatcher +} + +// A workspace that accumulated more ephemeral tasks than one page must still +// resolve its existing managed conversation: a first-page-only scan reports +// "not found" and Ensure silently creates a second hidden conversation, +// splitting the coordinator's history. +func TestEnsureFindsConversationBeyondFirstPage(t *testing.T) { + repo := &acPagingTaskRepo{} + svc, _ := newACPagingService(repo) + spec := pluginsdk.AgentConversationSpec{WorkspaceID: "ws-1", ConversationKey: "coordinator"} + + first, statusStr, err := svc.Ensure(context.Background(), "plugin-coordinator", spec) + if err != nil { + t.Fatalf("first Ensure: %v", err) + } + if statusStr != AgentConversationStatusCreated { + t.Fatalf("first Ensure status = %q, want %q", statusStr, AgentConversationStatusCreated) + } + + fillWithOtherEphemeralTasks(repo, "ws-1", managedConversationPageSize*2) + + second, statusStr, err := svc.Ensure(context.Background(), "plugin-coordinator", spec) + if err != nil { + t.Fatalf("second Ensure: %v", err) + } + if statusStr != AgentConversationStatusExists { + t.Fatalf("second Ensure status = %q, want %q", statusStr, AgentConversationStatusExists) + } + if second.TaskID != first.TaskID { + t.Fatalf("second Ensure returned a different conversation: %q vs %q", second.TaskID, first.TaskID) + } + if got := len(repo.tasks); got != 1+managedConversationPageSize*2 { + t.Fatalf("expected no duplicate conversation task, got %d tasks", got) + } +} + +// Dispatch must reach the same conversation once it is past the first page, +// rather than reporting NotFound after the scheduler already claimed the +// occurrence key. +func TestDispatchFindsConversationBeyondFirstPage(t *testing.T) { + repo := &acPagingTaskRepo{} + svc, dispatcher := newACPagingService(repo) + spec := pluginsdk.AgentConversationSpec{WorkspaceID: "ws-1", ConversationKey: "coordinator"} + + if _, _, err := svc.Ensure(context.Background(), "plugin-coordinator", spec); err != nil { + t.Fatalf("Ensure: %v", err) + } + fillWithOtherEphemeralTasks(repo, "ws-1", managedConversationPageSize*2) + + result, err := svc.Dispatch(context.Background(), "plugin-coordinator", "ws-1", "coordinator", "WAKE:CYCLE", "occ-1") + if err != nil { + t.Fatalf("Dispatch: %v", err) + } + if result.Status == "" || result.SessionID == "" { + t.Fatalf("Dispatch returned empty result: %+v", result) + } + if len(dispatcher.calls) != 1 { + t.Fatalf("expected exactly one delivery, got %d", len(dispatcher.calls)) + } +} + +// Delete must sweep every owned conversation, including any that a +// first-page-only scan would leave orphaned after uninstall. +func TestDeleteFindsConversationsBeyondFirstPage(t *testing.T) { + repo := &acPagingTaskRepo{} + svc, _ := newACPagingService(repo) + spec := pluginsdk.AgentConversationSpec{WorkspaceID: "ws-1", ConversationKey: "coordinator"} + + if _, _, err := svc.Ensure(context.Background(), "plugin-coordinator", spec); err != nil { + t.Fatalf("Ensure: %v", err) + } + fillWithOtherEphemeralTasks(repo, "ws-1", managedConversationPageSize*2) + + count, err := svc.Delete(context.Background(), "plugin-coordinator", "ws-1", "coordinator") + if err != nil { + t.Fatalf("Delete: %v", err) + } + if count != 1 { + t.Fatalf("Delete removed %d conversations, want 1", count) + } +} diff --git a/apps/backend/internal/task/service/agent_conversations_predicates_test.go b/apps/backend/internal/task/service/agent_conversations_predicates_test.go new file mode 100644 index 0000000000..95ae02ea11 --- /dev/null +++ b/apps/backend/internal/task/service/agent_conversations_predicates_test.go @@ -0,0 +1,58 @@ +package service + +import ( + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +func TestIsManagedConversationTask(t *testing.T) { + tests := []struct { + name string + task *models.Task + want bool + }{ + {name: "nil task", task: nil, want: false}, + {name: "nil metadata", task: &models.Task{IsEphemeral: true}, want: false}, + { + name: "ephemeral with plugin id", + task: &models.Task{ + IsEphemeral: true, + Metadata: map[string]interface{}{metaKeyPluginID: "plugin-coordinator"}, + }, + want: true, + }, + { + name: "non-ephemeral with plugin id", + task: &models.Task{ + Metadata: map[string]interface{}{metaKeyPluginID: "plugin-coordinator"}, + }, + want: false, + }, + { + name: "stale ephemeral column falls back to metadata", + task: &models.Task{ + Metadata: map[string]interface{}{ + metaKeyPluginID: "plugin-coordinator", + metaKeyEphemeral: true, + }, + }, + want: true, + }, + { + name: "ephemeral without plugin id", + task: &models.Task{ + IsEphemeral: true, + Metadata: map[string]interface{}{"some_other_key": "value"}, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsManagedConversationTask(tt.task); got != tt.want { + t.Fatalf("IsManagedConversationTask = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/apps/backend/internal/task/service/agent_conversations_real_repo_test.go b/apps/backend/internal/task/service/agent_conversations_real_repo_test.go new file mode 100644 index 0000000000..48b0db843d --- /dev/null +++ b/apps/backend/internal/task/service/agent_conversations_real_repo_test.go @@ -0,0 +1,185 @@ +package service + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/task/models" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" + "github.com/kandev/kandev/pkg/pluginsdk" +) + +// The uninstall cleanup path is otherwise only proven against hand-written +// fakes: internal/plugins' uninstall tests use a fake AgentConversationService, +// and this package's own tests use in-memory task repos that hand back the +// very *models.Task pointer they were given. Neither can catch the thing most +// likely to break provenance matching in production — task metadata makes a +// round trip through JSON in SQLite, and DeleteAllForPlugin's ownership check +// type-asserts kandev.ephemeral back to a bool. These tests run Ensure and +// DeleteAllForPlugin over the real repository so that round trip is real. + +func newAgentConversationServiceOverRealRepo(t *testing.T) (*AgentConversationService, *sqliterepo.Repository) { + t.Helper() + _, _, repo := createTestService(t) + svc := NewAgentConversationService(repo, repo, nil, newACFakeStateRepo(), nil) + svc.SetDispatcher(newACFakeDispatcher()) + return svc, repo +} + +func seedConversationWorkspace(t *testing.T, repo *sqliterepo.Repository, workspaceID string) { + t.Helper() + profileID := "test-workspace-default-profile" + if err := repo.CreateWorkspace(context.Background(), &models.Workspace{ + ID: workspaceID, + Name: workspaceID, + DefaultAgentProfileID: &profileID, + }); err != nil { + t.Fatalf("create workspace %s: %v", workspaceID, err) + } +} + +func ensureConversation(t *testing.T, svc *AgentConversationService, pluginID, workspaceID, key string) pluginsdk.AgentConversationDescriptor { + t.Helper() + desc, status, err := svc.Ensure(context.Background(), pluginID, pluginsdk.AgentConversationSpec{ + WorkspaceID: workspaceID, + ConversationKey: key, + }) + if err != nil { + t.Fatalf("Ensure(%s/%s/%s): %v", pluginID, workspaceID, key, err) + } + if status != AgentConversationStatusCreated { + t.Fatalf("Ensure(%s/%s/%s) status = %q, want %q", pluginID, workspaceID, key, status, AgentConversationStatusCreated) + } + return desc +} + +func taskExists(t *testing.T, repo *sqliterepo.Repository, taskID string) bool { + t.Helper() + task, err := repo.GetTask(context.Background(), taskID) + return err == nil && task != nil +} + +// SQLite wraps ErrNoPrimarySession with the task id. Ensure must classify that +// typed absence as repairable instead of returning an error and leaving the +// managed task without a session. +func TestEnsureRepairsWrappedMissingPrimarySessionOverRealRepository(t *testing.T) { + svc, repo := newAgentConversationServiceOverRealRepo(t) + ctx := context.Background() + seedConversationWorkspace(t, repo, "ws-one") + + original := ensureConversation(t, svc, "plugin-coordinator", "ws-one", "coordinator") + session, err := repo.GetTaskSession(ctx, original.SessionID) + if err != nil { + t.Fatalf("load conversation session: %v", err) + } + if err := repo.DeleteTaskSession(ctx, session); err != nil { + t.Fatalf("delete conversation session: %v", err) + } + + repaired, status, err := svc.Ensure(ctx, "plugin-coordinator", pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-one", ConversationKey: "coordinator", + }) + if err != nil { + t.Fatalf("Ensure repair: %v", err) + } + if status != AgentConversationStatusExists { + t.Fatalf("Ensure repair status = %q, want %q", status, AgentConversationStatusExists) + } + if repaired.TaskID != original.TaskID || repaired.SessionID != original.SessionID { + t.Fatalf("repaired descriptor = %#v, want stable task/session %#v", repaired, original) + } +} + +// Uninstall cleanup has to survive the metadata JSON round trip, reach every +// workspace, and take the conversation's sessions with it. +func TestDeleteAllForPluginOverRealRepository(t *testing.T) { + svc, repo := newAgentConversationServiceOverRealRepo(t) + ctx := context.Background() + seedConversationWorkspace(t, repo, "ws-one") + seedConversationWorkspace(t, repo, "ws-two") + + first := ensureConversation(t, svc, "plugin-coordinator", "ws-one", "coordinator") + second := ensureConversation(t, svc, "plugin-coordinator", "ws-two", "coordinator") + + // Re-read one conversation through the repository before deleting, so a + // metadata round trip that loses kandev.ephemeral fails here rather than + // silently making the delete a no-op. + stored, err := repo.GetTask(ctx, first.TaskID) + if err != nil { + t.Fatalf("re-read conversation task: %v", err) + } + if !isManagedConversationOwnedByPlugin(stored, "plugin-coordinator") { + t.Fatalf("conversation task read back from SQLite is no longer recognised as owned; metadata = %#v", stored.Metadata) + } + + count, err := svc.DeleteAllForPlugin(ctx, "plugin-coordinator") + if err != nil { + t.Fatalf("DeleteAllForPlugin: %v", err) + } + if count != 2 { + t.Fatalf("DeleteAllForPlugin removed %d conversations, want 2 (one per workspace)", count) + } + for _, desc := range []pluginsdk.AgentConversationDescriptor{first, second} { + if taskExists(t, repo, desc.TaskID) { + t.Errorf("conversation task %s still exists after uninstall cleanup", desc.TaskID) + } + session, err := repo.GetTaskSession(ctx, desc.SessionID) + if err == nil && session != nil { + t.Errorf("session %s survived deletion of its conversation task %s", desc.SessionID, desc.TaskID) + } + } +} + +// The blast radius of a cleanup that runs during uninstall matters more than +// the cleanup itself: everything not owned by the uninstalled plugin has to +// survive it. +func TestDeleteAllForPluginOverRealRepositoryLeavesEverythingElse(t *testing.T) { + svc, repo := newAgentConversationServiceOverRealRepo(t) + ctx := context.Background() + seedConversationWorkspace(t, repo, "ws-one") + + victim := ensureConversation(t, svc, "plugin-coordinator", "ws-one", "coordinator") + otherPlugin := ensureConversation(t, svc, "plugin-notes", "ws-one", "notes") + + ordinary := &models.Task{ + ID: "ordinary-user-task", + WorkspaceID: "ws-one", + Title: "A real user task", + Priority: "medium", + Origin: models.TaskOriginManual, + } + if err := repo.CreateTask(ctx, ordinary); err != nil { + t.Fatalf("create ordinary task: %v", err) + } + + count, err := svc.DeleteAllForPlugin(ctx, "plugin-coordinator") + if err != nil { + t.Fatalf("DeleteAllForPlugin: %v", err) + } + if count != 1 { + t.Fatalf("DeleteAllForPlugin removed %d conversations, want exactly 1", count) + } + if taskExists(t, repo, victim.TaskID) { + t.Error("the uninstalled plugin's own conversation survived") + } + if !taskExists(t, repo, otherPlugin.TaskID) { + t.Error("another plugin's managed conversation was deleted by an unrelated uninstall") + } + if !taskExists(t, repo, ordinary.ID) { + t.Error("an ordinary user task was deleted by a plugin uninstall") + } +} + +// An empty plugin id must never be treated as "match everything". +func TestDeleteAllForPluginRejectsEmptyPluginIDOverRealRepository(t *testing.T) { + svc, repo := newAgentConversationServiceOverRealRepo(t) + seedConversationWorkspace(t, repo, "ws-one") + kept := ensureConversation(t, svc, "plugin-coordinator", "ws-one", "coordinator") + + if _, err := svc.DeleteAllForPlugin(context.Background(), ""); err == nil { + t.Fatal("expected an error for an empty plugin id") + } + if !taskExists(t, repo, kept.TaskID) { + t.Error("an empty plugin id deleted a conversation") + } +} diff --git a/apps/backend/internal/task/service/agent_conversations_state_test.go b/apps/backend/internal/task/service/agent_conversations_state_test.go new file mode 100644 index 0000000000..8f72ca58b7 --- /dev/null +++ b/apps/backend/internal/task/service/agent_conversations_state_test.go @@ -0,0 +1,78 @@ +package service + +import ( + "context" + "encoding/json" + "sync" +) + +// acFakeStateRepo simulates the durable atomic Claim primitive +// (internal/plugins/state.Store.Claim in production) with an in-process +// mutex-guarded map. It proves service behavior while Store.Claim's SQL-level +// atomicity and restart durability are proven separately in internal/plugins/state. +type acFakeStateRepo struct { + mu sync.Mutex + claims map[string]bool + values map[string]json.RawMessage +} + +func newACFakeStateRepo() *acFakeStateRepo { + return &acFakeStateRepo{claims: map[string]bool{}, values: map[string]json.RawMessage{}} +} + +func acStateKey(pluginID, scope, scopeID, key string) string { + return pluginID + "/" + scope + "/" + scopeID + "/" + key +} + +func (f *acFakeStateRepo) Claim(_ context.Context, pluginID, scope, scopeID, key string, value json.RawMessage) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + k := acStateKey(pluginID, scope, scopeID, key) + if f.claims[k] { + return false, nil + } + f.claims[k] = true + f.values[k] = append(json.RawMessage(nil), value...) + return true, nil +} + +func (f *acFakeStateRepo) Get(_ context.Context, pluginID, scope, scopeID, key string) (json.RawMessage, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + value, found := f.values[acStateKey(pluginID, scope, scopeID, key)] + if !found { + return nil, false, nil + } + return append(json.RawMessage(nil), value...), true, nil +} + +func (f *acFakeStateRepo) Set(_ context.Context, pluginID, scope, scopeID, key string, value json.RawMessage) error { + f.mu.Lock() + defer f.mu.Unlock() + k := acStateKey(pluginID, scope, scopeID, key) + f.claims[k] = true + f.values[k] = append(json.RawMessage(nil), value...) + return nil +} + +func (f *acFakeStateRepo) Delete(_ context.Context, pluginID, scope, scopeID, key string) error { + f.mu.Lock() + defer f.mu.Unlock() + k := acStateKey(pluginID, scope, scopeID, key) + delete(f.claims, k) + delete(f.values, k) + return nil +} + +func (f *acFakeStateRepo) seedPending(pluginID, workspaceID, conversationKey, occurrenceKey string) { + f.mu.Lock() + defer f.mu.Unlock() + k := acStateKey(pluginID, agentConversationOccurrenceScope, workspaceID+"/"+conversationKey, occurrenceKey) + f.claims[k] = true + f.values[k], _ = json.Marshal(map[string]interface{}{ + "status": agentConversationOccurrencePending, + "plugin_id": pluginID, + "workspace_id": workspaceID, + "conversation_key": conversationKey, + }) +} diff --git a/apps/backend/internal/task/service/agent_conversations_test.go b/apps/backend/internal/task/service/agent_conversations_test.go new file mode 100644 index 0000000000..41fef0274b --- /dev/null +++ b/apps/backend/internal/task/service/agent_conversations_test.go @@ -0,0 +1,917 @@ +package service + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/pkg/api/v1" + "github.com/kandev/kandev/pkg/pluginsdk" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// ── fakes ──────────────────────────────────────────────────────────────── + +type acFakeTaskRepo struct { + mu sync.Mutex + tasks []*models.Task + nextIdx int + workspace *models.Workspace +} + +func (f *acFakeTaskRepo) GetWorkspace(_ context.Context, _ string) (*models.Workspace, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.workspace == nil { + profileID := "profile-workspace-default" + return &models.Workspace{ID: "workspace-1", DefaultAgentProfileID: &profileID}, nil + } + return f.workspace, nil +} + +func (f *acFakeTaskRepo) ListTasksByWorkspace(ctx context.Context, workspaceID, _, _, _ string, page, pageSize int, _ string, _, includeEphemeral, onlyEphemeral, _ bool) ([]*models.Task, int, error) { + f.mu.Lock() + defer f.mu.Unlock() + var filtered []*models.Task + for _, t := range f.tasks { + if t.WorkspaceID != workspaceID { + continue + } + if onlyEphemeral && !t.IsEphemeral { + continue + } + if !includeEphemeral && t.IsEphemeral { + continue + } + filtered = append(filtered, t) + } + return filtered, len(filtered), nil +} + +func (f *acFakeTaskRepo) ListEphemeralTasksAllWorkspaces(_ context.Context) ([]*models.Task, error) { + f.mu.Lock() + defer f.mu.Unlock() + var filtered []*models.Task + for _, t := range f.tasks { + if t.IsEphemeral { + filtered = append(filtered, t) + } + } + return filtered, nil +} + +func (f *acFakeTaskRepo) CreateTask(_ context.Context, task *models.Task) error { + f.mu.Lock() + defer f.mu.Unlock() + if task.ID == "" { + task.ID = f.makeID() + } + for _, existing := range f.tasks { + if existing.ID == task.ID { + return errors.New("task already exists") + } + } + f.tasks = append(f.tasks, task) + return nil +} + +func (f *acFakeTaskRepo) UpdateTask(_ context.Context, task *models.Task) error { + f.mu.Lock() + defer f.mu.Unlock() + for i, existing := range f.tasks { + if existing.ID == task.ID { + f.tasks[i] = task + return nil + } + } + return errors.New("task not found") +} + +func (f *acFakeTaskRepo) DeleteTask(_ context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + for i, t := range f.tasks { + if t.ID == id { + f.tasks = append(f.tasks[:i], f.tasks[i+1:]...) + return nil + } + } + return nil +} + +func (f *acFakeTaskRepo) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.tasks) +} + +func (f *acFakeTaskRepo) makeID() string { + f.nextIdx++ + return "task-" + acItoa(f.nextIdx) +} + +func acItoa(n int) string { + if n == 0 { + return "0" + } + var buf [16]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +} + +type acFakeSessionRepo struct { + mu sync.Mutex + sessions map[string]*models.TaskSession + nextIdx int +} + +func newACFakeSessionRepo() *acFakeSessionRepo { + return &acFakeSessionRepo{sessions: make(map[string]*models.TaskSession)} +} + +func (f *acFakeSessionRepo) GetPrimarySessionByTaskID(_ context.Context, taskID string) (*models.TaskSession, error) { + f.mu.Lock() + defer f.mu.Unlock() + for _, s := range f.sessions { + if s.TaskID == taskID && s.IsPrimary { + return s, nil + } + } + return nil, nil +} + +func (f *acFakeSessionRepo) CreateTaskSession(_ context.Context, session *models.TaskSession) error { + f.mu.Lock() + defer f.mu.Unlock() + if session.ID == "" { + f.nextIdx++ + session.ID = "session-" + acItoa(f.nextIdx) + } + if _, exists := f.sessions[session.ID]; exists { + return errors.New("session already exists") + } + f.sessions[session.ID] = session + return nil +} + +func (f *acFakeSessionRepo) UpdateTaskSession(_ context.Context, session *models.TaskSession) error { + f.mu.Lock() + defer f.mu.Unlock() + if _, ok := f.sessions[session.ID]; !ok { + return errors.New("session not found") + } + f.sessions[session.ID] = session + return nil +} + +func (f *acFakeSessionRepo) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.sessions) +} + +func (f *acFakeSessionRepo) setState(id string, state models.TaskSessionState) { + f.mu.Lock() + defer f.mu.Unlock() + if s, ok := f.sessions[id]; ok { + s.State = state + } +} + +// acFakeProfileRepo defaults every profile ID to "found and enabled" so +// tests that don't care about profile validation (most Ensure tests +// predating it) need no setup. Tests exercising the configuration_required +// gate mark specific IDs missing/disabled explicitly. +type acFakeProfileRepo struct { + mu sync.Mutex + missing map[string]bool + disabled map[string]bool + calls []string +} + +func newACFakeProfileRepo() *acFakeProfileRepo { + return &acFakeProfileRepo{missing: map[string]bool{}, disabled: map[string]bool{}} +} + +func (f *acFakeProfileRepo) GetProfile(_ context.Context, profileID string) (AgentConversationProfileInfo, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, profileID) + if f.missing[profileID] { + return AgentConversationProfileInfo{}, false, nil + } + if f.disabled[profileID] { + return AgentConversationProfileInfo{Enabled: false}, true, nil + } + return AgentConversationProfileInfo{Enabled: true}, true, nil +} + +func (f *acFakeProfileRepo) markMissing(id string) { + f.mu.Lock() + defer f.mu.Unlock() + f.missing[id] = true +} + +func (f *acFakeProfileRepo) markDisabled(id string) { + f.mu.Lock() + defer f.mu.Unlock() + f.disabled[id] = true +} + +// acDeliverCall records one call reaching the fake dispatcher — i.e. one +// call that would have reached the real agent runtime in production. +type acDeliverCall struct { + taskID string + sessionID string + text string + source string + idempotencyID string +} + +// acFakeDispatcher stands in for the orchestrator-backed dispatcher. +// Recording calls here (rather than just asserting on a created message row, +// as the pre-fix tests did) is what proves Dispatch reaches the runtime +// delivery seam and not just message persistence. +type acFakeDispatcher struct { + mu sync.Mutex + calls []acDeliverCall + err error +} + +func newACFakeDispatcher() *acFakeDispatcher { + return &acFakeDispatcher{} +} + +func (f *acFakeDispatcher) Deliver(_ context.Context, taskID string, session *models.TaskSession, text, source, idempotencyID string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.err != nil { + return "", f.err + } + f.calls = append(f.calls, acDeliverCall{taskID: taskID, sessionID: session.ID, text: text, source: source, idempotencyID: idempotencyID}) + if session.State == models.TaskSessionStateCreated { + return "started", nil + } + return "sent", nil +} + +func (f *acFakeDispatcher) callCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.calls) +} + +func (f *acFakeDispatcher) lastCall() acDeliverCall { + f.mu.Lock() + defer f.mu.Unlock() + if len(f.calls) == 0 { + return acDeliverCall{} + } + return f.calls[len(f.calls)-1] +} + +type acFakeEventBus struct { + mu sync.Mutex + events []*bus.Event +} + +func (f *acFakeEventBus) Publish(_ context.Context, _ string, event *bus.Event) error { + f.mu.Lock() + defer f.mu.Unlock() + f.events = append(f.events, event) + return nil +} + +func (f *acFakeEventBus) getPublished() []*bus.Event { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]*bus.Event, len(f.events)) + copy(out, f.events) + return out +} + +type acTestDeps struct { + tasks *acFakeTaskRepo + sess *acFakeSessionRepo + profiles *acFakeProfileRepo + state *acFakeStateRepo + dispatcher *acFakeDispatcher + eventer *acFakeEventBus +} + +func newACTestService() (*AgentConversationService, acTestDeps) { + deps := acTestDeps{ + tasks: &acFakeTaskRepo{}, + sess: newACFakeSessionRepo(), + profiles: newACFakeProfileRepo(), + state: newACFakeStateRepo(), + dispatcher: newACFakeDispatcher(), + eventer: &acFakeEventBus{}, + } + svc := NewAgentConversationService(deps.tasks, deps.sess, deps.profiles, deps.state, deps.eventer) + svc.SetDispatcher(deps.dispatcher) + return svc, deps +} + +// ── Ensure tests ──────────────────────────────────────────────────────── + +func TestEnsureCreatesConversation(t *testing.T) { + svc, deps := newACTestService() + + spec := pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", + ConversationKey: "coordinator", + BasePrompt: "You are a coordinator.", + } + + desc, statusStr, err := svc.Ensure(context.Background(), "plugin-coordinator", spec) + if err != nil { + t.Fatalf("Ensure: %v", err) + } + if desc.TaskID == "" { + t.Fatal("expected non-empty TaskID") + } + if desc.SessionID == "" { + t.Fatal("expected non-empty SessionID") + } + if desc.WorkspaceID != "ws-1" { + t.Fatalf("WorkspaceID = %q, want ws-1", desc.WorkspaceID) + } + if desc.ConversationKey != "coordinator" { + t.Fatalf("ConversationKey = %q, want coordinator", desc.ConversationKey) + } + if statusStr != AgentConversationStatusCreated { + t.Fatalf("status = %q, want created", statusStr) + } + + published := deps.eventer.getPublished() + if len(published) != 1 { + t.Fatalf("expected 1 event, got %d", len(published)) + } + if published[0].Type != events.TaskCreated { + t.Fatalf("event type = %q, want %q", published[0].Type, events.TaskCreated) + } +} + +func TestEnsureUsesWorkspaceDefaultProfileWhenPluginOmitsProfile(t *testing.T) { + svc, deps := newACTestService() + profileID := "workspace-profile" + deps.tasks.workspace = &models.Workspace{ID: "ws-1", DefaultAgentProfileID: &profileID} + + desc, gotStatus, err := svc.Ensure(context.Background(), "plugin-coordinator", pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", ConversationKey: "coordinator", + }) + if err != nil { + t.Fatalf("Ensure: %v", err) + } + if gotStatus != AgentConversationStatusCreated { + t.Fatalf("status = %q, want %q", gotStatus, AgentConversationStatusCreated) + } + if desc.AgentProfileID != profileID { + t.Fatalf("descriptor profile = %q, want %q", desc.AgentProfileID, profileID) + } + if deps.tasks.count() != 1 || deps.sess.count() != 1 { + t.Fatalf("created tasks/sessions = %d/%d, want 1/1", deps.tasks.count(), deps.sess.count()) + } + if got := deps.tasks.tasks[0].Metadata[models.MetaKeyAgentProfileID]; got != profileID { + t.Fatalf("task profile metadata = %v, want %q", got, profileID) + } + for _, session := range deps.sess.sessions { + if session.AgentProfileID != profileID { + t.Fatalf("session profile = %q, want %q", session.AgentProfileID, profileID) + } + } +} + +func TestEnsureRejectsMissingWorkspaceDefaultProfileWithoutPartialRows(t *testing.T) { + svc, deps := newACTestService() + missing := "missing-profile" + deps.tasks.workspace = &models.Workspace{ID: "ws-1", DefaultAgentProfileID: &missing} + deps.profiles.markMissing(missing) + + _, gotStatus, err := svc.Ensure(context.Background(), "plugin-coordinator", pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", ConversationKey: "coordinator", + }) + if err != nil { + t.Fatalf("Ensure: %v", err) + } + if gotStatus != AgentConversationStatusConfigurationRequired { + t.Fatalf("status = %q, want %q", gotStatus, AgentConversationStatusConfigurationRequired) + } + if deps.tasks.count() != 0 || deps.sess.count() != 0 { + t.Fatalf("partial rows = tasks:%d sessions:%d, want 0/0", deps.tasks.count(), deps.sess.count()) + } +} + +func TestEnsureRejectsDisabledWorkspaceDefaultProfileWithoutPartialRows(t *testing.T) { + svc, deps := newACTestService() + disabled := "disabled-profile" + deps.tasks.workspace = &models.Workspace{ID: "ws-1", DefaultAgentProfileID: &disabled} + deps.profiles.markDisabled(disabled) + + _, gotStatus, err := svc.Ensure(context.Background(), "plugin-coordinator", pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", ConversationKey: "coordinator", + }) + if err != nil { + t.Fatalf("Ensure: %v", err) + } + if gotStatus != AgentConversationStatusConfigurationRequired { + t.Fatalf("status = %q, want %q", gotStatus, AgentConversationStatusConfigurationRequired) + } + if deps.tasks.count() != 0 || deps.sess.count() != 0 { + t.Fatalf("partial rows = tasks:%d sessions:%d, want 0/0", deps.tasks.count(), deps.sess.count()) + } +} + +func TestEnsureReturnsExistsOnSecondCall(t *testing.T) { + svc, _ := newACTestService() + + spec := pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", + ConversationKey: "coordinator", + } + + desc1, _, err := svc.Ensure(context.Background(), "plugin-coordinator", spec) + if err != nil { + t.Fatalf("first Ensure: %v", err) + } + + desc2, statusStr, err := svc.Ensure(context.Background(), "plugin-coordinator", spec) + if err != nil { + t.Fatalf("second Ensure: %v", err) + } + if statusStr != AgentConversationStatusExists { + t.Fatalf("status = %q, want exists", statusStr) + } + if desc1.TaskID != desc2.TaskID { + t.Fatalf("TaskID changed: %q → %q", desc1.TaskID, desc2.TaskID) + } + if desc1.SessionID != desc2.SessionID { + t.Fatalf("SessionID changed: %q → %q", desc1.SessionID, desc2.SessionID) + } +} + +func TestEnsureRejectsEmptyArguments(t *testing.T) { + svc, _ := newACTestService() + + tests := []struct { + name string + plugin string + spec pluginsdk.AgentConversationSpec + }{ + {"empty plugin id", "", pluginsdk.AgentConversationSpec{WorkspaceID: "ws-1", ConversationKey: "key"}}, + {"empty workspace", "p", pluginsdk.AgentConversationSpec{WorkspaceID: "", ConversationKey: "key"}}, + {"empty key", "p", pluginsdk.AgentConversationSpec{WorkspaceID: "ws-1", ConversationKey: ""}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, err := svc.Ensure(context.Background(), tt.plugin, tt.spec) + if err == nil { + t.Fatal("expected error") + } + if code := status.Code(err); code != codes.InvalidArgument { + t.Fatalf("got code %v, want InvalidArgument", code) + } + }) + } +} + +func TestEnsureRepairsMissingPrimarySession(t *testing.T) { + svc, deps := newACTestService() + + spec := pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", + ConversationKey: "coordinator", + } + + desc, statusStr, err := svc.Ensure(context.Background(), "plugin-coordinator", spec) + if err != nil { + t.Fatalf("first Ensure: %v", err) + } + if statusStr != AgentConversationStatusCreated { + t.Fatalf("status = %q, want created", statusStr) + } + + // Delete the session to simulate a partial creation. + deps.sess.mu.Lock() + delete(deps.sess.sessions, desc.SessionID) + deps.sess.mu.Unlock() + + // Second Ensure should repair the missing session. + desc2, statusStr, err := svc.Ensure(context.Background(), "plugin-coordinator", spec) + if err != nil { + t.Fatalf("second Ensure: %v", err) + } + if statusStr != AgentConversationStatusExists { + t.Fatalf("status = %q, want exists", statusStr) + } + if desc2.TaskID != desc.TaskID { + t.Fatalf("TaskID changed") + } + if desc2.SessionID != desc.SessionID { + t.Fatalf("SessionID = %q, want stable identity %q", desc2.SessionID, desc.SessionID) + } + if desc2.SessionID != conversationPrimarySessionID(desc.TaskID) { + t.Fatalf("SessionID = %q, want deterministic identity for task %q", desc2.SessionID, desc.TaskID) + } + if got := deps.sess.count(); got != 1 { + t.Fatalf("session count after repair = %d, want 1", got) + } +} + +func TestEnsureStoresProfileIDInMetadata(t *testing.T) { + svc, deps := newACTestService() + + spec := pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", + ConversationKey: "coordinator", + AgentProfileID: "profile-gpt4", + } + + desc, statusStr, err := svc.Ensure(context.Background(), "plugin-coordinator", spec) + if err != nil { + t.Fatalf("Ensure: %v", err) + } + if statusStr != AgentConversationStatusCreated { + t.Fatalf("status = %q, want created", statusStr) + } + + if desc.AgentProfileID != "profile-gpt4" { + t.Fatalf("AgentProfileID = %q, want profile-gpt4", desc.AgentProfileID) + } + + task, _, _ := deps.tasks.ListTasksByWorkspace(context.Background(), "ws-1", "", "", "", 1, 100, "", false, true, true, false) + if len(task) == 0 { + t.Fatal("no tasks found") + } + if p, ok := task[0].Metadata[models.MetaKeyAgentProfileID].(string); !ok || p != "profile-gpt4" { + t.Fatalf("metadata agent_profile_id = %v, want profile-gpt4", task[0].Metadata[models.MetaKeyAgentProfileID]) + } +} + +func TestEnsureProfileInDescriptorFromExistingTask(t *testing.T) { + svc, _ := newACTestService() + + spec := pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", + ConversationKey: "coordinator", + AgentProfileID: "profile-gpt4", + } + + desc, _, err := svc.Ensure(context.Background(), "plugin-coordinator", spec) + if err != nil { + t.Fatalf("first Ensure: %v", err) + } + if desc.AgentProfileID != "profile-gpt4" { + t.Fatalf("first AgentProfileID = %q", desc.AgentProfileID) + } + + desc2, statusStr, err := svc.Ensure(context.Background(), "plugin-coordinator", spec) + if err != nil { + t.Fatalf("second Ensure: %v", err) + } + if statusStr != AgentConversationStatusExists { + t.Fatalf("status = %q, want exists", statusStr) + } + if desc2.AgentProfileID != "profile-gpt4" { + t.Fatalf("second AgentProfileID = %q", desc2.AgentProfileID) + } +} + +func TestEnsureCrossPluginSeparation(t *testing.T) { + svc, _ := newACTestService() + + spec := pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", + ConversationKey: "coordinator", + } + + descA, _, err := svc.Ensure(context.Background(), "plugin-a", spec) + if err != nil { + t.Fatalf("plugin-a Ensure: %v", err) + } + + descB, statusStr, err := svc.Ensure(context.Background(), "plugin-b", spec) + if err != nil { + t.Fatalf("plugin-b Ensure: %v", err) + } + if statusStr != AgentConversationStatusCreated { + t.Fatalf("plugin-b status = %q, want created (separate)", statusStr) + } + if descA.TaskID == descB.TaskID { + t.Fatal("plugin-a and plugin-b should have different task IDs") + } +} + +func TestEnsureCrossWorkspaceSeparation(t *testing.T) { + svc, _ := newACTestService() + + desc1, _, err := svc.Ensure(context.Background(), "plugin-c", pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", + ConversationKey: "coordinator", + }) + if err != nil { + t.Fatalf("ws-1 Ensure: %v", err) + } + + desc2, statusStr, err := svc.Ensure(context.Background(), "plugin-c", pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-2", + ConversationKey: "coordinator", + }) + if err != nil { + t.Fatalf("ws-2 Ensure: %v", err) + } + if statusStr != AgentConversationStatusCreated { + t.Fatalf("ws-2 status = %q, want created", statusStr) + } + if desc1.TaskID == desc2.TaskID { + t.Fatal("different workspaces should have different task IDs") + } +} + +func TestConcurrentEnsureIsIdempotent(t *testing.T) { + svc, _ := newACTestService() + + spec := pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", + ConversationKey: "coordinator", + } + + var wg sync.WaitGroup + results := make([]struct { + desc pluginsdk.AgentConversationDescriptor + err error + }, 10) + + for i := range 10 { + wg.Add(1) + go func(idx int) { + defer wg.Done() + desc, _, err := svc.Ensure(context.Background(), "plugin-coordinator", spec) + results[idx] = struct { + desc pluginsdk.AgentConversationDescriptor + err error + }{desc, err} + }(i) + } + wg.Wait() + + taskIDs := make(map[string]int) + for _, r := range results { + if r.err != nil { + t.Fatalf("concurrent Ensure error: %v", r.err) + } + taskIDs[r.desc.TaskID]++ + } + if len(taskIDs) != 1 { + t.Fatalf("expected 1 unique task ID, got %d: %v", len(taskIDs), taskIDs) + } +} + +func TestStoresBasePromptInMetadata(t *testing.T) { + svc, deps := newACTestService() + + spec := pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", + ConversationKey: "coordinator", + BasePrompt: "You are a cycle coordinator.", + } + + _, _, err := svc.Ensure(context.Background(), "plugin-coordinator", spec) + if err != nil { + t.Fatalf("Ensure: %v", err) + } + + task, _, _ := deps.tasks.ListTasksByWorkspace(context.Background(), "ws-1", "", "", "", 1, 100, "", false, true, true, false) + if len(task) == 0 { + t.Fatal("no tasks") + } + if p, ok := task[0].Metadata["kandev.base_prompt"].(string); !ok || p != "You are a cycle coordinator." { + t.Fatalf("base_prompt = %v, want 'You are a cycle coordinator.'", task[0].Metadata["kandev.base_prompt"]) + } +} + +func TestEnsureReconcilesExistingConversationProfileAndBasePrompt(t *testing.T) { + svc, deps := newACTestService() + ctx := context.Background() + + _, _, err := svc.Ensure(ctx, "plugin-coordinator", pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", + ConversationKey: "coordinator", + AgentProfileID: "profile-old", + BasePrompt: "old base prompt", + }) + if err != nil { + t.Fatalf("first Ensure: %v", err) + } + + desc, statusStr, err := svc.Ensure(ctx, "plugin-coordinator", pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", + ConversationKey: "coordinator", + AgentProfileID: "profile-new", + BasePrompt: "new base prompt", + }) + if err != nil { + t.Fatalf("reconcile Ensure: %v", err) + } + if statusStr != AgentConversationStatusExists { + t.Fatalf("status = %q, want exists", statusStr) + } + if desc.AgentProfileID != "profile-new" { + t.Fatalf("descriptor profile = %q, want profile-new", desc.AgentProfileID) + } + + primary, err := deps.sess.GetPrimarySessionByTaskID(ctx, desc.TaskID) + if err != nil { + t.Fatalf("GetPrimarySessionByTaskID: %v", err) + } + if primary.AgentProfileID != "profile-new" { + t.Fatalf("session profile = %q, want profile-new", primary.AgentProfileID) + } + + if _, err := svc.Dispatch(ctx, "plugin-coordinator", "ws-1", "coordinator", "WAKE:CYCLE", ""); err != nil { + t.Fatalf("Dispatch: %v", err) + } + if got := deps.dispatcher.lastCall().text; got != "new base prompt\n\nWAKE:CYCLE" { + t.Fatalf("dispatch text = %q, want refreshed base prompt composed with wake", got) + } +} + +func TestEnsureRejectsProfileChangeForLiveConversationSession(t *testing.T) { + svc, deps := newACTestService() + ctx := context.Background() + desc, _, err := svc.Ensure(ctx, "plugin-coordinator", pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", ConversationKey: "coordinator", AgentProfileID: "profile-old", + }) + if err != nil { + t.Fatalf("first Ensure: %v", err) + } + deps.sess.setState(desc.SessionID, models.TaskSessionStateRunning) + + _, _, err = svc.Ensure(ctx, "plugin-coordinator", pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", ConversationKey: "coordinator", AgentProfileID: "profile-new", + }) + if status.Code(err) != codes.FailedPrecondition { + t.Fatalf("Ensure() error code = %v, want FailedPrecondition (err=%v)", status.Code(err), err) + } + primary, err := deps.sess.GetPrimarySessionByTaskID(ctx, desc.TaskID) + if err != nil { + t.Fatalf("GetPrimarySessionByTaskID: %v", err) + } + if primary.AgentProfileID != "profile-old" { + t.Fatalf("live session profile = %q, want profile-old", primary.AgentProfileID) + } +} + +func TestDispatchRetriesOccurrenceAfterDeliveryFailure(t *testing.T) { + svc, deps := newACTestService() + ctx := context.Background() + if _, _, err := svc.Ensure(ctx, "plugin-coordinator", pluginsdk.AgentConversationSpec{WorkspaceID: "ws-1", ConversationKey: "coordinator"}); err != nil { + t.Fatalf("Ensure: %v", err) + } + + deps.dispatcher.err = errors.New("temporary dispatcher outage") + if _, err := svc.Dispatch(ctx, "plugin-coordinator", "ws-1", "coordinator", "WAKE:CYCLE", "occurrence-1"); err == nil { + t.Fatal("first Dispatch error = nil, want delivery failure") + } + deps.dispatcher.err = nil + + dispatch, err := svc.Dispatch(ctx, "plugin-coordinator", "ws-1", "coordinator", "WAKE:CYCLE", "occurrence-1") + if err != nil { + t.Fatalf("retry Dispatch: %v", err) + } + if dispatch.Status != "started" { + t.Fatalf("retry status = %q, want started", dispatch.Status) + } + if got := deps.dispatcher.callCount(); got != 1 { + t.Fatalf("dispatcher calls = %d, want one successful retry", got) + } + duplicate, err := svc.Dispatch(ctx, "plugin-coordinator", "ws-1", "coordinator", "WAKE:CYCLE", "occurrence-1") + if err != nil { + t.Fatalf("duplicate Dispatch: %v", err) + } + if duplicate.Status != "duplicate_occurrence" { + t.Fatalf("duplicate status = %q, want duplicate_occurrence", duplicate.Status) + } + if got := deps.dispatcher.callCount(); got != 1 { + t.Fatalf("dispatcher calls after duplicate = %d, want one", got) + } +} + +func TestDispatchRecoversPendingOccurrenceAfterRestart(t *testing.T) { + svc, deps := newACTestService() + ctx := context.Background() + if _, _, err := svc.Ensure(ctx, "plugin-coordinator", pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", ConversationKey: "coordinator", + }); err != nil { + t.Fatalf("Ensure: %v", err) + } + + // Simulate a process stopping after the durable claim but before it reaches + // the dispatcher. A pending claim is recoverable; it is not an accepted + // duplicate and it must not require claim expiry. + deps.state.seedPending("plugin-coordinator", "ws-1", "coordinator", "occurrence-restart") + dispatch, err := svc.Dispatch(ctx, "plugin-coordinator", "ws-1", "coordinator", "WAKE:CYCLE", "occurrence-restart") + if err != nil { + t.Fatalf("recovered Dispatch: %v", err) + } + if dispatch.Status != "started" { + t.Fatalf("recovered status = %q, want started", dispatch.Status) + } + if got := deps.dispatcher.callCount(); got != 1 { + t.Fatalf("dispatcher calls = %d, want one recovered delivery", got) + } + raw, found, err := deps.state.Get(ctx, "plugin-coordinator", agentConversationOccurrenceScope, "ws-1/coordinator", "occurrence-restart") + if err != nil || !found { + t.Fatalf("accepted occurrence state = (%v, %v), want a stored value", string(raw), found) + } + if got := occurrenceStatus(raw); got != agentConversationOccurrenceAccepted { + t.Fatalf("occurrence status = %q, want accepted", got) + } +} + +func TestDispatchBusySessionDoesNotConsumeOccurrence(t *testing.T) { + svc, deps := newACTestService() + ctx := context.Background() + desc, _, err := svc.Ensure(ctx, "plugin-coordinator", pluginsdk.AgentConversationSpec{WorkspaceID: "ws-1", ConversationKey: "coordinator"}) + if err != nil { + t.Fatalf("Ensure: %v", err) + } + deps.sess.setState(desc.SessionID, models.TaskSessionStateRunning) + + busy, err := svc.Dispatch(ctx, "plugin-coordinator", "ws-1", "coordinator", "WAKE:CYCLE", "occurrence-1") + if err != nil { + t.Fatalf("busy Dispatch: %v", err) + } + if busy.Status != "skipped_busy" { + t.Fatalf("busy status = %q, want skipped_busy", busy.Status) + } + + deps.sess.setState(desc.SessionID, models.TaskSessionStateIdle) + dispatch, err := svc.Dispatch(ctx, "plugin-coordinator", "ws-1", "coordinator", "WAKE:CYCLE", "occurrence-1") + if err != nil { + t.Fatalf("retry Dispatch: %v", err) + } + if dispatch.Status != "sent" { + t.Fatalf("retry status = %q, want sent", dispatch.Status) + } +} + +func TestDeleteRejectsEmptyOwnershipIdentifiers(t *testing.T) { + svc, _ := newACTestService() + + for _, tc := range []struct { + name, pluginID, workspaceID, conversationKey string + }{ + {name: "plugin", workspaceID: "ws-1", conversationKey: "coordinator"}, + {name: "workspace", pluginID: "plugin-coordinator", conversationKey: "coordinator"}, + {name: "key", pluginID: "plugin-coordinator", workspaceID: "ws-1"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := svc.Delete(context.Background(), tc.pluginID, tc.workspaceID, tc.conversationKey) + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("Delete error code = %v, want InvalidArgument (err=%v)", status.Code(err), err) + } + }) + } +} + +func TestAgentConversationTaskIsCreatedAsEphemeral(t *testing.T) { + svc, deps := newACTestService() + + spec := pluginsdk.AgentConversationSpec{ + WorkspaceID: "ws-1", + ConversationKey: "coordinator", + } + if _, _, err := svc.Ensure(context.Background(), "plugin-coordinator", spec); err != nil { + t.Fatalf("Ensure: %v", err) + } + + all, _, _ := deps.tasks.ListTasksByWorkspace(context.Background(), "ws-1", "", "", "", 1, 100, "", false, true, true, false) + if len(all) != 1 { + t.Fatalf("expected 1 task, got %d", len(all)) + } + if !all[0].IsEphemeral { + t.Fatal("task should be ephemeral") + } + if all[0].Origin != models.TaskOriginManual { + t.Fatalf("origin = %q, want manual", all[0].Origin) + } + if all[0].Priority != "medium" { + t.Fatalf("priority = %q, want medium", all[0].Priority) + } + if all[0].State != v1.TaskStateCreated { + t.Fatalf("state = %v, want CREATED", all[0].State) + } +} diff --git a/apps/backend/internal/task/service/quick_chat_expiration.go b/apps/backend/internal/task/service/quick_chat_expiration.go index fde4c137a1..436dea3347 100644 --- a/apps/backend/internal/task/service/quick_chat_expiration.go +++ b/apps/backend/internal/task/service/quick_chat_expiration.go @@ -46,6 +46,12 @@ func (s *Service) runQuickChatExpiration(ctx context.Context, now time.Time) { s.logger.Info("quick-chat expiration: found candidates", zap.Int("count", len(tasks))) for _, task := range tasks { + // Plugin-managed conversation tasks are excluded from the 7-day quick + // chat sweeper. They are removed only through the plugin lifecycle + // (disable or uninstall). + if IsManagedConversationTask(task) { + continue + } deleted, err := s.deleteExpiredQuickChatTask(ctx, task.ID, cutoff) if err != nil { s.logger.Warn("quick-chat expiration: failed to delete task", diff --git a/apps/backend/internal/task/service/quick_chat_sessions.go b/apps/backend/internal/task/service/quick_chat_sessions.go index 5d0f4e3f88..30f258fc3a 100644 --- a/apps/backend/internal/task/service/quick_chat_sessions.go +++ b/apps/backend/internal/task/service/quick_chat_sessions.go @@ -126,13 +126,14 @@ func (s *Service) listQuickChatTasks(ctx context.Context, workspaceID string) ([ } // IsRestorableQuickChatTask reports whether a task backs a quick-chat tab. -// Workflow-bound and automation-run tasks are ephemeral for other reasons and -// must never surface in the tab strip. +// Workflow-bound, automation-run, and plugin-managed conversation tasks +// are never surfaced in the tab strip. func IsRestorableQuickChatTask(task *models.Task) bool { return task != nil && task.IsEphemeral && task.WorkflowID == "" && - task.Origin != models.TaskOriginAutomationRun + task.Origin != models.TaskOriginAutomationRun && + !IsManagedConversationTask(task) } func quickChatSessionKind(task *models.Task) string { diff --git a/apps/backend/internal/task/service/quick_chat_sessions_test.go b/apps/backend/internal/task/service/quick_chat_sessions_test.go index 75f67e1225..fcaf9cd82b 100644 --- a/apps/backend/internal/task/service/quick_chat_sessions_test.go +++ b/apps/backend/internal/task/service/quick_chat_sessions_test.go @@ -193,6 +193,14 @@ func TestIsRestorableQuickChatTask(t *testing.T) { task: &models.Task{IsEphemeral: true, Origin: models.TaskOriginAutomationRun}, want: false, }, + { + name: "managed conversation", + task: &models.Task{ + IsEphemeral: true, + Metadata: map[string]interface{}{metaKeyPluginID: "plugin-coordinator"}, + }, + want: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/apps/backend/pkg/pluginsdk/data_types.go b/apps/backend/pkg/pluginsdk/data_types.go index b14342124a..684e292792 100644 --- a/apps/backend/pkg/pluginsdk/data_types.go +++ b/apps/backend/pkg/pluginsdk/data_types.go @@ -1349,3 +1349,92 @@ func messageDispatchFromProto(p *pluginv1.SendMessageResponse) *MessageDispatch } return &MessageDispatch{SessionID: p.GetSessionId(), Status: p.GetStatus()} } + +// ── Agent conversations ───────────────────────────────────────────────── + +// AgentConversationSpec controls how Ensure creates a conversation. +type AgentConversationSpec struct { + WorkspaceID string + ConversationKey string + BasePrompt string + AgentProfileID string +} + +func (s AgentConversationSpec) toProto() *pluginv1.AgentConversationSpec { + return &pluginv1.AgentConversationSpec{ + WorkspaceId: s.WorkspaceID, + ConversationKey: s.ConversationKey, + BasePrompt: s.BasePrompt, + AgentProfileId: s.AgentProfileID, + } +} + +func agentConversationSpecFromProto(p *pluginv1.AgentConversationSpec) AgentConversationSpec { + if p == nil { + return AgentConversationSpec{} + } + return AgentConversationSpec{ + WorkspaceID: p.GetWorkspaceId(), + ConversationKey: p.GetConversationKey(), + BasePrompt: p.GetBasePrompt(), + AgentProfileID: p.GetAgentProfileId(), + } +} + +// AgentConversationDescriptor identifies an existing conversation. +type AgentConversationDescriptor struct { + TaskID string + SessionID string + WorkspaceID string + ConversationKey string + AgentProfileID string +} + +func (d AgentConversationDescriptor) toProto() *pluginv1.AgentConversationDescriptor { + return &pluginv1.AgentConversationDescriptor{ + TaskId: d.TaskID, + SessionId: d.SessionID, + WorkspaceId: d.WorkspaceID, + ConversationKey: d.ConversationKey, + AgentProfileId: d.AgentProfileID, + } +} + +func agentConversationDescriptorFromProto(p *pluginv1.AgentConversationDescriptor) AgentConversationDescriptor { + if p == nil { + return AgentConversationDescriptor{} + } + return AgentConversationDescriptor{ + TaskID: p.GetTaskId(), + SessionID: p.GetSessionId(), + WorkspaceID: p.GetWorkspaceId(), + ConversationKey: p.GetConversationKey(), + AgentProfileID: p.GetAgentProfileId(), + } +} + +// AgentConversationDispatch is the outcome of DispatchAgentConversation. +type AgentConversationDispatch struct { + SessionID string + Status string + Descriptor AgentConversationDescriptor +} + +func (d AgentConversationDispatch) toProto() *pluginv1.DispatchAgentConversationResponse { + return &pluginv1.DispatchAgentConversationResponse{ + SessionId: d.SessionID, + Status: d.Status, + ConvDescriptor: d.Descriptor.toProto(), + } +} + +func agentConversationDispatchFromProto(p *pluginv1.DispatchAgentConversationResponse) AgentConversationDispatch { + if p == nil { + return AgentConversationDispatch{} + } + return AgentConversationDispatch{ + SessionID: p.GetSessionId(), + Status: p.GetStatus(), + Descriptor: agentConversationDescriptorFromProto(p.GetConvDescriptor()), + } +} diff --git a/apps/backend/pkg/pluginsdk/host.go b/apps/backend/pkg/pluginsdk/host.go index 80d3b6d417..b6f0aa7c78 100644 --- a/apps/backend/pkg/pluginsdk/host.go +++ b/apps/backend/pkg/pluginsdk/host.go @@ -262,6 +262,51 @@ func PluginOwnedTaskTrees(host Host) (PluginOwnedTaskTreeManager, bool) { return manager.PluginOwnedTaskTrees(), true } +// ── Agent conversation host extension ──────────────────────────────────── + +// AgentConversationHost is an optional Host extension for managing workspace +// agent conversations. It is kept separate from Host so existing host +// implementations remain source-compatible. +type AgentConversationHost interface { + AgentConversations() AgentConversationManager +} + +// AgentConversationManager is the interface for managing workspace agent +// conversations. It lets a plugin create (or find), dispatch to, and +// delete a hidden workflowless ephemeral task/session per +// (plugin_id, workspace_id, conversation_key). +type AgentConversationManager interface { + // Ensure creates or repairs one conversation per (workspace_id, conversation_key). + // Returns the existing descriptor when one already exists for this + // plugin/workspace/key. Returns a typed configuration-required result + // (status="configuration_required") when the referenced agent profile is + // missing, disabled, or incompatible — the conversation is neither created + // nor dispatched until the operator resolves the profile. + Ensure(ctx context.Context, spec AgentConversationSpec) (AgentConversationDescriptor, string, error) + + // Dispatch sends text to an ensured conversation. OccurrenceKey provides + // stable idempotency: a key that was already claimed returns the prior + // dispatch result. Returns status "duplicate_occurrence" when + // occurrence_key matches a previously dispatched occurrence (same session + // for in-flight turns, skipped for busy-session coalesced drops). + // Returns "skipped_busy" when the session is mid-turn and the dispatch + // was coalesced rather than queued. + Dispatch(ctx context.Context, workspaceID, conversationKey, text, occurrenceKey string) (AgentConversationDispatch, error) + + // Delete removes all conversations matching the workspace and key owned + // by this plugin. Returns the count of deleted conversations. + Delete(ctx context.Context, workspaceID, conversationKey string) (int32, error) +} + +// AgentConversations returns the optional agent conversation manager. +func AgentConversations(host Host) (AgentConversationManager, bool) { + manager, ok := host.(AgentConversationHost) + if !ok { + return nil, false + } + return manager.AgentConversations(), true +} + // newHostClient wraps a *grpc.ClientConn (dialed over the go-plugin broker) // as a Go-native Host implementation. func newHostClient(conn *grpc.ClientConn) Host { @@ -280,6 +325,10 @@ func (h *grpcHostClient) PluginOwnedTaskTrees() PluginOwnedTaskTreeManager { return grpcPluginOwnedTaskTreeManager{client: h.client} } +func (h *grpcHostClient) AgentConversations() AgentConversationManager { + return grpcAgentConversationManager{client: h.client} +} + func (h *grpcHostClient) GetState(ctx context.Context, scope, scopeID, key string) (map[string]any, bool, error) { resp, err := h.client.GetState(ctx, &pluginv1.GetStateRequest{Scope: scope, ScopeId: scopeID, Key: key}) if err != nil { @@ -598,6 +647,42 @@ func deletedTaskIDsFromStatus(err error) []string { return nil } +type grpcAgentConversationManager struct { + client pluginv1.HostClient +} + +func (m grpcAgentConversationManager) Ensure(ctx context.Context, spec AgentConversationSpec) (AgentConversationDescriptor, string, error) { + resp, err := m.client.EnsureAgentConversation(ctx, &pluginv1.EnsureAgentConversationRequest{Spec: spec.toProto()}) + if err != nil { + return AgentConversationDescriptor{}, "", err + } + return agentConversationDescriptorFromProto(resp.GetConvDescriptor()), resp.GetStatus(), nil +} + +func (m grpcAgentConversationManager) Dispatch(ctx context.Context, workspaceID, conversationKey, text, occurrenceKey string) (AgentConversationDispatch, error) { + resp, err := m.client.DispatchAgentConversation(ctx, &pluginv1.DispatchAgentConversationRequest{ + WorkspaceId: workspaceID, + ConversationKey: conversationKey, + Text: text, + OccurrenceKey: occurrenceKey, + }) + if err != nil { + return AgentConversationDispatch{}, err + } + return agentConversationDispatchFromProto(resp), nil +} + +func (m grpcAgentConversationManager) Delete(ctx context.Context, workspaceID, conversationKey string) (int32, error) { + resp, err := m.client.DeleteAgentConversation(ctx, &pluginv1.DeleteAgentConversationRequest{ + WorkspaceId: workspaceID, + ConversationKey: conversationKey, + }) + if err != nil { + return 0, err + } + return resp.GetDeletedCount(), nil +} + func (r grpcMessageReader) List(ctx context.Context, filter MessageFilter, page Page) ([]Message, *PageInfo, error) { resp, err := r.client.ListMessages(ctx, &pluginv1.ListMessagesRequest{Filter: filter.toProto(), Page: page.toProto()}) if err != nil { @@ -980,6 +1065,64 @@ func (s *grpcHostServer) DeletePluginOwnedTaskTree(ctx context.Context, req *plu return &pluginv1.DeletePluginOwnedTaskTreeResponse{DeletedTaskIds: deletedTaskIDs}, nil } +// agentConversationManagerFor resolves the impl's agent conversation manager. +// The type assertion alone is not enough: AgentConversationHost is a public +// interface, and an implementation signals "this caller may not use agent +// conversations" (undeclared capability) or "not wired yet" by returning a nil +// manager. Calling through that nil interface panics the whole gRPC server, so +// the adapter answers Unimplemented instead. +func (s *grpcHostServer) agentConversationManagerFor() (AgentConversationManager, error) { + host, ok := s.impl.(AgentConversationHost) + if !ok { + return nil, status.Error(codes.Unimplemented, "agent_conversation capability not implemented on this host") + } + manager := host.AgentConversations() + if manager == nil { + return nil, status.Error(codes.Unimplemented, "agent_conversation capability not implemented on this host") + } + return manager, nil +} + +func (s *grpcHostServer) EnsureAgentConversation(ctx context.Context, req *pluginv1.EnsureAgentConversationRequest) (*pluginv1.EnsureAgentConversationResponse, error) { + manager, err := s.agentConversationManagerFor() + if err != nil { + return nil, err + } + spec := agentConversationSpecFromProto(req.GetSpec()) + descriptor, statusStr, err := manager.Ensure(ctx, spec) + if err != nil { + return nil, err + } + return &pluginv1.EnsureAgentConversationResponse{ + ConvDescriptor: descriptor.toProto(), + Status: statusStr, + }, nil +} + +func (s *grpcHostServer) DispatchAgentConversation(ctx context.Context, req *pluginv1.DispatchAgentConversationRequest) (*pluginv1.DispatchAgentConversationResponse, error) { + manager, err := s.agentConversationManagerFor() + if err != nil { + return nil, err + } + dispatch, err := manager.Dispatch(ctx, req.GetWorkspaceId(), req.GetConversationKey(), req.GetText(), req.GetOccurrenceKey()) + if err != nil { + return nil, err + } + return dispatch.toProto(), nil +} + +func (s *grpcHostServer) DeleteAgentConversation(ctx context.Context, req *pluginv1.DeleteAgentConversationRequest) (*pluginv1.DeleteAgentConversationResponse, error) { + manager, err := s.agentConversationManagerFor() + if err != nil { + return nil, err + } + deletedCount, err := manager.Delete(ctx, req.GetWorkspaceId(), req.GetConversationKey()) + if err != nil { + return nil, err + } + return &pluginv1.DeleteAgentConversationResponse{DeletedCount: deletedCount}, nil +} + var _ pluginv1.HostServer = (*grpcHostServer)(nil) // UnimplementedHostData is an embeddable default for the Host data API @@ -1012,6 +1155,14 @@ func (UnimplementedHostData) PluginOwnedTaskTrees() PluginOwnedTaskTreeManager { return unimplementedPluginOwnedTaskTreeManager{} } +// AgentConversations is the embeddable default for the agent_conversation +// Host extension: a Host that hasn't wired a conversation manager still +// satisfies AgentConversationHost, returning gRPC Unimplemented until +// overridden. +func (UnimplementedHostData) AgentConversations() AgentConversationManager { + return unimplementedAgentConversationManager{} +} + // InvokeUtilityAgent is the embeddable default for the agent_invoke Host // method (ADR 0048). It lives on UnimplementedHostData — the shared // "unimplemented Host extensions" embed both real Host implementations use — @@ -1110,3 +1261,17 @@ func (unimplementedPluginOwnedTaskTreeManager) Preview(context.Context, string) func (unimplementedPluginOwnedTaskTreeManager) Delete(context.Context, string) ([]string, error) { return nil, errUnimplementedHostData("plugin_owned_task_trees") } + +type unimplementedAgentConversationManager struct{} + +func (unimplementedAgentConversationManager) Ensure(context.Context, AgentConversationSpec) (AgentConversationDescriptor, string, error) { + return AgentConversationDescriptor{}, "", errUnimplementedHostData("agent_conversation") +} + +func (unimplementedAgentConversationManager) Dispatch(context.Context, string, string, string, string) (AgentConversationDispatch, error) { + return AgentConversationDispatch{}, errUnimplementedHostData("agent_conversation") +} + +func (unimplementedAgentConversationManager) Delete(context.Context, string, string) (int32, error) { + return 0, errUnimplementedHostData("agent_conversation") +} diff --git a/apps/backend/pkg/pluginsdk/host_agent_conversations_server_test.go b/apps/backend/pkg/pluginsdk/host_agent_conversations_server_test.go new file mode 100644 index 0000000000..34a6aa6cda --- /dev/null +++ b/apps/backend/pkg/pluginsdk/host_agent_conversations_server_test.go @@ -0,0 +1,56 @@ +package pluginsdk + +import ( + "context" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// nilManagerHost implements AgentConversationHost but returns a nil manager, +// which is what any Host does when it decides the caller may not use agent +// conversations (an undeclared capability) or has not wired the service yet. +// The server adapter must answer with a typed error rather than dereferencing +// it: AgentConversationHost is a public interface, so the adapter cannot +// assume every implementation returns a usable manager. +type nilManagerHost struct { + recordingHost +} + +func (h *nilManagerHost) AgentConversations() AgentConversationManager { return nil } + +func TestHost_AgentConversations_NilManagerIsTypedErrorNotPanic(t *testing.T) { + host := dialHostOverBufconn(t, &nilManagerHost{}) + manager, ok := AgentConversations(host) + if !ok { + t.Fatal("expected the client to expose an agent conversation manager") + } + + t.Run("ensure", func(t *testing.T) { + _, _, err := manager.Ensure(context.Background(), AgentConversationSpec{ + WorkspaceID: "ws1", ConversationKey: "coordinator", + }) + assertUnimplemented(t, err) + }) + + t.Run("dispatch", func(t *testing.T) { + _, err := manager.Dispatch(context.Background(), "ws1", "coordinator", "WAKE:CYCLE", "occ-1") + assertUnimplemented(t, err) + }) + + t.Run("delete", func(t *testing.T) { + _, err := manager.Delete(context.Background(), "ws1", "coordinator") + assertUnimplemented(t, err) + }) +} + +func assertUnimplemented(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("expected an error, got nil") + } + if got := status.Code(err); got != codes.Unimplemented { + t.Fatalf("expected Unimplemented, got %s (%v)", got, err) + } +} diff --git a/apps/backend/proto/kandev/plugin/v1/plugin.pb.go b/apps/backend/proto/kandev/plugin/v1/plugin.pb.go index b359e6f2e7..0a6246ccf8 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin.pb.go +++ b/apps/backend/proto/kandev/plugin/v1/plugin.pb.go @@ -7300,6 +7300,491 @@ func (x *DeletePluginOwnedTaskTreeProgress) GetDeletedTaskIds() []string { return nil } +// AgentConversationDescriptor identifies an existing conversation. +type AgentConversationDescriptor struct { + state protoimpl.MessageState `protogen:"open.v1"` + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + WorkspaceId string `protobuf:"bytes,3,opt,name=workspace_id,json=workspaceId,proto3" json:"workspace_id,omitempty"` + ConversationKey string `protobuf:"bytes,4,opt,name=conversation_key,json=conversationKey,proto3" json:"conversation_key,omitempty"` + // The agent_profile_id the conversation uses; empty when the profile + // referenced at ensure time is no longer available. + AgentProfileId string `protobuf:"bytes,5,opt,name=agent_profile_id,json=agentProfileId,proto3" json:"agent_profile_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentConversationDescriptor) Reset() { + *x = AgentConversationDescriptor{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[113] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentConversationDescriptor) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentConversationDescriptor) ProtoMessage() {} + +func (x *AgentConversationDescriptor) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[113] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentConversationDescriptor.ProtoReflect.Descriptor instead. +func (*AgentConversationDescriptor) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{113} +} + +func (x *AgentConversationDescriptor) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *AgentConversationDescriptor) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *AgentConversationDescriptor) GetWorkspaceId() string { + if x != nil { + return x.WorkspaceId + } + return "" +} + +func (x *AgentConversationDescriptor) GetConversationKey() string { + if x != nil { + return x.ConversationKey + } + return "" +} + +func (x *AgentConversationDescriptor) GetAgentProfileId() string { + if x != nil { + return x.AgentProfileId + } + return "" +} + +// AgentConversationSpec controls how Ensure creates a conversation. +type AgentConversationSpec struct { + state protoimpl.MessageState `protogen:"open.v1"` + WorkspaceId string `protobuf:"bytes,1,opt,name=workspace_id,json=workspaceId,proto3" json:"workspace_id,omitempty"` + ConversationKey string `protobuf:"bytes,2,opt,name=conversation_key,json=conversationKey,proto3" json:"conversation_key,omitempty"` + // Editable base prompt baked into every dispatch to this conversation. + // The plugin appends its cycle/report-specific instructions. + BasePrompt string `protobuf:"bytes,3,opt,name=base_prompt,json=basePrompt,proto3" json:"base_prompt,omitempty"` + // Agent profile id to use. Empty means "use the workspace default". + // An explicit profile that is disabled/deleted produces a typed + // configuration-required result. + AgentProfileId string `protobuf:"bytes,4,opt,name=agent_profile_id,json=agentProfileId,proto3" json:"agent_profile_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentConversationSpec) Reset() { + *x = AgentConversationSpec{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[114] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentConversationSpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentConversationSpec) ProtoMessage() {} + +func (x *AgentConversationSpec) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[114] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentConversationSpec.ProtoReflect.Descriptor instead. +func (*AgentConversationSpec) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{114} +} + +func (x *AgentConversationSpec) GetWorkspaceId() string { + if x != nil { + return x.WorkspaceId + } + return "" +} + +func (x *AgentConversationSpec) GetConversationKey() string { + if x != nil { + return x.ConversationKey + } + return "" +} + +func (x *AgentConversationSpec) GetBasePrompt() string { + if x != nil { + return x.BasePrompt + } + return "" +} + +func (x *AgentConversationSpec) GetAgentProfileId() string { + if x != nil { + return x.AgentProfileId + } + return "" +} + +// EnsureAgentConversationRequest carries the Ensure request. +type EnsureAgentConversationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Spec *AgentConversationSpec `protobuf:"bytes,1,opt,name=spec,proto3" json:"spec,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EnsureAgentConversationRequest) Reset() { + *x = EnsureAgentConversationRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[115] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EnsureAgentConversationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EnsureAgentConversationRequest) ProtoMessage() {} + +func (x *EnsureAgentConversationRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[115] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EnsureAgentConversationRequest.ProtoReflect.Descriptor instead. +func (*EnsureAgentConversationRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{115} +} + +func (x *EnsureAgentConversationRequest) GetSpec() *AgentConversationSpec { + if x != nil { + return x.Spec + } + return nil +} + +// EnsureAgentConversationResponse carries the result. +type EnsureAgentConversationResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConvDescriptor *AgentConversationDescriptor `protobuf:"bytes,1,opt,name=conv_descriptor,json=convDescriptor,proto3" json:"conv_descriptor,omitempty"` + // One of "created", "exists", or "configuration_required". + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EnsureAgentConversationResponse) Reset() { + *x = EnsureAgentConversationResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[116] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EnsureAgentConversationResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EnsureAgentConversationResponse) ProtoMessage() {} + +func (x *EnsureAgentConversationResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[116] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EnsureAgentConversationResponse.ProtoReflect.Descriptor instead. +func (*EnsureAgentConversationResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{116} +} + +func (x *EnsureAgentConversationResponse) GetConvDescriptor() *AgentConversationDescriptor { + if x != nil { + return x.ConvDescriptor + } + return nil +} + +func (x *EnsureAgentConversationResponse) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +// DispatchAgentConversationRequest sends a prompt to an ensured conversation. +type DispatchAgentConversationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + WorkspaceId string `protobuf:"bytes,1,opt,name=workspace_id,json=workspaceId,proto3" json:"workspace_id,omitempty"` + ConversationKey string `protobuf:"bytes,2,opt,name=conversation_key,json=conversationKey,proto3" json:"conversation_key,omitempty"` + Text string `protobuf:"bytes,3,opt,name=text,proto3" json:"text,omitempty"` + // Stable idempotency key derived from (trigger, due_timestamp) for + // scheduled occurrences, or a unique key for manual invocations. + // An already-claimed key returns the prior dispatch result. + OccurrenceKey string `protobuf:"bytes,4,opt,name=occurrence_key,json=occurrenceKey,proto3" json:"occurrence_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DispatchAgentConversationRequest) Reset() { + *x = DispatchAgentConversationRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[117] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DispatchAgentConversationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DispatchAgentConversationRequest) ProtoMessage() {} + +func (x *DispatchAgentConversationRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[117] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DispatchAgentConversationRequest.ProtoReflect.Descriptor instead. +func (*DispatchAgentConversationRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{117} +} + +func (x *DispatchAgentConversationRequest) GetWorkspaceId() string { + if x != nil { + return x.WorkspaceId + } + return "" +} + +func (x *DispatchAgentConversationRequest) GetConversationKey() string { + if x != nil { + return x.ConversationKey + } + return "" +} + +func (x *DispatchAgentConversationRequest) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +func (x *DispatchAgentConversationRequest) GetOccurrenceKey() string { + if x != nil { + return x.OccurrenceKey + } + return "" +} + +// DispatchAgentConversationResponse carries the dispatch result. +type DispatchAgentConversationResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // One of "started", "sent", "duplicate_occurrence", or "skipped_busy". + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + ConvDescriptor *AgentConversationDescriptor `protobuf:"bytes,3,opt,name=conv_descriptor,json=convDescriptor,proto3" json:"conv_descriptor,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DispatchAgentConversationResponse) Reset() { + *x = DispatchAgentConversationResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[118] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DispatchAgentConversationResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DispatchAgentConversationResponse) ProtoMessage() {} + +func (x *DispatchAgentConversationResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[118] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DispatchAgentConversationResponse.ProtoReflect.Descriptor instead. +func (*DispatchAgentConversationResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{118} +} + +func (x *DispatchAgentConversationResponse) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *DispatchAgentConversationResponse) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *DispatchAgentConversationResponse) GetConvDescriptor() *AgentConversationDescriptor { + if x != nil { + return x.ConvDescriptor + } + return nil +} + +// DeleteAgentConversationRequest deletes all conversations matching the +// request. Only conversations owned by this plugin are affected. +type DeleteAgentConversationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + WorkspaceId string `protobuf:"bytes,1,opt,name=workspace_id,json=workspaceId,proto3" json:"workspace_id,omitempty"` + ConversationKey string `protobuf:"bytes,2,opt,name=conversation_key,json=conversationKey,proto3" json:"conversation_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteAgentConversationRequest) Reset() { + *x = DeleteAgentConversationRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[119] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteAgentConversationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteAgentConversationRequest) ProtoMessage() {} + +func (x *DeleteAgentConversationRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[119] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteAgentConversationRequest.ProtoReflect.Descriptor instead. +func (*DeleteAgentConversationRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{119} +} + +func (x *DeleteAgentConversationRequest) GetWorkspaceId() string { + if x != nil { + return x.WorkspaceId + } + return "" +} + +func (x *DeleteAgentConversationRequest) GetConversationKey() string { + if x != nil { + return x.ConversationKey + } + return "" +} + +// DeleteAgentConversationResponse carries the result. +type DeleteAgentConversationResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + DeletedCount int32 `protobuf:"varint,1,opt,name=deleted_count,json=deletedCount,proto3" json:"deleted_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteAgentConversationResponse) Reset() { + *x = DeleteAgentConversationResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[120] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteAgentConversationResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteAgentConversationResponse) ProtoMessage() {} + +func (x *DeleteAgentConversationResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[120] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteAgentConversationResponse.ProtoReflect.Descriptor instead. +func (*DeleteAgentConversationResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{120} +} + +func (x *DeleteAgentConversationResponse) GetDeletedCount() int32 { + if x != nil { + return x.DeletedCount + } + return 0 +} + // Configuration and connection eligibility are always resolved within the host's workspace. type AutomationConditionRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -7312,7 +7797,7 @@ type AutomationConditionRequest struct { func (x *AutomationConditionRequest) Reset() { *x = AutomationConditionRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[113] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[121] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7324,7 +7809,7 @@ func (x *AutomationConditionRequest) String() string { func (*AutomationConditionRequest) ProtoMessage() {} func (x *AutomationConditionRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[113] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[121] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7337,7 +7822,7 @@ func (x *AutomationConditionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AutomationConditionRequest.ProtoReflect.Descriptor instead. func (*AutomationConditionRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{113} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{121} } func (x *AutomationConditionRequest) GetWorkspaceId() string { @@ -7375,7 +7860,7 @@ type AutomationConditionResponse struct { func (x *AutomationConditionResponse) Reset() { *x = AutomationConditionResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[114] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[122] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7387,7 +7872,7 @@ func (x *AutomationConditionResponse) String() string { func (*AutomationConditionResponse) ProtoMessage() {} func (x *AutomationConditionResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[114] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[122] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7400,7 +7885,7 @@ func (x *AutomationConditionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AutomationConditionResponse.ProtoReflect.Descriptor instead. func (*AutomationConditionResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{114} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{122} } func (x *AutomationConditionResponse) GetAvailable() bool { @@ -7454,7 +7939,7 @@ type AutomationWebhookRequest struct { func (x *AutomationWebhookRequest) Reset() { *x = AutomationWebhookRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[115] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[123] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7466,7 +7951,7 @@ func (x *AutomationWebhookRequest) String() string { func (*AutomationWebhookRequest) ProtoMessage() {} func (x *AutomationWebhookRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[115] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[123] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7479,7 +7964,7 @@ func (x *AutomationWebhookRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AutomationWebhookRequest.ProtoReflect.Descriptor instead. func (*AutomationWebhookRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{115} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{123} } func (x *AutomationWebhookRequest) GetWorkspaceId() string { @@ -7550,7 +8035,7 @@ type AutomationWebhookResponse struct { func (x *AutomationWebhookResponse) Reset() { *x = AutomationWebhookResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[116] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[124] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7562,7 +8047,7 @@ func (x *AutomationWebhookResponse) String() string { func (*AutomationWebhookResponse) ProtoMessage() {} func (x *AutomationWebhookResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[116] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[124] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7575,7 +8060,7 @@ func (x *AutomationWebhookResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AutomationWebhookResponse.ProtoReflect.Descriptor instead. func (*AutomationWebhookResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{116} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{124} } func (x *AutomationWebhookResponse) GetOutcome() string { @@ -8237,7 +8722,40 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" + "!DeletePluginOwnedTaskTreeResponse\x12(\n" + "\x10deleted_task_ids\x18\x01 \x03(\tR\x0edeletedTaskIds\"M\n" + "!DeletePluginOwnedTaskTreeProgress\x12(\n" + - "\x10deleted_task_ids\x18\x01 \x03(\tR\x0edeletedTaskIds\"|\n" + + "\x10deleted_task_ids\x18\x01 \x03(\tR\x0edeletedTaskIds\"\xcd\x01\n" + + "\x1bAgentConversationDescriptor\x12\x17\n" + + "\atask_id\x18\x01 \x01(\tR\x06taskId\x12\x1d\n" + + "\n" + + "session_id\x18\x02 \x01(\tR\tsessionId\x12!\n" + + "\fworkspace_id\x18\x03 \x01(\tR\vworkspaceId\x12)\n" + + "\x10conversation_key\x18\x04 \x01(\tR\x0fconversationKey\x12(\n" + + "\x10agent_profile_id\x18\x05 \x01(\tR\x0eagentProfileId\"\xb0\x01\n" + + "\x15AgentConversationSpec\x12!\n" + + "\fworkspace_id\x18\x01 \x01(\tR\vworkspaceId\x12)\n" + + "\x10conversation_key\x18\x02 \x01(\tR\x0fconversationKey\x12\x1f\n" + + "\vbase_prompt\x18\x03 \x01(\tR\n" + + "basePrompt\x12(\n" + + "\x10agent_profile_id\x18\x04 \x01(\tR\x0eagentProfileId\"]\n" + + "\x1eEnsureAgentConversationRequest\x12;\n" + + "\x04spec\x18\x01 \x01(\v2'.kandev.plugin.v1.AgentConversationSpecR\x04spec\"\x91\x01\n" + + "\x1fEnsureAgentConversationResponse\x12V\n" + + "\x0fconv_descriptor\x18\x01 \x01(\v2-.kandev.plugin.v1.AgentConversationDescriptorR\x0econvDescriptor\x12\x16\n" + + "\x06status\x18\x02 \x01(\tR\x06status\"\xab\x01\n" + + " DispatchAgentConversationRequest\x12!\n" + + "\fworkspace_id\x18\x01 \x01(\tR\vworkspaceId\x12)\n" + + "\x10conversation_key\x18\x02 \x01(\tR\x0fconversationKey\x12\x12\n" + + "\x04text\x18\x03 \x01(\tR\x04text\x12%\n" + + "\x0eoccurrence_key\x18\x04 \x01(\tR\roccurrenceKey\"\xb2\x01\n" + + "!DispatchAgentConversationResponse\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x16\n" + + "\x06status\x18\x02 \x01(\tR\x06status\x12V\n" + + "\x0fconv_descriptor\x18\x03 \x01(\v2-.kandev.plugin.v1.AgentConversationDescriptorR\x0econvDescriptor\"n\n" + + "\x1eDeleteAgentConversationRequest\x12!\n" + + "\fworkspace_id\x18\x01 \x01(\tR\vworkspaceId\x12)\n" + + "\x10conversation_key\x18\x02 \x01(\tR\x0fconversationKey\"F\n" + + "\x1fDeleteAgentConversationResponse\x12#\n" + + "\rdeleted_count\x18\x01 \x01(\x05R\fdeletedCount\"|\n" + "\x1aAutomationConditionRequest\x12!\n" + "\fworkspace_id\x18\x01 \x01(\tR\vworkspaceId\x12#\n" + "\rcondition_key\x18\x02 \x01(\tR\fconditionKey\x12\x16\n" + @@ -8275,7 +8793,7 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" + "\x18AuthorizeEntityReference\x121.kandev.plugin.v1.AuthorizeEntityReferenceRequest\x1a2.kandev.plugin.v1.AuthorizeEntityReferenceResponse\x12u\n" + "\x14ResolveGitCredential\x12-.kandev.plugin.v1.ResolveGitCredentialRequest\x1a..kandev.plugin.v1.ResolveGitCredentialResponse\x12x\n" + "\x17GetGitCredentialBinding\x12-.kandev.plugin.v1.GitCredentialBindingRequest\x1a..kandev.plugin.v1.GitCredentialBindingResponse\x12Z\n" + - "\x0fInvokeAgentTool\x12\".kandev.plugin.v1.AgentToolRequest\x1a#.kandev.plugin.v1.AgentToolResponse2\x80\x1b\n" + + "\x0fInvokeAgentTool\x12\".kandev.plugin.v1.AgentToolRequest\x1a#.kandev.plugin.v1.AgentToolResponse2\x87\x1e\n" + "\x04Host\x12Q\n" + "\bGetState\x12!.kandev.plugin.v1.GetStateRequest\x1a\".kandev.plugin.v1.GetStateResponse\x12Q\n" + "\bSetState\x12!.kandev.plugin.v1.SetStateRequest\x1a\".kandev.plugin.v1.SetStateResponse\x12Z\n" + @@ -8309,7 +8827,10 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" + "\bMoveTask\x12!.kandev.plugin.v1.MoveTaskRequest\x1a\".kandev.plugin.v1.MoveTaskResponse\x12Z\n" + "\vSendMessage\x12$.kandev.plugin.v1.SendMessageRequest\x1a%.kandev.plugin.v1.SendMessageResponse\x12\x87\x01\n" + "\x1aPreviewPluginOwnedTaskTree\x123.kandev.plugin.v1.PreviewPluginOwnedTaskTreeRequest\x1a4.kandev.plugin.v1.PreviewPluginOwnedTaskTreeResponse\x12\x84\x01\n" + - "\x19DeletePluginOwnedTaskTree\x122.kandev.plugin.v1.DeletePluginOwnedTaskTreeRequest\x1a3.kandev.plugin.v1.DeletePluginOwnedTaskTreeResponse\x12r\n" + + "\x19DeletePluginOwnedTaskTree\x122.kandev.plugin.v1.DeletePluginOwnedTaskTreeRequest\x1a3.kandev.plugin.v1.DeletePluginOwnedTaskTreeResponse\x12~\n" + + "\x17EnsureAgentConversation\x120.kandev.plugin.v1.EnsureAgentConversationRequest\x1a1.kandev.plugin.v1.EnsureAgentConversationResponse\x12\x84\x01\n" + + "\x19DispatchAgentConversation\x122.kandev.plugin.v1.DispatchAgentConversationRequest\x1a3.kandev.plugin.v1.DispatchAgentConversationResponse\x12~\n" + + "\x17DeleteAgentConversation\x120.kandev.plugin.v1.DeleteAgentConversationRequest\x1a1.kandev.plugin.v1.DeleteAgentConversationResponse\x12r\n" + "\x13RespondToPermission\x12,.kandev.plugin.v1.RespondToPermissionRequest\x1a-.kandev.plugin.v1.RespondToPermissionResponse\x12r\n" + "\x13AnswerClarification\x12,.kandev.plugin.v1.AnswerClarificationRequest\x1a-.kandev.plugin.v1.AnswerClarificationResponse\x12r\n" + "\x13CancelClarification\x12,.kandev.plugin.v1.CancelClarificationRequest\x1a-.kandev.plugin.v1.CancelClarificationResponseB:Z8github.com/kandev/kandev/proto/kandev/plugin/v1;pluginv1b\x06proto3" @@ -8326,7 +8847,7 @@ func file_kandev_plugin_v1_plugin_proto_rawDescGZIP() []byte { return file_kandev_plugin_v1_plugin_proto_rawDescData } -var file_kandev_plugin_v1_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 121) +var file_kandev_plugin_v1_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 129) var file_kandev_plugin_v1_plugin_proto_goTypes = []any{ (*Event)(nil), // 0: kandev.plugin.v1.Event (*EventAck)(nil), // 1: kandev.plugin.v1.EventAck @@ -8441,36 +8962,44 @@ var file_kandev_plugin_v1_plugin_proto_goTypes = []any{ (*DeletePluginOwnedTaskTreeRequest)(nil), // 110: kandev.plugin.v1.DeletePluginOwnedTaskTreeRequest (*DeletePluginOwnedTaskTreeResponse)(nil), // 111: kandev.plugin.v1.DeletePluginOwnedTaskTreeResponse (*DeletePluginOwnedTaskTreeProgress)(nil), // 112: kandev.plugin.v1.DeletePluginOwnedTaskTreeProgress - (*AutomationConditionRequest)(nil), // 113: kandev.plugin.v1.AutomationConditionRequest - (*AutomationConditionResponse)(nil), // 114: kandev.plugin.v1.AutomationConditionResponse - (*AutomationWebhookRequest)(nil), // 115: kandev.plugin.v1.AutomationWebhookRequest - (*AutomationWebhookResponse)(nil), // 116: kandev.plugin.v1.AutomationWebhookResponse - nil, // 117: kandev.plugin.v1.WebhookRequest.HeadersEntry - nil, // 118: kandev.plugin.v1.WebhookResponse.HeadersEntry - nil, // 119: kandev.plugin.v1.PluginActionResponse.HeadersEntry - nil, // 120: kandev.plugin.v1.AutomationWebhookRequest.HeadersEntry - (*structpb.Struct)(nil), // 121: google.protobuf.Struct + (*AgentConversationDescriptor)(nil), // 113: kandev.plugin.v1.AgentConversationDescriptor + (*AgentConversationSpec)(nil), // 114: kandev.plugin.v1.AgentConversationSpec + (*EnsureAgentConversationRequest)(nil), // 115: kandev.plugin.v1.EnsureAgentConversationRequest + (*EnsureAgentConversationResponse)(nil), // 116: kandev.plugin.v1.EnsureAgentConversationResponse + (*DispatchAgentConversationRequest)(nil), // 117: kandev.plugin.v1.DispatchAgentConversationRequest + (*DispatchAgentConversationResponse)(nil), // 118: kandev.plugin.v1.DispatchAgentConversationResponse + (*DeleteAgentConversationRequest)(nil), // 119: kandev.plugin.v1.DeleteAgentConversationRequest + (*DeleteAgentConversationResponse)(nil), // 120: kandev.plugin.v1.DeleteAgentConversationResponse + (*AutomationConditionRequest)(nil), // 121: kandev.plugin.v1.AutomationConditionRequest + (*AutomationConditionResponse)(nil), // 122: kandev.plugin.v1.AutomationConditionResponse + (*AutomationWebhookRequest)(nil), // 123: kandev.plugin.v1.AutomationWebhookRequest + (*AutomationWebhookResponse)(nil), // 124: kandev.plugin.v1.AutomationWebhookResponse + nil, // 125: kandev.plugin.v1.WebhookRequest.HeadersEntry + nil, // 126: kandev.plugin.v1.WebhookResponse.HeadersEntry + nil, // 127: kandev.plugin.v1.PluginActionResponse.HeadersEntry + nil, // 128: kandev.plugin.v1.AutomationWebhookRequest.HeadersEntry + (*structpb.Struct)(nil), // 129: google.protobuf.Struct } var file_kandev_plugin_v1_plugin_proto_depIdxs = []int32{ - 121, // 0: kandev.plugin.v1.Event.payload:type_name -> google.protobuf.Struct - 121, // 1: kandev.plugin.v1.AgentToolRequest.arguments:type_name -> google.protobuf.Struct + 129, // 0: kandev.plugin.v1.Event.payload:type_name -> google.protobuf.Struct + 129, // 1: kandev.plugin.v1.AgentToolRequest.arguments:type_name -> google.protobuf.Struct 3, // 2: kandev.plugin.v1.AgentToolRequest.context:type_name -> kandev.plugin.v1.AgentToolContext - 121, // 3: kandev.plugin.v1.AgentToolResponse.structured_content:type_name -> google.protobuf.Struct - 117, // 4: kandev.plugin.v1.WebhookRequest.headers:type_name -> kandev.plugin.v1.WebhookRequest.HeadersEntry - 118, // 5: kandev.plugin.v1.WebhookResponse.headers:type_name -> kandev.plugin.v1.WebhookResponse.HeadersEntry + 129, // 3: kandev.plugin.v1.AgentToolResponse.structured_content:type_name -> google.protobuf.Struct + 125, // 4: kandev.plugin.v1.WebhookRequest.headers:type_name -> kandev.plugin.v1.WebhookRequest.HeadersEntry + 126, // 5: kandev.plugin.v1.WebhookResponse.headers:type_name -> kandev.plugin.v1.WebhookResponse.HeadersEntry 8, // 6: kandev.plugin.v1.PluginActionRequest.context:type_name -> kandev.plugin.v1.VerifiedActionContext - 119, // 7: kandev.plugin.v1.PluginActionResponse.headers:type_name -> kandev.plugin.v1.PluginActionResponse.HeadersEntry + 127, // 7: kandev.plugin.v1.PluginActionResponse.headers:type_name -> kandev.plugin.v1.PluginActionResponse.HeadersEntry 12, // 8: kandev.plugin.v1.SearchEntityReferencesResponse.candidates:type_name -> kandev.plugin.v1.EntityReferenceCandidate - 121, // 9: kandev.plugin.v1.EntityReferenceCandidate.attributes:type_name -> google.protobuf.Struct - 121, // 10: kandev.plugin.v1.AuthorizeEntityReferenceRequest.reference:type_name -> google.protobuf.Struct - 121, // 11: kandev.plugin.v1.GetStateResponse.value:type_name -> google.protobuf.Struct - 121, // 12: kandev.plugin.v1.SetStateRequest.value:type_name -> google.protobuf.Struct + 129, // 9: kandev.plugin.v1.EntityReferenceCandidate.attributes:type_name -> google.protobuf.Struct + 129, // 10: kandev.plugin.v1.AuthorizeEntityReferenceRequest.reference:type_name -> google.protobuf.Struct + 129, // 11: kandev.plugin.v1.GetStateResponse.value:type_name -> google.protobuf.Struct + 129, // 12: kandev.plugin.v1.SetStateRequest.value:type_name -> google.protobuf.Struct 27, // 13: kandev.plugin.v1.ListStateResponse.entries:type_name -> kandev.plugin.v1.StateEntry - 121, // 14: kandev.plugin.v1.StateEntry.value:type_name -> google.protobuf.Struct - 121, // 15: kandev.plugin.v1.GetConfigResponse.config:type_name -> google.protobuf.Struct - 121, // 16: kandev.plugin.v1.EmitEventRequest.payload:type_name -> google.protobuf.Struct + 129, // 14: kandev.plugin.v1.StateEntry.value:type_name -> google.protobuf.Struct + 129, // 15: kandev.plugin.v1.GetConfigResponse.config:type_name -> google.protobuf.Struct + 129, // 16: kandev.plugin.v1.EmitEventRequest.payload:type_name -> google.protobuf.Struct 43, // 17: kandev.plugin.v1.Task.repositories:type_name -> kandev.plugin.v1.TaskRepository - 121, // 18: kandev.plugin.v1.Task.metadata:type_name -> google.protobuf.Struct + 129, // 18: kandev.plugin.v1.Task.metadata:type_name -> google.protobuf.Struct 44, // 19: kandev.plugin.v1.Task.pull_requests:type_name -> kandev.plugin.v1.TaskPullRequest 45, // 20: kandev.plugin.v1.ListTasksRequest.filter:type_name -> kandev.plugin.v1.TaskFilter 40, // 21: kandev.plugin.v1.ListTasksRequest.page:type_name -> kandev.plugin.v1.Page @@ -8519,106 +9048,115 @@ var file_kandev_plugin_v1_plugin_proto_depIdxs = []int32{ 81, // 64: kandev.plugin.v1.CancelClarificationResponse.interaction:type_name -> kandev.plugin.v1.Interaction 98, // 65: kandev.plugin.v1.CreateTaskRequest.repositories:type_name -> kandev.plugin.v1.PluginTaskRepository 100, // 66: kandev.plugin.v1.CreateTaskRequest.launch:type_name -> kandev.plugin.v1.PluginTaskLaunchOptions - 121, // 67: kandev.plugin.v1.CreateTaskRequest.metadata:type_name -> google.protobuf.Struct + 129, // 67: kandev.plugin.v1.CreateTaskRequest.metadata:type_name -> google.protobuf.Struct 99, // 68: kandev.plugin.v1.PluginTaskRepository.remote:type_name -> kandev.plugin.v1.RemoteRepositoryDescriptor 42, // 69: kandev.plugin.v1.CreateTaskResponse.task:type_name -> kandev.plugin.v1.Task 42, // 70: kandev.plugin.v1.UpdateTaskResponse.task:type_name -> kandev.plugin.v1.Task 42, // 71: kandev.plugin.v1.MoveTaskResponse.task:type_name -> kandev.plugin.v1.Task 42, // 72: kandev.plugin.v1.PreviewPluginOwnedTaskTreeResponse.tasks:type_name -> kandev.plugin.v1.Task - 120, // 73: kandev.plugin.v1.AutomationWebhookRequest.headers:type_name -> kandev.plugin.v1.AutomationWebhookRequest.HeadersEntry - 0, // 74: kandev.plugin.v1.Plugin.DeliverEvent:input_type -> kandev.plugin.v1.Event - 5, // 75: kandev.plugin.v1.Plugin.HandleWebhook:input_type -> kandev.plugin.v1.WebhookRequest - 113, // 76: kandev.plugin.v1.Plugin.DescribeAutomationCondition:input_type -> kandev.plugin.v1.AutomationConditionRequest - 115, // 77: kandev.plugin.v1.Plugin.VerifyAutomationWebhook:input_type -> kandev.plugin.v1.AutomationWebhookRequest - 7, // 78: kandev.plugin.v1.Plugin.HandleAction:input_type -> kandev.plugin.v1.PluginActionRequest - 10, // 79: kandev.plugin.v1.Plugin.SearchEntityReferences:input_type -> kandev.plugin.v1.SearchEntityReferencesRequest - 13, // 80: kandev.plugin.v1.Plugin.AuthorizeEntityReference:input_type -> kandev.plugin.v1.AuthorizeEntityReferenceRequest - 15, // 81: kandev.plugin.v1.Plugin.ResolveGitCredential:input_type -> kandev.plugin.v1.ResolveGitCredentialRequest - 17, // 82: kandev.plugin.v1.Plugin.GetGitCredentialBinding:input_type -> kandev.plugin.v1.GitCredentialBindingRequest - 2, // 83: kandev.plugin.v1.Plugin.InvokeAgentTool:input_type -> kandev.plugin.v1.AgentToolRequest - 19, // 84: kandev.plugin.v1.Host.GetState:input_type -> kandev.plugin.v1.GetStateRequest - 21, // 85: kandev.plugin.v1.Host.SetState:input_type -> kandev.plugin.v1.SetStateRequest - 23, // 86: kandev.plugin.v1.Host.DeleteState:input_type -> kandev.plugin.v1.DeleteStateRequest - 25, // 87: kandev.plugin.v1.Host.ListState:input_type -> kandev.plugin.v1.ListStateRequest - 36, // 88: kandev.plugin.v1.Host.RevealSecret:input_type -> kandev.plugin.v1.RevealSecretRequest - 38, // 89: kandev.plugin.v1.Host.EmitEvent:input_type -> kandev.plugin.v1.EmitEventRequest - 30, // 90: kandev.plugin.v1.Host.GetSecret:input_type -> kandev.plugin.v1.GetSecretRequest - 32, // 91: kandev.plugin.v1.Host.SetSecret:input_type -> kandev.plugin.v1.SetSecretRequest - 34, // 92: kandev.plugin.v1.Host.DeleteSecret:input_type -> kandev.plugin.v1.DeleteSecretRequest - 28, // 93: kandev.plugin.v1.Host.GetConfig:input_type -> kandev.plugin.v1.GetConfigRequest - 46, // 94: kandev.plugin.v1.Host.ListTasks:input_type -> kandev.plugin.v1.ListTasksRequest - 48, // 95: kandev.plugin.v1.Host.GetTask:input_type -> kandev.plugin.v1.GetTaskRequest - 51, // 96: kandev.plugin.v1.Host.ListWorkspaces:input_type -> kandev.plugin.v1.ListWorkspacesRequest - 54, // 97: kandev.plugin.v1.Host.ListWorkflows:input_type -> kandev.plugin.v1.ListWorkflowsRequest - 57, // 98: kandev.plugin.v1.Host.ListWorkflowSteps:input_type -> kandev.plugin.v1.ListWorkflowStepsRequest - 60, // 99: kandev.plugin.v1.Host.ListAgentProfiles:input_type -> kandev.plugin.v1.ListAgentProfilesRequest - 63, // 100: kandev.plugin.v1.Host.ListExecutorProfiles:input_type -> kandev.plugin.v1.ListExecutorProfilesRequest - 66, // 101: kandev.plugin.v1.Host.ListRepositories:input_type -> kandev.plugin.v1.ListRepositoriesRequest - 70, // 102: kandev.plugin.v1.Host.ListSessions:input_type -> kandev.plugin.v1.ListSessionsRequest - 73, // 103: kandev.plugin.v1.Host.ListSessionCodeStats:input_type -> kandev.plugin.v1.ListSessionCodeStatsRequest - 77, // 104: kandev.plugin.v1.Host.ListMessages:input_type -> kandev.plugin.v1.ListMessagesRequest - 83, // 105: kandev.plugin.v1.Host.ListPendingInteractions:input_type -> kandev.plugin.v1.ListPendingInteractionsRequest - 85, // 106: kandev.plugin.v1.Host.GetInteraction:input_type -> kandev.plugin.v1.GetInteractionRequest - 94, // 107: kandev.plugin.v1.Host.InvokeUtilityAgent:input_type -> kandev.plugin.v1.InvokeUtilityAgentRequest - 95, // 108: kandev.plugin.v1.Host.InvokeUtilityAgentWithOptions:input_type -> kandev.plugin.v1.InvokeUtilityAgentWithOptionsRequest - 97, // 109: kandev.plugin.v1.Host.CreateTask:input_type -> kandev.plugin.v1.CreateTaskRequest - 102, // 110: kandev.plugin.v1.Host.UpdateTask:input_type -> kandev.plugin.v1.UpdateTaskRequest - 104, // 111: kandev.plugin.v1.Host.MoveTask:input_type -> kandev.plugin.v1.MoveTaskRequest - 106, // 112: kandev.plugin.v1.Host.SendMessage:input_type -> kandev.plugin.v1.SendMessageRequest - 108, // 113: kandev.plugin.v1.Host.PreviewPluginOwnedTaskTree:input_type -> kandev.plugin.v1.PreviewPluginOwnedTaskTreeRequest - 110, // 114: kandev.plugin.v1.Host.DeletePluginOwnedTaskTree:input_type -> kandev.plugin.v1.DeletePluginOwnedTaskTreeRequest - 87, // 115: kandev.plugin.v1.Host.RespondToPermission:input_type -> kandev.plugin.v1.RespondToPermissionRequest - 90, // 116: kandev.plugin.v1.Host.AnswerClarification:input_type -> kandev.plugin.v1.AnswerClarificationRequest - 92, // 117: kandev.plugin.v1.Host.CancelClarification:input_type -> kandev.plugin.v1.CancelClarificationRequest - 1, // 118: kandev.plugin.v1.Plugin.DeliverEvent:output_type -> kandev.plugin.v1.EventAck - 6, // 119: kandev.plugin.v1.Plugin.HandleWebhook:output_type -> kandev.plugin.v1.WebhookResponse - 114, // 120: kandev.plugin.v1.Plugin.DescribeAutomationCondition:output_type -> kandev.plugin.v1.AutomationConditionResponse - 116, // 121: kandev.plugin.v1.Plugin.VerifyAutomationWebhook:output_type -> kandev.plugin.v1.AutomationWebhookResponse - 9, // 122: kandev.plugin.v1.Plugin.HandleAction:output_type -> kandev.plugin.v1.PluginActionResponse - 11, // 123: kandev.plugin.v1.Plugin.SearchEntityReferences:output_type -> kandev.plugin.v1.SearchEntityReferencesResponse - 14, // 124: kandev.plugin.v1.Plugin.AuthorizeEntityReference:output_type -> kandev.plugin.v1.AuthorizeEntityReferenceResponse - 16, // 125: kandev.plugin.v1.Plugin.ResolveGitCredential:output_type -> kandev.plugin.v1.ResolveGitCredentialResponse - 18, // 126: kandev.plugin.v1.Plugin.GetGitCredentialBinding:output_type -> kandev.plugin.v1.GitCredentialBindingResponse - 4, // 127: kandev.plugin.v1.Plugin.InvokeAgentTool:output_type -> kandev.plugin.v1.AgentToolResponse - 20, // 128: kandev.plugin.v1.Host.GetState:output_type -> kandev.plugin.v1.GetStateResponse - 22, // 129: kandev.plugin.v1.Host.SetState:output_type -> kandev.plugin.v1.SetStateResponse - 24, // 130: kandev.plugin.v1.Host.DeleteState:output_type -> kandev.plugin.v1.DeleteStateResponse - 26, // 131: kandev.plugin.v1.Host.ListState:output_type -> kandev.plugin.v1.ListStateResponse - 37, // 132: kandev.plugin.v1.Host.RevealSecret:output_type -> kandev.plugin.v1.RevealSecretResponse - 39, // 133: kandev.plugin.v1.Host.EmitEvent:output_type -> kandev.plugin.v1.EmitEventResponse - 31, // 134: kandev.plugin.v1.Host.GetSecret:output_type -> kandev.plugin.v1.GetSecretResponse - 33, // 135: kandev.plugin.v1.Host.SetSecret:output_type -> kandev.plugin.v1.SetSecretResponse - 35, // 136: kandev.plugin.v1.Host.DeleteSecret:output_type -> kandev.plugin.v1.DeleteSecretResponse - 29, // 137: kandev.plugin.v1.Host.GetConfig:output_type -> kandev.plugin.v1.GetConfigResponse - 47, // 138: kandev.plugin.v1.Host.ListTasks:output_type -> kandev.plugin.v1.ListTasksResponse - 49, // 139: kandev.plugin.v1.Host.GetTask:output_type -> kandev.plugin.v1.GetTaskResponse - 52, // 140: kandev.plugin.v1.Host.ListWorkspaces:output_type -> kandev.plugin.v1.ListWorkspacesResponse - 55, // 141: kandev.plugin.v1.Host.ListWorkflows:output_type -> kandev.plugin.v1.ListWorkflowsResponse - 58, // 142: kandev.plugin.v1.Host.ListWorkflowSteps:output_type -> kandev.plugin.v1.ListWorkflowStepsResponse - 61, // 143: kandev.plugin.v1.Host.ListAgentProfiles:output_type -> kandev.plugin.v1.ListAgentProfilesResponse - 64, // 144: kandev.plugin.v1.Host.ListExecutorProfiles:output_type -> kandev.plugin.v1.ListExecutorProfilesResponse - 67, // 145: kandev.plugin.v1.Host.ListRepositories:output_type -> kandev.plugin.v1.ListRepositoriesResponse - 71, // 146: kandev.plugin.v1.Host.ListSessions:output_type -> kandev.plugin.v1.ListSessionsResponse - 74, // 147: kandev.plugin.v1.Host.ListSessionCodeStats:output_type -> kandev.plugin.v1.ListSessionCodeStatsResponse - 78, // 148: kandev.plugin.v1.Host.ListMessages:output_type -> kandev.plugin.v1.ListMessagesResponse - 84, // 149: kandev.plugin.v1.Host.ListPendingInteractions:output_type -> kandev.plugin.v1.ListPendingInteractionsResponse - 86, // 150: kandev.plugin.v1.Host.GetInteraction:output_type -> kandev.plugin.v1.GetInteractionResponse - 96, // 151: kandev.plugin.v1.Host.InvokeUtilityAgent:output_type -> kandev.plugin.v1.InvokeUtilityAgentResponse - 96, // 152: kandev.plugin.v1.Host.InvokeUtilityAgentWithOptions:output_type -> kandev.plugin.v1.InvokeUtilityAgentResponse - 101, // 153: kandev.plugin.v1.Host.CreateTask:output_type -> kandev.plugin.v1.CreateTaskResponse - 103, // 154: kandev.plugin.v1.Host.UpdateTask:output_type -> kandev.plugin.v1.UpdateTaskResponse - 105, // 155: kandev.plugin.v1.Host.MoveTask:output_type -> kandev.plugin.v1.MoveTaskResponse - 107, // 156: kandev.plugin.v1.Host.SendMessage:output_type -> kandev.plugin.v1.SendMessageResponse - 109, // 157: kandev.plugin.v1.Host.PreviewPluginOwnedTaskTree:output_type -> kandev.plugin.v1.PreviewPluginOwnedTaskTreeResponse - 111, // 158: kandev.plugin.v1.Host.DeletePluginOwnedTaskTree:output_type -> kandev.plugin.v1.DeletePluginOwnedTaskTreeResponse - 88, // 159: kandev.plugin.v1.Host.RespondToPermission:output_type -> kandev.plugin.v1.RespondToPermissionResponse - 91, // 160: kandev.plugin.v1.Host.AnswerClarification:output_type -> kandev.plugin.v1.AnswerClarificationResponse - 93, // 161: kandev.plugin.v1.Host.CancelClarification:output_type -> kandev.plugin.v1.CancelClarificationResponse - 118, // [118:162] is the sub-list for method output_type - 74, // [74:118] is the sub-list for method input_type - 74, // [74:74] is the sub-list for extension type_name - 74, // [74:74] is the sub-list for extension extendee - 0, // [0:74] is the sub-list for field type_name + 114, // 73: kandev.plugin.v1.EnsureAgentConversationRequest.spec:type_name -> kandev.plugin.v1.AgentConversationSpec + 113, // 74: kandev.plugin.v1.EnsureAgentConversationResponse.conv_descriptor:type_name -> kandev.plugin.v1.AgentConversationDescriptor + 113, // 75: kandev.plugin.v1.DispatchAgentConversationResponse.conv_descriptor:type_name -> kandev.plugin.v1.AgentConversationDescriptor + 128, // 76: kandev.plugin.v1.AutomationWebhookRequest.headers:type_name -> kandev.plugin.v1.AutomationWebhookRequest.HeadersEntry + 0, // 77: kandev.plugin.v1.Plugin.DeliverEvent:input_type -> kandev.plugin.v1.Event + 5, // 78: kandev.plugin.v1.Plugin.HandleWebhook:input_type -> kandev.plugin.v1.WebhookRequest + 121, // 79: kandev.plugin.v1.Plugin.DescribeAutomationCondition:input_type -> kandev.plugin.v1.AutomationConditionRequest + 123, // 80: kandev.plugin.v1.Plugin.VerifyAutomationWebhook:input_type -> kandev.plugin.v1.AutomationWebhookRequest + 7, // 81: kandev.plugin.v1.Plugin.HandleAction:input_type -> kandev.plugin.v1.PluginActionRequest + 10, // 82: kandev.plugin.v1.Plugin.SearchEntityReferences:input_type -> kandev.plugin.v1.SearchEntityReferencesRequest + 13, // 83: kandev.plugin.v1.Plugin.AuthorizeEntityReference:input_type -> kandev.plugin.v1.AuthorizeEntityReferenceRequest + 15, // 84: kandev.plugin.v1.Plugin.ResolveGitCredential:input_type -> kandev.plugin.v1.ResolveGitCredentialRequest + 17, // 85: kandev.plugin.v1.Plugin.GetGitCredentialBinding:input_type -> kandev.plugin.v1.GitCredentialBindingRequest + 2, // 86: kandev.plugin.v1.Plugin.InvokeAgentTool:input_type -> kandev.plugin.v1.AgentToolRequest + 19, // 87: kandev.plugin.v1.Host.GetState:input_type -> kandev.plugin.v1.GetStateRequest + 21, // 88: kandev.plugin.v1.Host.SetState:input_type -> kandev.plugin.v1.SetStateRequest + 23, // 89: kandev.plugin.v1.Host.DeleteState:input_type -> kandev.plugin.v1.DeleteStateRequest + 25, // 90: kandev.plugin.v1.Host.ListState:input_type -> kandev.plugin.v1.ListStateRequest + 36, // 91: kandev.plugin.v1.Host.RevealSecret:input_type -> kandev.plugin.v1.RevealSecretRequest + 38, // 92: kandev.plugin.v1.Host.EmitEvent:input_type -> kandev.plugin.v1.EmitEventRequest + 30, // 93: kandev.plugin.v1.Host.GetSecret:input_type -> kandev.plugin.v1.GetSecretRequest + 32, // 94: kandev.plugin.v1.Host.SetSecret:input_type -> kandev.plugin.v1.SetSecretRequest + 34, // 95: kandev.plugin.v1.Host.DeleteSecret:input_type -> kandev.plugin.v1.DeleteSecretRequest + 28, // 96: kandev.plugin.v1.Host.GetConfig:input_type -> kandev.plugin.v1.GetConfigRequest + 46, // 97: kandev.plugin.v1.Host.ListTasks:input_type -> kandev.plugin.v1.ListTasksRequest + 48, // 98: kandev.plugin.v1.Host.GetTask:input_type -> kandev.plugin.v1.GetTaskRequest + 51, // 99: kandev.plugin.v1.Host.ListWorkspaces:input_type -> kandev.plugin.v1.ListWorkspacesRequest + 54, // 100: kandev.plugin.v1.Host.ListWorkflows:input_type -> kandev.plugin.v1.ListWorkflowsRequest + 57, // 101: kandev.plugin.v1.Host.ListWorkflowSteps:input_type -> kandev.plugin.v1.ListWorkflowStepsRequest + 60, // 102: kandev.plugin.v1.Host.ListAgentProfiles:input_type -> kandev.plugin.v1.ListAgentProfilesRequest + 63, // 103: kandev.plugin.v1.Host.ListExecutorProfiles:input_type -> kandev.plugin.v1.ListExecutorProfilesRequest + 66, // 104: kandev.plugin.v1.Host.ListRepositories:input_type -> kandev.plugin.v1.ListRepositoriesRequest + 70, // 105: kandev.plugin.v1.Host.ListSessions:input_type -> kandev.plugin.v1.ListSessionsRequest + 73, // 106: kandev.plugin.v1.Host.ListSessionCodeStats:input_type -> kandev.plugin.v1.ListSessionCodeStatsRequest + 77, // 107: kandev.plugin.v1.Host.ListMessages:input_type -> kandev.plugin.v1.ListMessagesRequest + 83, // 108: kandev.plugin.v1.Host.ListPendingInteractions:input_type -> kandev.plugin.v1.ListPendingInteractionsRequest + 85, // 109: kandev.plugin.v1.Host.GetInteraction:input_type -> kandev.plugin.v1.GetInteractionRequest + 94, // 110: kandev.plugin.v1.Host.InvokeUtilityAgent:input_type -> kandev.plugin.v1.InvokeUtilityAgentRequest + 95, // 111: kandev.plugin.v1.Host.InvokeUtilityAgentWithOptions:input_type -> kandev.plugin.v1.InvokeUtilityAgentWithOptionsRequest + 97, // 112: kandev.plugin.v1.Host.CreateTask:input_type -> kandev.plugin.v1.CreateTaskRequest + 102, // 113: kandev.plugin.v1.Host.UpdateTask:input_type -> kandev.plugin.v1.UpdateTaskRequest + 104, // 114: kandev.plugin.v1.Host.MoveTask:input_type -> kandev.plugin.v1.MoveTaskRequest + 106, // 115: kandev.plugin.v1.Host.SendMessage:input_type -> kandev.plugin.v1.SendMessageRequest + 108, // 116: kandev.plugin.v1.Host.PreviewPluginOwnedTaskTree:input_type -> kandev.plugin.v1.PreviewPluginOwnedTaskTreeRequest + 110, // 117: kandev.plugin.v1.Host.DeletePluginOwnedTaskTree:input_type -> kandev.plugin.v1.DeletePluginOwnedTaskTreeRequest + 115, // 118: kandev.plugin.v1.Host.EnsureAgentConversation:input_type -> kandev.plugin.v1.EnsureAgentConversationRequest + 117, // 119: kandev.plugin.v1.Host.DispatchAgentConversation:input_type -> kandev.plugin.v1.DispatchAgentConversationRequest + 119, // 120: kandev.plugin.v1.Host.DeleteAgentConversation:input_type -> kandev.plugin.v1.DeleteAgentConversationRequest + 87, // 121: kandev.plugin.v1.Host.RespondToPermission:input_type -> kandev.plugin.v1.RespondToPermissionRequest + 90, // 122: kandev.plugin.v1.Host.AnswerClarification:input_type -> kandev.plugin.v1.AnswerClarificationRequest + 92, // 123: kandev.plugin.v1.Host.CancelClarification:input_type -> kandev.plugin.v1.CancelClarificationRequest + 1, // 124: kandev.plugin.v1.Plugin.DeliverEvent:output_type -> kandev.plugin.v1.EventAck + 6, // 125: kandev.plugin.v1.Plugin.HandleWebhook:output_type -> kandev.plugin.v1.WebhookResponse + 122, // 126: kandev.plugin.v1.Plugin.DescribeAutomationCondition:output_type -> kandev.plugin.v1.AutomationConditionResponse + 124, // 127: kandev.plugin.v1.Plugin.VerifyAutomationWebhook:output_type -> kandev.plugin.v1.AutomationWebhookResponse + 9, // 128: kandev.plugin.v1.Plugin.HandleAction:output_type -> kandev.plugin.v1.PluginActionResponse + 11, // 129: kandev.plugin.v1.Plugin.SearchEntityReferences:output_type -> kandev.plugin.v1.SearchEntityReferencesResponse + 14, // 130: kandev.plugin.v1.Plugin.AuthorizeEntityReference:output_type -> kandev.plugin.v1.AuthorizeEntityReferenceResponse + 16, // 131: kandev.plugin.v1.Plugin.ResolveGitCredential:output_type -> kandev.plugin.v1.ResolveGitCredentialResponse + 18, // 132: kandev.plugin.v1.Plugin.GetGitCredentialBinding:output_type -> kandev.plugin.v1.GitCredentialBindingResponse + 4, // 133: kandev.plugin.v1.Plugin.InvokeAgentTool:output_type -> kandev.plugin.v1.AgentToolResponse + 20, // 134: kandev.plugin.v1.Host.GetState:output_type -> kandev.plugin.v1.GetStateResponse + 22, // 135: kandev.plugin.v1.Host.SetState:output_type -> kandev.plugin.v1.SetStateResponse + 24, // 136: kandev.plugin.v1.Host.DeleteState:output_type -> kandev.plugin.v1.DeleteStateResponse + 26, // 137: kandev.plugin.v1.Host.ListState:output_type -> kandev.plugin.v1.ListStateResponse + 37, // 138: kandev.plugin.v1.Host.RevealSecret:output_type -> kandev.plugin.v1.RevealSecretResponse + 39, // 139: kandev.plugin.v1.Host.EmitEvent:output_type -> kandev.plugin.v1.EmitEventResponse + 31, // 140: kandev.plugin.v1.Host.GetSecret:output_type -> kandev.plugin.v1.GetSecretResponse + 33, // 141: kandev.plugin.v1.Host.SetSecret:output_type -> kandev.plugin.v1.SetSecretResponse + 35, // 142: kandev.plugin.v1.Host.DeleteSecret:output_type -> kandev.plugin.v1.DeleteSecretResponse + 29, // 143: kandev.plugin.v1.Host.GetConfig:output_type -> kandev.plugin.v1.GetConfigResponse + 47, // 144: kandev.plugin.v1.Host.ListTasks:output_type -> kandev.plugin.v1.ListTasksResponse + 49, // 145: kandev.plugin.v1.Host.GetTask:output_type -> kandev.plugin.v1.GetTaskResponse + 52, // 146: kandev.plugin.v1.Host.ListWorkspaces:output_type -> kandev.plugin.v1.ListWorkspacesResponse + 55, // 147: kandev.plugin.v1.Host.ListWorkflows:output_type -> kandev.plugin.v1.ListWorkflowsResponse + 58, // 148: kandev.plugin.v1.Host.ListWorkflowSteps:output_type -> kandev.plugin.v1.ListWorkflowStepsResponse + 61, // 149: kandev.plugin.v1.Host.ListAgentProfiles:output_type -> kandev.plugin.v1.ListAgentProfilesResponse + 64, // 150: kandev.plugin.v1.Host.ListExecutorProfiles:output_type -> kandev.plugin.v1.ListExecutorProfilesResponse + 67, // 151: kandev.plugin.v1.Host.ListRepositories:output_type -> kandev.plugin.v1.ListRepositoriesResponse + 71, // 152: kandev.plugin.v1.Host.ListSessions:output_type -> kandev.plugin.v1.ListSessionsResponse + 74, // 153: kandev.plugin.v1.Host.ListSessionCodeStats:output_type -> kandev.plugin.v1.ListSessionCodeStatsResponse + 78, // 154: kandev.plugin.v1.Host.ListMessages:output_type -> kandev.plugin.v1.ListMessagesResponse + 84, // 155: kandev.plugin.v1.Host.ListPendingInteractions:output_type -> kandev.plugin.v1.ListPendingInteractionsResponse + 86, // 156: kandev.plugin.v1.Host.GetInteraction:output_type -> kandev.plugin.v1.GetInteractionResponse + 96, // 157: kandev.plugin.v1.Host.InvokeUtilityAgent:output_type -> kandev.plugin.v1.InvokeUtilityAgentResponse + 96, // 158: kandev.plugin.v1.Host.InvokeUtilityAgentWithOptions:output_type -> kandev.plugin.v1.InvokeUtilityAgentResponse + 101, // 159: kandev.plugin.v1.Host.CreateTask:output_type -> kandev.plugin.v1.CreateTaskResponse + 103, // 160: kandev.plugin.v1.Host.UpdateTask:output_type -> kandev.plugin.v1.UpdateTaskResponse + 105, // 161: kandev.plugin.v1.Host.MoveTask:output_type -> kandev.plugin.v1.MoveTaskResponse + 107, // 162: kandev.plugin.v1.Host.SendMessage:output_type -> kandev.plugin.v1.SendMessageResponse + 109, // 163: kandev.plugin.v1.Host.PreviewPluginOwnedTaskTree:output_type -> kandev.plugin.v1.PreviewPluginOwnedTaskTreeResponse + 111, // 164: kandev.plugin.v1.Host.DeletePluginOwnedTaskTree:output_type -> kandev.plugin.v1.DeletePluginOwnedTaskTreeResponse + 116, // 165: kandev.plugin.v1.Host.EnsureAgentConversation:output_type -> kandev.plugin.v1.EnsureAgentConversationResponse + 118, // 166: kandev.plugin.v1.Host.DispatchAgentConversation:output_type -> kandev.plugin.v1.DispatchAgentConversationResponse + 120, // 167: kandev.plugin.v1.Host.DeleteAgentConversation:output_type -> kandev.plugin.v1.DeleteAgentConversationResponse + 88, // 168: kandev.plugin.v1.Host.RespondToPermission:output_type -> kandev.plugin.v1.RespondToPermissionResponse + 91, // 169: kandev.plugin.v1.Host.AnswerClarification:output_type -> kandev.plugin.v1.AnswerClarificationResponse + 93, // 170: kandev.plugin.v1.Host.CancelClarification:output_type -> kandev.plugin.v1.CancelClarificationResponse + 124, // [124:171] is the sub-list for method output_type + 77, // [77:124] is the sub-list for method input_type + 77, // [77:77] is the sub-list for extension type_name + 77, // [77:77] is the sub-list for extension extendee + 0, // [0:77] is the sub-list for field type_name } func init() { file_kandev_plugin_v1_plugin_proto_init() } @@ -8647,7 +9185,7 @@ func file_kandev_plugin_v1_plugin_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_kandev_plugin_v1_plugin_proto_rawDesc), len(file_kandev_plugin_v1_plugin_proto_rawDesc)), NumEnums: 0, - NumMessages: 121, + NumMessages: 129, NumExtensions: 0, NumServices: 2, }, diff --git a/apps/backend/proto/kandev/plugin/v1/plugin.proto b/apps/backend/proto/kandev/plugin/v1/plugin.proto index b4e6ee1da5..31efcd77c7 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin.proto +++ b/apps/backend/proto/kandev/plugin/v1/plugin.proto @@ -146,6 +146,15 @@ service Host { rpc PreviewPluginOwnedTaskTree(PreviewPluginOwnedTaskTreeRequest) returns (PreviewPluginOwnedTaskTreeResponse); rpc DeletePluginOwnedTaskTree(DeletePluginOwnedTaskTreeRequest) returns (DeletePluginOwnedTaskTreeResponse); + // Agent conversations — capability agent_conversation. Ensure creates or + // repairs one hidden workflowless ephemeral task with a primary session per + // (plugin_id, workspace_id, conversation_key). Dispatch sends a prompt to an + // ensured conversation with stable occurrence idempotency and busy-session + // coalescing. Delete removes only conversations owned by this plugin. + rpc EnsureAgentConversation(EnsureAgentConversationRequest) returns (EnsureAgentConversationResponse); + rpc DispatchAgentConversation(DispatchAgentConversationRequest) returns (DispatchAgentConversationResponse); + rpc DeleteAgentConversation(DeleteAgentConversationRequest) returns (DeleteAgentConversationResponse); + // 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 @@ -938,6 +947,75 @@ message DeletePluginOwnedTaskTreeProgress { repeated string deleted_task_ids = 1; } +// ── Agent conversations ───────────────────────────────────────────────── + +// AgentConversationDescriptor identifies an existing conversation. +message AgentConversationDescriptor { + string task_id = 1; + string session_id = 2; + string workspace_id = 3; + string conversation_key = 4; + // The agent_profile_id the conversation uses; empty when the profile + // referenced at ensure time is no longer available. + string agent_profile_id = 5; +} + +// AgentConversationSpec controls how Ensure creates a conversation. +message AgentConversationSpec { + string workspace_id = 1; + string conversation_key = 2; + // Editable base prompt baked into every dispatch to this conversation. + // The plugin appends its cycle/report-specific instructions. + string base_prompt = 3; + // Agent profile id to use. Empty means "use the workspace default". + // An explicit profile that is disabled/deleted produces a typed + // configuration-required result. + string agent_profile_id = 4; +} + +// EnsureAgentConversationRequest carries the Ensure request. +message EnsureAgentConversationRequest { + AgentConversationSpec spec = 1; +} + +// EnsureAgentConversationResponse carries the result. +message EnsureAgentConversationResponse { + AgentConversationDescriptor conv_descriptor = 1; + // One of "created", "exists", or "configuration_required". + string status = 2; +} + +// DispatchAgentConversationRequest sends a prompt to an ensured conversation. +message DispatchAgentConversationRequest { + string workspace_id = 1; + string conversation_key = 2; + string text = 3; + // Stable idempotency key derived from (trigger, due_timestamp) for + // scheduled occurrences, or a unique key for manual invocations. + // An already-claimed key returns the prior dispatch result. + string occurrence_key = 4; +} + +// DispatchAgentConversationResponse carries the dispatch result. +message DispatchAgentConversationResponse { + string session_id = 1; + // One of "started", "sent", "duplicate_occurrence", or "skipped_busy". + string status = 2; + AgentConversationDescriptor conv_descriptor = 3; +} + +// DeleteAgentConversationRequest deletes all conversations matching the +// request. Only conversations owned by this plugin are affected. +message DeleteAgentConversationRequest { + string workspace_id = 1; + string conversation_key = 2; +} + +// DeleteAgentConversationResponse carries the result. +message DeleteAgentConversationResponse { + int32 deleted_count = 1; +} + // Configuration and connection eligibility are always resolved within the host's workspace. message AutomationConditionRequest { string workspace_id = 1; diff --git a/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go b/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go index 05cbf77233..d228f9810b 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go +++ b/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go @@ -522,6 +522,9 @@ const ( 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_EnsureAgentConversation_FullMethodName = "/kandev.plugin.v1.Host/EnsureAgentConversation" + Host_DispatchAgentConversation_FullMethodName = "/kandev.plugin.v1.Host/DispatchAgentConversation" + Host_DeleteAgentConversation_FullMethodName = "/kandev.plugin.v1.Host/DeleteAgentConversation" Host_RespondToPermission_FullMethodName = "/kandev.plugin.v1.Host/RespondToPermission" Host_AnswerClarification_FullMethodName = "/kandev.plugin.v1.Host/AnswerClarification" Host_CancelClarification_FullMethodName = "/kandev.plugin.v1.Host/CancelClarification" @@ -636,6 +639,14 @@ 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) + // Agent conversations — capability agent_conversation. Ensure creates or + // repairs one hidden workflowless ephemeral task with a primary session per + // (plugin_id, workspace_id, conversation_key). Dispatch sends a prompt to an + // ensured conversation with stable occurrence idempotency and busy-session + // coalescing. Delete removes only conversations owned by this plugin. + EnsureAgentConversation(ctx context.Context, in *EnsureAgentConversationRequest, opts ...grpc.CallOption) (*EnsureAgentConversationResponse, error) + DispatchAgentConversation(ctx context.Context, in *DispatchAgentConversationRequest, opts ...grpc.CallOption) (*DispatchAgentConversationResponse, error) + DeleteAgentConversation(ctx context.Context, in *DeleteAgentConversationRequest, opts ...grpc.CallOption) (*DeleteAgentConversationResponse, 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 @@ -967,6 +978,36 @@ func (c *hostClient) DeletePluginOwnedTaskTree(ctx context.Context, in *DeletePl return out, nil } +func (c *hostClient) EnsureAgentConversation(ctx context.Context, in *EnsureAgentConversationRequest, opts ...grpc.CallOption) (*EnsureAgentConversationResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EnsureAgentConversationResponse) + err := c.cc.Invoke(ctx, Host_EnsureAgentConversation_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *hostClient) DispatchAgentConversation(ctx context.Context, in *DispatchAgentConversationRequest, opts ...grpc.CallOption) (*DispatchAgentConversationResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DispatchAgentConversationResponse) + err := c.cc.Invoke(ctx, Host_DispatchAgentConversation_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *hostClient) DeleteAgentConversation(ctx context.Context, in *DeleteAgentConversationRequest, opts ...grpc.CallOption) (*DeleteAgentConversationResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteAgentConversationResponse) + err := c.cc.Invoke(ctx, Host_DeleteAgentConversation_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + 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) @@ -1106,6 +1147,14 @@ type HostServer interface { SendMessage(context.Context, *SendMessageRequest) (*SendMessageResponse, error) PreviewPluginOwnedTaskTree(context.Context, *PreviewPluginOwnedTaskTreeRequest) (*PreviewPluginOwnedTaskTreeResponse, error) DeletePluginOwnedTaskTree(context.Context, *DeletePluginOwnedTaskTreeRequest) (*DeletePluginOwnedTaskTreeResponse, error) + // Agent conversations — capability agent_conversation. Ensure creates or + // repairs one hidden workflowless ephemeral task with a primary session per + // (plugin_id, workspace_id, conversation_key). Dispatch sends a prompt to an + // ensured conversation with stable occurrence idempotency and busy-session + // coalescing. Delete removes only conversations owned by this plugin. + EnsureAgentConversation(context.Context, *EnsureAgentConversationRequest) (*EnsureAgentConversationResponse, error) + DispatchAgentConversation(context.Context, *DispatchAgentConversationRequest) (*DispatchAgentConversationResponse, error) + DeleteAgentConversation(context.Context, *DeleteAgentConversationRequest) (*DeleteAgentConversationResponse, 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 @@ -1220,6 +1269,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) EnsureAgentConversation(context.Context, *EnsureAgentConversationRequest) (*EnsureAgentConversationResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method EnsureAgentConversation not implemented") +} +func (UnimplementedHostServer) DispatchAgentConversation(context.Context, *DispatchAgentConversationRequest) (*DispatchAgentConversationResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method DispatchAgentConversation not implemented") +} +func (UnimplementedHostServer) DeleteAgentConversation(context.Context, *DeleteAgentConversationRequest) (*DeleteAgentConversationResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method DeleteAgentConversation not implemented") +} func (UnimplementedHostServer) RespondToPermission(context.Context, *RespondToPermissionRequest) (*RespondToPermissionResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method RespondToPermission not implemented") } @@ -1808,6 +1866,60 @@ func _Host_DeletePluginOwnedTaskTree_Handler(srv interface{}, ctx context.Contex return interceptor(ctx, in, info, handler) } +func _Host_EnsureAgentConversation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(EnsureAgentConversationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).EnsureAgentConversation(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_EnsureAgentConversation_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).EnsureAgentConversation(ctx, req.(*EnsureAgentConversationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Host_DispatchAgentConversation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DispatchAgentConversationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).DispatchAgentConversation(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_DispatchAgentConversation_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).DispatchAgentConversation(ctx, req.(*DispatchAgentConversationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Host_DeleteAgentConversation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteAgentConversationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).DeleteAgentConversation(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_DeleteAgentConversation_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).DeleteAgentConversation(ctx, req.(*DeleteAgentConversationRequest)) + } + 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 { @@ -1993,6 +2105,18 @@ var Host_ServiceDesc = grpc.ServiceDesc{ MethodName: "DeletePluginOwnedTaskTree", Handler: _Host_DeletePluginOwnedTaskTree_Handler, }, + { + MethodName: "EnsureAgentConversation", + Handler: _Host_EnsureAgentConversation_Handler, + }, + { + MethodName: "DispatchAgentConversation", + Handler: _Host_DispatchAgentConversation_Handler, + }, + { + MethodName: "DeleteAgentConversation", + Handler: _Host_DeleteAgentConversation_Handler, + }, { MethodName: "RespondToPermission", Handler: _Host_RespondToPermission_Handler, diff --git a/apps/web/lib/ws/handlers/quick-chat.test.ts b/apps/web/lib/ws/handlers/quick-chat.test.ts index 0dd771e974..eafd288cd8 100644 --- a/apps/web/lib/ws/handlers/quick-chat.test.ts +++ b/apps/web/lib/ws/handlers/quick-chat.test.ts @@ -58,6 +58,10 @@ describe("quickChatSessionFromTaskEvent", () => { ["a non-ephemeral task", { is_ephemeral: false }], ["a workflow-bound ephemeral task", { workflow_id: "wf-1" }], ["an automation run", { origin: "automation_run" }], + [ + "a managed agent conversation", + { metadata: { "kandev.plugin_id": "plugin-coordinator", "kandev.ephemeral": true } }, + ], ["a task with no primary session yet", { primary_session_id: null }], ["an event without a workspace", { workspace_id: undefined }], ])("ignores %s", (_label, overrides) => { diff --git a/apps/web/lib/ws/handlers/quick-chat.ts b/apps/web/lib/ws/handlers/quick-chat.ts index 94627a8a4a..eb9a022d8c 100644 --- a/apps/web/lib/ws/handlers/quick-chat.ts +++ b/apps/web/lib/ws/handlers/quick-chat.ts @@ -8,6 +8,11 @@ import type { TaskEventPayload } from "@/lib/types/backend"; // user-chosen title apart from the placeholder. const QUICK_CHAT_PLACEHOLDER_TITLE = "Quick Chat"; +// i18n-exempt: wire metadata keys. These values are part of the backend task +// contract and are never rendered as user-facing copy. +const MANAGED_PLUGIN_ID_KEY = "kandev.plugin_id"; +const MANAGED_EPHEMERAL_KEY = "kandev.ephemeral"; + function readMetadataString( metadata: Record | null | undefined, key: string, @@ -16,6 +21,14 @@ function readMetadataString( return typeof value === "string" && value ? value : undefined; } +function isManagedConversationEvent(payload: TaskEventPayload): boolean { + const metadata = payload.metadata; + if (!metadata || typeof metadata[MANAGED_PLUGIN_ID_KEY] !== "string") { + return false; + } + return payload.is_ephemeral || metadata[MANAGED_EPHEMERAL_KEY] === true; +} + /** * Maps a task lifecycle event onto a quick-chat tab, or null when the task does * not back one. @@ -29,6 +42,7 @@ export function quickChatSessionFromTaskEvent(payload: TaskEventPayload): QuickC if (!payload.is_ephemeral) return null; if (payload.workflow_id) return null; if (payload.origin === "automation_run") return null; + if (isManagedConversationEvent(payload)) return null; const sessionId = payload.primary_session_id; const workspaceId = payload.workspace_id; if (!sessionId || !workspaceId) return null; diff --git a/docs/plans/agent-conversation-host-port/plan.md b/docs/plans/agent-conversation-host-port/plan.md new file mode 100644 index 0000000000..7ff3416d4b --- /dev/null +++ b/docs/plans/agent-conversation-host-port/plan.md @@ -0,0 +1,59 @@ +--- +created: 2026-09-14 +status: completed +requirements: + - REQ-PLUGINS-PLUGINS-001 +system_design: + - ../../specs/plugins/system-design/plugins-01.md +legacy_specs: [] +--- + +# Implementation Plan: AgentConversation Host Contract Port + +## Overview + +Port the maintained `agent_conversation` Host contract to canonical Kandev so a +plugin can manage one hidden conversation per plugin, workspace, and conversation +key through the existing typed gRPC Host channel. The source contract is +`yattdev/kandev` commit `612f46f7a4b206c7cd81863efc4202c123901542`; the target +branch is canonical `main` as it stood at merge base `753e5549e`. + +## Scope + +The port adds `EnsureAgentConversation`, `DispatchAgentConversation`, and +`DeleteAgentConversation` to `kandev.plugin.v1`, Go-native SDK access through +`pluginsdk.AgentConversations(host)`, and capability-gated Host wiring. `Ensure` +creates or repairs a hidden, workflowless ephemeral task/session; `Dispatch` +records durable occurrence keys so retries do not double-send; `Delete` removes +only conversations owned by the calling plugin. Installation, disable, and +uninstall paths clean up plugin-owned conversations. Quick Chat and its expiry +sweeper exclude those managed sessions. + +The port preserves typed `PermissionDenied` for an undeclared +`agent_conversation` capability, replay-safe dispatch statuses, workspace +isolation, pagination, cancellation-safe cleanup, and the published manifest and +authoring guidance. It does not add workspace-agent-principal binding, a general +metadata query API, a user-facing UI, or a schema index unrelated to the scoped +contract. + +## Compatibility and rollback + +The RPCs and capability are additive. Plugins that do not declare +`capabilities.agent_conversation: true` retain their existing behavior; plugins +that do declare it receive a typed denial when an older Host does not implement +the contract. Rolling back the Host leaves existing managed ephemeral records +inert; normal plugin uninstall or the lifecycle cleanup path removes them. + +## Verification + +- Regenerate the proto with the repository-pinned toolchain. +- Run focused task-service, plugin, SDK, SQLite repository, and backend-app + tests; run build, vet, and touched-package lint. +- Cover capability denial, idempotent Ensure/Dispatch/Delete, occurrence claims, + cleanup under cancellation, cross-workspace isolation, lifecycle cleanup, and + managed-session Quick Chat and sweeper exclusion. +- Validate the public manifest and authoring references. + +## Work orders + +- [x] [Task 01: Port the AgentConversation Host contract](task-01-port-agent-conversation-host.md) diff --git a/docs/plans/agent-conversation-host-port/task-01-port-agent-conversation-host.md b/docs/plans/agent-conversation-host-port/task-01-port-agent-conversation-host.md new file mode 100644 index 0000000000..2430484f92 --- /dev/null +++ b/docs/plans/agent-conversation-host-port/task-01-port-agent-conversation-host.md @@ -0,0 +1,80 @@ +--- +id: "01-port-agent-conversation-host" +title: "Port the AgentConversation Host contract" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-PLUGINS-PLUGINS-001 +acceptance_criteria: + - AC-PLUGINS-PLUGINS-001.3 + - AC-PLUGINS-PLUGINS-001.7 + - AC-PLUGINS-PLUGINS-001.11 +system_design: + - ../../specs/plugins/system-design/plugins-01.md +--- + +# Task 01: Port the AgentConversation Host Contract + +## Summary + +Port the maintained AgentConversation Host contract into canonical Kandev. The +contract lets a plugin that declares `agent_conversation: true` ensure, dispatch +to, and delete hidden workspace conversations through the existing gRPC Host and +the Go-native SDK manager. + +## In scope + +- Add the additive `EnsureAgentConversation`, `DispatchAgentConversation`, and + `DeleteAgentConversation` protobuf RPCs and generated stubs. +- Expose typed descriptors, dispatch outcomes, and the optional + `pluginsdk.AgentConversations(host)` manager. +- Gate every RPC on `capabilities.agent_conversation`, returning typed + `PermissionDenied` when it is absent. +- Create, repair, dispatch, and delete plugin-owned hidden ephemeral task/session + records with workspace isolation and idempotent occurrence keys. +- Keep managed conversations out of Quick Chat restoration and expiry; remove + them during plugin lifecycle cleanup. +- Publish the capability and manager contract in manifest and authoring guides. + +## Out of scope + +- Workspace-agent-principal binding from the maintained fork's optional + `workspace_agent_principals` capability. +- A repository metadata predicate, general metadata index, or unrelated schema + migration. +- A user-facing SPA surface or a Coordinator plugin package change. + +## Acceptance + +- A plugin with `agent_conversation: true` can ensure a stable hidden + conversation, dispatch a prompt exactly once per occurrence key, and delete + only its own matching conversation. +- A plugin without that capability receives gRPC `PermissionDenied`; no direct + database, shell, REST, MCP, or prompt-derived authority is introduced. +- Managed records are isolated by workspace and plugin ownership, excluded from + Quick Chat and its sweeper, and removed on disable or uninstall. +- The public manifest and authoring documentation describe the capability, + manager, outcomes, and compatibility behavior. + +## Verification + +```bash +cd apps/backend && go test -tags fts5 -count=1 ./internal/task/service/ ./internal/task/repository/sqlite/ ./internal/plugins/... ./pkg/pluginsdk/ ./internal/backendapp/ +cd apps/backend && go build ./... && go vet ./... +cd apps/backend && golangci-lint run ./internal/task/service/... ./internal/task/repository/sqlite/... ./internal/plugins/... ./pkg/pluginsdk/... ./internal/backendapp/... +node --test .github/scripts/pr-docs.test.cjs +git diff --check +``` + +## Results + +- Ported the maintained contract from `612f46f7a4b206c7cd81863efc4202c123901542` + onto canonical-main APIs, including generated proto, SDK, Host, task service, + lifecycle wiring, tests, and public references. +- Added cancellation-safe compensation for dispatch claims and recorded messages, + SQLite cross-connection claim coverage, Quick Chat exclusion coverage, and + stale ephemeral metadata compatibility coverage. +- Focused and touched-package tests, build, vet, lint, documentation checks, and + diff validation passed at the delivery head before PR review follow-up. diff --git a/docs/public/plugins-authoring.md b/docs/public/plugins-authoring.md index d148df3182..664bc04df0 100644 --- a/docs/public/plugins-authoring.md +++ b/docs/public/plugins-authoring.md @@ -685,6 +685,7 @@ subscription vocabulary and wildcard rules are in the | 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 | | Agent invocation | InvokeUtilityAgent(ctx, prompt, options...) | agent_invoke: true | One-shot completion. No options, or an empty ProfileID, uses the current platform default. A non-empty ProfileID selects that exact eligible profile. An invalid explicit profile returns FailedPrecondition without fallback. The host does not read plugin configuration for selection. | +| Agent conversations | AgentConversations(host): Ensure, Dispatch, Delete | agent_conversation: true | Hidden workflowless ephemeral task/session per plugin, workspace, and conversation key; dispatch occurrence keys are durable and idempotent; uninstall removes every conversation owned by the plugin | The Go signatures, filters, DTOs, and pagination types live in apps/backend/pkg/pluginsdk/host.go and data_types.go. api_write task/message @@ -861,6 +862,33 @@ 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. +Managed agent conversations are another optional Host extension. They let a +backend plugin keep one hidden agent session for each workspace and stable +conversation key: + +```go +conversations, ok := pluginsdk.AgentConversations(host) +if !ok { + return errors.New("this Kandev host does not support agent conversations") +} + +descriptor, status, err := conversations.Ensure(ctx, pluginsdk.AgentConversationSpec{ + WorkspaceID: workspaceID, + ConversationKey: "coordinator", + BasePrompt: "Review this workspace and report actionable changes.", + AgentProfileID: configuredProfileID, +}) +``` + +Declare `agent_conversation: true` before using the manager. `Ensure` returns +`created`, `exists`, or `configuration_required`; the last result creates no +task or session. `Dispatch` returns `started`, `sent`, `duplicate_occurrence`, +or `skipped_busy`. Supply a stable occurrence key when a scheduled event may be +retried; a busy dispatch does not consume it. `Delete` is idempotent and only +removes conversations owned by the calling plugin. Disabling or uninstalling +the plugin removes all of that plugin's managed conversations across +workspaces. + **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, @@ -1005,7 +1033,8 @@ checked against your manifest's `capabilities` before the handler runs: `GetState`/`SetState`/`DeleteState`/`ListState` require `capabilities.state: true`; `GetSecret`/`SetSecret`/`DeleteSecret`/ `RevealSecret` require `capabilities.secrets: true`; `InvokeUtilityAgent` -requires `capabilities.agent_invoke: true`; each data-reader accessor requires +requires `capabilities.agent_invoke: true`; managed conversation operations +require `capabilities.agent_conversation: true`; each data-reader accessor requires its resource in `capabilities.api_read` (e.g. `tasks`, `sessions`, `messages`, `interactions`, `workspaces`, `workflows`, `agent_profiles`, `repositories`). Calling one without the declared capability returns gRPC `PermissionDenied` diff --git a/docs/public/plugins-manifest.md b/docs/public/plugins-manifest.md index c4f03eda98..273ed45da5 100644 --- a/docs/public/plugins-manifest.md +++ b/docs/public/plugins-manifest.md @@ -52,6 +52,7 @@ capabilities: state: true secrets: true agent_invoke: true # gates Host.InvokeUtilityAgent + agent_conversation: true # gates managed AgentConversations Ensure/Dispatch/Delete auth: true # gates external (OIDC/SAML) login; see ADR 0050 user_state: true # gates host.storage (per-user browser storage) @@ -207,6 +208,7 @@ for a complete authoring path. | `capabilities.state` | no | bool | Gates `Host.GetState`/`SetState`/`DeleteState`/`ListState`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. | | `capabilities.secrets` | no | bool | Gates `Host.RevealSecret`/`GetSecret`/`SetSecret`/`DeleteSecret`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. | | `capabilities.agent_invoke` | no | bool | Gates `Host.InvokeUtilityAgent`, a one-shot completion. No options, or an empty `UtilityAgentOptions.ProfileID`, uses the current default profile from Settings > Utility Agents. A non-empty profile ID selects that exact eligible global, enabled, non-CLI profile whose agent supports sessionless inference. The host does not read plugin configuration or utility-agent records for selection. Calling without this capability returns gRPC `PermissionDenied`; a missing, deleted, disabled, CLI-passthrough, workspace-scoped, or non-inference explicit profile returns gRPC `FailedPrecondition` without fallback. See [explicit plugin utility selection](../decisions/2026-09-14-explicit-plugin-utility-selection.md). | +| `capabilities.agent_conversation` | no | bool | Gates the optional `pluginsdk.AgentConversations(host)` manager. `Ensure` creates or repairs a hidden ephemeral task/session for one `(plugin, workspace, conversation key)`, `Dispatch` sends an idempotent prompt, and `Delete` removes the matching plugin-owned conversation. Calls without this capability return gRPC `PermissionDenied`. | | `capabilities.auth` | no | bool | Lets the plugin log a visitor in against an external IdP (OIDC/SAML). Its webhook validates the token, then asserts the identity to Kandev via the `X-Kandev-Auth-Login` response header (`{provider, subject, email, display_name}`); Kandev mints the session and sets the cookie, so the plugin never sees the token. Requires authentication enabled; new users are provisioned as members, and Kandev never creates an admin nor auto-links to an existing admin account. **You MUST only assert an email the IdP verified as owned by the subject; a spoofed email claim is account takeover.** Highest-privilege capability; grant only to trusted plugins. See ADR 0050. | | `capabilities.user_state` | no | bool | Gates `host.storage` (`get`/`set`/`delete`/`list`/`subscribe`), the authenticated per-user browser storage surface at `/api/plugins/{id}/user-state/...`. Unlike `capabilities.state` (the gRPC `Host.SetState` family, written by the plugin's own backend), this is reachable directly from the plugin's frontend bundle with no Go backend required; every read/write is scoped to the calling user. Calling the route without this capability returns `403`. See [Authoring a plugin](plugins-authoring.md) and the per-user-plugin-storage decision record. | | `webhooks[].key` | yes | string | Must be unique within the manifest. Used in the relay path `POST /api/plugins/{id}/webhooks/{key}`. | diff --git a/docs/public/plugins.md b/docs/public/plugins.md index 86724193fb..baf7e83e29 100644 --- a/docs/public/plugins.md +++ b/docs/public/plugins.md @@ -203,8 +203,9 @@ binary an operator hasn't explicitly approved via install or Sync. ## Enable, disable, uninstall -- **Disable** stops the subprocess. Config and state are preserved; no - events or webhooks are delivered while disabled. +- **Disable** stops the subprocess and removes the plugin's managed agent + conversations. Config and other state are preserved; no events or webhooks + are delivered while disabled. - **Enable** respawns the subprocess and re-completes the handshake. It is also the manual recovery action for an `error` plugin; the Settings row and detail page show the last failure diagnostic when one is available. A successful @@ -308,7 +309,9 @@ disk on its first restart after upgrading to this version. sessions, workspaces, workflows, agent profiles, repositories) is gated individually via `api_read:`. Task create/update and message send are independently gated by `api_write:tasks` and `api_write:messages` and - use Kandev's first-party service paths. An undeclared capability returns + use Kandev's first-party service paths. `agent_conversation` separately gates + plugin-owned hidden agent sessions and their Ensure, Dispatch, and Delete + lifecycle. An undeclared capability returns gRPC `PermissionDenied` with a message naming the missing capability, checked before the handler runs. `GetConfig` and `EmitEvent` are the only ungated RPCs: a plugin can always read its own config (secrets included)