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

316 lines
13 KiB
Go

package backendapp
import (
"context"
"database/sql"
"errors"
"testing"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/orchestrator"
orchexecutor "github.com/kandev/kandev/internal/orchestrator/executor"
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
taskmodels "github.com/kandev/kandev/internal/task/models"
tasksqlite "github.com/kandev/kandev/internal/task/repository/sqlite"
taskservice "github.com/kandev/kandev/internal/task/service"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
type fakeMessengerTaskSvc struct {
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) {
s, ok := f.byID[id]
if !ok {
return nil, errors.New("session not found")
}
return s, nil
}
func (f *fakeMessengerTaskSvc) GetPrimarySession(_ context.Context, _ string) (*taskmodels.TaskSession, error) {
return f.primary, f.primaryErr
}
func (f *fakeMessengerTaskSvc) CreateMessage(_ context.Context, req *taskservice.CreateMessageRequest) (*taskmodels.Message, error) {
f.created = &taskmodels.Message{ID: "msg-1", TaskSessionID: req.TaskSessionID, TaskID: req.TaskID, Content: req.Content}
return f.created, nil
}
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
}
type fakeMessengerOrch struct {
queue *messagequeue.Service
startCalls int
promptCalls int
resumeCalls int
queueStatusCalls int
queueStatusCtx context.Context
promptErr error
promptFailFirstOnly bool
promptHook func()
}
func (f *fakeMessengerOrch) GetMessageQueue() *messagequeue.Service { return f.queue }
func (f *fakeMessengerOrch) PublishQueueStatusEvent(ctx context.Context, _ string) {
f.queueStatusCalls++
f.queueStatusCtx = ctx
}
func (f *fakeMessengerOrch) StartCreatedSession(_ context.Context, _, _, _, _ string, _, _, _ bool, _ []v1.MessageAttachment, _ []v1.EntityReference) (*orchexecutor.TaskExecution, error) {
f.startCalls++
return &orchexecutor.TaskExecution{}, nil
}
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
}
return &orchestrator.PromptResult{}, nil
}
func (f *fakeMessengerOrch) ResumeTaskSession(_ context.Context, _, _ string) (*orchexecutor.TaskExecution, error) {
f.resumeCalls++
return &orchexecutor.TaskExecution{}, nil
}
func newMessengerAdapter(t *testing.T, tasks *fakeMessengerTaskSvc, orch *fakeMessengerOrch) pluginsTaskMessengerAdapter {
t.Helper()
log, err := logger.NewFromZap(zap.NewNop())
require.NoError(t, err)
if orch.queue == nil {
orch.queue = messagequeue.NewServiceMemory(log)
}
return pluginsTaskMessengerAdapter{tasks: tasks, orch: orch, log: log}
}
func TestPluginsMessenger_RunningSessionQueues(t *testing.T) {
tasks := &fakeMessengerTaskSvc{primary: &taskmodels.TaskSession{ID: "s1", TaskID: "t1", State: taskmodels.TaskSessionStateRunning}}
orch := &fakeMessengerOrch{}
a := newMessengerAdapter(t, tasks, orch)
res, err := a.SendMessage(context.Background(), "t1", "", "do the thing", "plugin:p")
require.NoError(t, err)
require.Equal(t, "s1", res.SessionID)
require.Equal(t, "queued", res.Status)
require.Equal(t, 1, orch.queue.GetStatus(context.Background(), "s1").Count, "message should be enqueued")
require.Nil(t, tasks.created, "queued path records via the queue, not CreateMessage")
require.Zero(t, orch.startCalls+orch.promptCalls)
require.Equal(t, 1, orch.queueStatusCalls, "queued message should publish queue status")
}
func TestPluginsMessenger_QueueStatusSurvivesCancelledRequest(t *testing.T) {
tasks := &fakeMessengerTaskSvc{primary: &taskmodels.TaskSession{ID: "s1", TaskID: "t1", State: taskmodels.TaskSessionStateRunning}}
orch := &fakeMessengerOrch{}
a := newMessengerAdapter(t, tasks, orch)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := a.SendMessage(ctx, "t1", "", "do the thing", "plugin:p")
require.NoError(t, err)
require.NotNil(t, orch.queueStatusCtx)
require.NoError(t, orch.queueStatusCtx.Err(), "queue status publication must not inherit a cancelled request")
}
func TestPluginsMessenger_CreatedSessionStarts(t *testing.T) {
tasks := &fakeMessengerTaskSvc{primary: &taskmodels.TaskSession{ID: "s1", TaskID: "t1", State: taskmodels.TaskSessionStateCreated, AgentProfileID: "prof-1"}}
orch := &fakeMessengerOrch{}
a := newMessengerAdapter(t, tasks, orch)
res, err := a.SendMessage(context.Background(), "t1", "", "kick off", "plugin:p")
require.NoError(t, err)
require.Equal(t, "started", res.Status)
require.Equal(t, 1, orch.startCalls)
require.NotNil(t, tasks.created, "the user message is recorded so it's tied to the launched turn")
require.Empty(t, tasks.deleted)
}
func TestPluginsMessenger_WaitingSessionPrompts(t *testing.T) {
tasks := &fakeMessengerTaskSvc{primary: &taskmodels.TaskSession{ID: "s1", TaskID: "t1", State: taskmodels.TaskSessionStateWaitingForInput, AgentExecutionID: "exec-1"}}
orch := &fakeMessengerOrch{}
a := newMessengerAdapter(t, tasks, orch)
res, err := a.SendMessage(context.Background(), "t1", "", "follow up", "plugin:p")
require.NoError(t, err)
require.Equal(t, "sent", res.Status)
require.Equal(t, 1, orch.promptCalls)
require.Zero(t, orch.startCalls)
require.Empty(t, tasks.deleted)
}
func TestPluginsMessenger_PromptResumesWhenExecutionGone(t *testing.T) {
tasks := &fakeMessengerTaskSvc{primary: &taskmodels.TaskSession{ID: "s1", TaskID: "t1", State: taskmodels.TaskSessionStateWaitingForInput, AgentExecutionID: "exec-1"}}
orch := &fakeMessengerOrch{promptErr: orchexecutor.ErrExecutionNotFound, promptFailFirstOnly: true}
a := newMessengerAdapter(t, tasks, orch)
res, err := a.SendMessage(context.Background(), "t1", "", "wake up", "plugin:p")
require.NoError(t, err)
require.Equal(t, "sent", res.Status)
require.Equal(t, 1, orch.resumeCalls, "a missing execution triggers a resume")
require.Equal(t, 2, orch.promptCalls, "prompt is retried after resume")
require.Empty(t, tasks.deleted)
}
// TestPluginsMessenger_ResumeWaitTimeoutDeletesMessage pins the explicit
// choice: when the agent execution is gone, resume succeeds, but the session
// isn't ready in time (WaitForSessionReady errors), the send fails and the
// recorded user message is deleted — so a plugin retry doesn't stack a
// duplicate prompt (queueing is not idempotent). The retry prompt is never
// reached.
func TestPluginsMessenger_ResumeWaitTimeoutDeletesMessage(t *testing.T) {
tasks := &fakeMessengerTaskSvc{
primary: &taskmodels.TaskSession{ID: "s1", TaskID: "t1", State: taskmodels.TaskSessionStateWaitingForInput, AgentExecutionID: "exec-1"},
waitErr: errors.New("session not ready in time"),
}
orch := &fakeMessengerOrch{promptErr: orchexecutor.ErrExecutionNotFound}
a := newMessengerAdapter(t, tasks, orch)
_, err := a.SendMessage(context.Background(), "t1", "", "wake up", "plugin:p")
require.Error(t, err)
require.Equal(t, 1, orch.resumeCalls, "resume is attempted")
require.Equal(t, 1, orch.promptCalls, "the retry prompt is not reached after a wait timeout")
require.Equal(t, []string{"msg-1"}, tasks.deleted, "the recorded message is removed so a retry can't duplicate it")
}
func TestPluginsMessenger_PromptFailureDeletesRecordedMessage(t *testing.T) {
tasks := &fakeMessengerTaskSvc{primary: &taskmodels.TaskSession{ID: "s1", TaskID: "t1", State: taskmodels.TaskSessionStateWaitingForInput, AgentExecutionID: "exec-1"}}
orch := &fakeMessengerOrch{promptErr: errors.New("dispatch boom")}
a := newMessengerAdapter(t, tasks, orch)
_, err := a.SendMessage(context.Background(), "t1", "", "nope", "plugin:p")
require.Error(t, err)
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},
}}
orch := &fakeMessengerOrch{}
a := newMessengerAdapter(t, tasks, orch)
_, err := a.SendMessage(context.Background(), "t1", "s1", "sneaky", "plugin:p")
require.Equal(t, codes.NotFound, status.Code(err), "a session from another task must not be reachable via a mismatched pair")
}
func TestPluginsMessenger_NoPrimarySessionIsNotFound(t *testing.T) {
tasks := &fakeMessengerTaskSvc{primaryErr: tasksqlite.ErrNoPrimarySession}
orch := &fakeMessengerOrch{}
a := newMessengerAdapter(t, tasks, orch)
_, err := a.SendMessage(context.Background(), "t1", "", "hello", "plugin:p")
require.Equal(t, codes.NotFound, status.Code(err))
}
func TestPluginsMessenger_TerminalSessionRejected(t *testing.T) {
tasks := &fakeMessengerTaskSvc{primary: &taskmodels.TaskSession{ID: "s1", TaskID: "t1", State: taskmodels.TaskSessionStateFailed}}
orch := &fakeMessengerOrch{}
a := newMessengerAdapter(t, tasks, orch)
_, err := a.SendMessage(context.Background(), "t1", "", "hello", "plugin:p")
require.Equal(t, codes.FailedPrecondition, status.Code(err))
}