60aef37cbd
Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
798 lines
27 KiB
Go
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)
|
|
}
|
|
}
|