Files
kandev/apps/backend/internal/task/service/agent_conversations_dispatch_test.go
T
2026-09-15 23:17:41 +01:00

798 lines
27 KiB
Go

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)
}
}