Files
kandev/apps/backend/internal/clarification/canceller.go
T
2026-04-16 15:58:01 +01:00

111 lines
3.5 KiB
Go

// Package clarification provides types and services for agent clarification requests.
package clarification
import (
"context"
"time"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
taskmodels "github.com/kandev/kandev/internal/task/models"
"go.uber.org/zap"
)
// Canceller wraps Store with message-update side effects.
// When the agent's turn completes, it cancels pending clarifications
// and marks the database messages with agent_disconnected metadata.
type Canceller struct {
store *Store
repo messageStore
eventBus EventBus
logger *logger.Logger
}
// NewCanceller creates a Canceller.
func NewCanceller(store *Store, repo messageStore, eventBus EventBus, log *logger.Logger) *Canceller {
return &Canceller{
store: store,
repo: repo,
eventBus: eventBus,
logger: log.WithFields(zap.String("component", "clarification-canceller")),
}
}
// CancelSessionAndNotify cancels all pending clarifications for a session,
// unblocking WaitForResponse callers, and marks the database messages
// as expired (with agent_disconnected=true for context) so the frontend
// closes the interactive overlay and renders a "Timed out" history entry.
// Returns the number of cancelled clarifications.
//
// Setting status=expired also prevents a UX bug where clicking the overlay's
// X button after the agent moved on would trigger a new turn via the
// respond handler's event fallback path (rejected responses were being
// forwarded to the orchestrator as "User declined to answer").
func (c *Canceller) CancelSessionAndNotify(ctx context.Context, sessionID string) int {
pendingIDs := c.store.CancelSession(sessionID)
if len(pendingIDs) == 0 {
return 0
}
for _, id := range pendingIDs {
msg, err := c.repo.FindMessageByPendingID(ctx, id)
if err != nil || msg == nil {
c.logger.Debug("message not found for cancelled clarification",
zap.String("pending_id", id),
zap.Error(err))
continue
}
if msg.Metadata == nil {
msg.Metadata = map[string]any{}
}
msg.Metadata["agent_disconnected"] = true
msg.Metadata["status"] = "expired"
if err := c.repo.UpdateMessage(ctx, msg); err != nil {
c.logger.Warn("failed to update message with expired status",
zap.String("pending_id", id),
zap.String("message_id", msg.ID),
zap.Error(err))
continue
}
// Publish message.updated event so the frontend picks up the metadata change
c.publishMessageUpdated(ctx, msg)
}
return len(pendingIDs)
}
// publishMessageUpdated publishes a message.updated event to the event bus.
func (c *Canceller) publishMessageUpdated(ctx context.Context, msg *taskmodels.Message) {
if c.eventBus == nil {
return
}
msgType := string(msg.Type)
if msgType == "" {
msgType = "message"
}
data := map[string]any{
"message_id": msg.ID,
"session_id": msg.TaskSessionID,
"task_id": msg.TaskID,
"turn_id": msg.TurnID,
"author_type": string(msg.AuthorType),
"author_id": msg.AuthorID,
"content": msg.Content,
"type": msgType,
"requests_input": msg.RequestsInput,
"created_at": msg.CreatedAt.Format(time.RFC3339),
"metadata": msg.Metadata,
}
event := bus.NewEvent(events.MessageUpdated, "clarification-canceller", data)
if err := c.eventBus.Publish(ctx, events.MessageUpdated, event); err != nil {
c.logger.Warn("failed to publish message.updated event",
zap.String("message_id", msg.ID),
zap.Error(err))
}
}