package clarification import ( "context" "errors" "fmt" "sort" "strings" "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" ) // ErrBundleNotFound is ResolveBundle's single "not found" outcome (A3, A5). // An unknown pending_id, a denied AuthorizeTaskAccess, a bundle whose // task_id cannot be resolved (M5a), and a bundle belonging to the separate // autopilot parent-question protocol all collapse to this one sentinel by // design, so REST/MCP layers map every one of them to the same 404 without // distinguishing the cause. var ErrBundleNotFound = errors.New("clarification bundle not found") // errClarificationNotActive is R2's no-winner branch: the claim was lost and // the re-read found no answered/rejected message either, so the bundle is // simply not active (superseded turn, terminal session, expired, cancelled, // or malformed). REST/MCP layers map it to 409 with an unchanged message. var errClarificationNotActive = errors.New("clarification request is no longer active") // IsNotActiveError reports whether err is R2's no-winner outcome, for // REST/MCP layers deciding a 409 response. func IsNotActiveError(err error) bool { return errors.Is(err, errClarificationNotActive) } // Resolution is ResolveBundle's result: the winning (or, on a lost claim, // reconstructed - R2b) terminal status and response for a bundle. type Resolution struct { PendingID string SessionID string TaskID string Status string Response *Response } // taskAccessAuthorizer is step 2's authorization check ("The resolution // claim", step 2). type taskAccessAuthorizer interface { AuthorizeTaskAccess(ctx context.Context, taskID string) error } // clarificationResponseClaim carries a winning claim's committed rows through // delivery. messages is immutable after claim construction: a delivery // callback can outlive the responder's bounded wait, so callback results // stay local. type clarificationResponseClaim struct { response *Response terminalStatus string messages []*taskmodels.Message } // Resolver implements ResolveBundle // (docs/specs/integrations/requirements/external-question-answering.md, "Resolution semantics"), // the single service-layer operation the REST endpoints and the // answer_question_kandev MCP tool both call. It resolves a bundle's identity // and authorization (M5, A1-A9), validates the outcome (N6-N8b), claims it // through upstream's CompleteActiveClarificationBundle (P1, R1, R3, R6), and // on a win delivers through upstream's existing live-waiter/detached-resume // path (R7) unchanged; on a loss it reconstructs the winner's outcome (R2). type Resolver struct { store *Store repo handlerMessageStore messages MessageCreator authorizer taskAccessAuthorizer detachedResumer DetachedClarificationResumer eventBus EventBus logger *logger.Logger now func() time.Time // seam for deterministic tests } // NewResolver creates a Resolver. func NewResolver( store *Store, repo handlerMessageStore, messages MessageCreator, authorizer taskAccessAuthorizer, detachedResumer DetachedClarificationResumer, eventBus EventBus, log *logger.Logger, ) *Resolver { return &Resolver{ store: store, repo: repo, messages: messages, authorizer: authorizer, detachedResumer: detachedResumer, eventBus: eventBus, logger: log.WithFields(zap.String("component", "clarification-resolver")), now: time.Now, } } // AuthorizeBundleAccess resolves a bundle's identity (M5) and authorizes the // caller against its owning task (A2), without claiming or applying anything. // REST read endpoints (GET /:id, GET /:id/wait) call this ahead of their // in-memory read (A7) so an unauthorized caller gets ErrBundleNotFound // exactly as A3 requires, and A5a's "no durable messages" case also // collapses to the same sentinel. POST /:id/cancel calls this alone (A9): // cancel does not go through ResolveBundle. func (r *Resolver) AuthorizeBundleAccess(ctx context.Context, pendingID string) (sessionID, taskID string, err error) { _, sessionID, taskID, err = r.resolveIdentity(ctx, pendingID) if err != nil { return "", "", err } if err := r.authorizer.AuthorizeTaskAccess(ctx, taskID); err != nil { return "", "", ErrBundleNotFound // A3 } return sessionID, taskID, nil } // resolveIdentity is step 1 (M5, M5a): read the bundle's durable messages // and derive session_id/task_id. A bundle with no messages, one whose // task_id cannot be resolved from either source, or one belonging to the // separate autopilot parent-question protocol (isParentQuestionBundle) is // not-found. func (r *Resolver) resolveIdentity(ctx context.Context, pendingID string) ([]*taskmodels.Message, string, string, error) { msgs, err := r.repo.FindMessagesByPendingID(ctx, pendingID) if err != nil { return nil, "", "", err } if len(msgs) == 0 { return nil, "", "", ErrBundleNotFound } if isParentQuestionBundle(msgs) { return nil, "", "", ErrBundleNotFound } sessionID, taskID, ok := r.resolveBundleTaskID(ctx, msgs) if !ok { return nil, "", "", ErrBundleNotFound // M5a } return msgs, sessionID, taskID, nil } // isParentQuestionBundle reports whether any message in the bundle carries // the autopilot parent-question marker (models.MetaKeyParentQuestion, // parent_question.go's parentQuestionMetadata). Those records share this // bundle's message type and status/pending_id metadata keys, but they // belong to a separate, ownership-gated resolution channel // (message_task_kandev's reply_to_question_id, validateParentQuestionOwnership) // that ResolveBundle and AuthorizeBundleAccess must never claim or read — // doing so would let any workspace-authorized caller (REST or the external // answer_question_kandev/list_pending_questions_kandev MCP tools) resolve a // running autopilot agent's question to its parent, bypassing that // ownership check and the parent-only interaction boundary the spec's S2 // relies on. func isParentQuestionBundle(msgs []*taskmodels.Message) bool { for _, m := range msgs { if marker, _ := m.Metadata[taskmodels.MetaKeyParentQuestion].(bool); marker { return true } } return false } // resolveBundleTaskID implements M5: session_id is always present on the // message row. task_id may be empty there; when so, it is resolved from the // bundle's session row. When neither source yields one, ok is false (M5a). func (r *Resolver) resolveBundleTaskID(ctx context.Context, msgs []*taskmodels.Message) (sessionID, taskID string, ok bool) { sessionID = msgs[0].TaskSessionID for _, m := range msgs { if m.TaskID != "" { return sessionID, m.TaskID, true } } session, err := r.repo.GetTaskSession(ctx, sessionID) if err != nil || session == nil || session.TaskID == "" { return sessionID, "", false } return sessionID, session.TaskID, true } // ResolveBundle runs the ordered steps described in the spec: resolve // identity, authorize, validate, claim, and (winner only) deliver. Cancel // SHALL NOT be submitted here (A9): cancel is authorized via // AuthorizeBundleAccess alone and never claims. func (r *Resolver) ResolveBundle(ctx context.Context, pendingID string, outcome Outcome) (*Resolution, bool, error) { msgs, sessionID, taskID, err := r.resolveIdentity(ctx, pendingID) if err != nil { return nil, false, err } if err := r.authorizer.AuthorizeTaskAccess(ctx, taskID); err != nil { return nil, false, ErrBundleNotFound // A3 } questions := bundleQuestions(msgs) if err := validateOutcome(questions, outcome); err != nil { return nil, false, err // N8c, R2c: validation runs before the claim } return r.claimAndDeliver(ctx, pendingID, sessionID, taskID, questions, outcome) } // claimAndDeliver is steps 4 and 5. R4: the durable claim commits before any // resume event is published, so a loser can never trigger a resume. func (r *Resolver) claimAndDeliver( ctx context.Context, pendingID, sessionID, taskID string, questions []Question, outcome Outcome, ) (*Resolution, bool, error) { status, response := buildOutcomeResponse(pendingID, questions, outcome, r.now()) persistenceCtx, cancel := clarificationPersistenceContext(ctx) completedMessages, claimed, claimErr := r.messages.CompleteActiveClarificationBundle( persistenceCtx, pendingID, status, responsesFromAnswers(response.Answers), ) cancel() if claimErr != nil { r.logger.Error("failed to claim clarification response", zap.String("pending_id", pendingID), zap.Error(claimErr)) return nil, false, fmt.Errorf("failed to update clarification state: %w", claimErr) // R4a } if !claimed { return r.reconstructLoser(ctx, pendingID, sessionID, taskID) // R2 } if err := r.deliverClaimedResolution(ctx, pendingID, status, response, completedMessages); err != nil { return nil, true, err } return &Resolution{ PendingID: pendingID, SessionID: sessionID, TaskID: taskID, Status: status, Response: response, }, true, nil } // responsesFromAnswers builds the map CompleteActiveClarificationBundle // requires for an answered outcome, keyed by question_id. Returns nil for a // rejection (empty Answers), matching upstream's own claim call. func responsesFromAnswers(answers []Answer) map[string]interface{} { if len(answers) == 0 { return nil } out := make(map[string]interface{}, len(answers)) for _, a := range answers { out[a.QuestionID] = a } return out } // reconstructLoser is R2/R2a/R2b: a lost claim re-reads the bundle's durable // messages exactly once and branches on what it finds, without retrying and // without claiming or modifying anything. func (r *Resolver) reconstructLoser(ctx context.Context, pendingID, sessionID, taskID string) (*Resolution, bool, error) { persistenceCtx, cancel := clarificationPersistenceContext(ctx) defer cancel() msgs, err := r.repo.FindMessagesByPendingID(persistenceCtx, pendingID) if err != nil { r.logger.Error("failed to re-read clarification bundle after lost claim", zap.String("pending_id", pendingID), zap.Error(err)) return nil, false, fmt.Errorf("failed to determine clarification outcome: %w", err) } status, response, hasWinner := reconstructWinnerResolution(pendingID, msgs, r.logger) if !hasWinner { return nil, false, errClarificationNotActive } return &Resolution{ PendingID: pendingID, SessionID: sessionID, TaskID: taskID, Status: status, Response: response, }, false, nil } // reconstructWinnerResolution implements R2b. status is the status of the // first message in D2 order whose status is answered or rejected; answers is // built by walking the bundle's messages in D2 order and taking each // message's metadata.response where present, in that order. reject_reason // is always "" (R2b: upstream stores no reject reason on the message rows). func reconstructWinnerResolution(pendingID string, msgs []*taskmodels.Message, log *logger.Logger) (string, *Response, bool) { ordered := orderBundleMessages(msgs) status := "" var respondedAt time.Time for _, m := range ordered { s := stringFromMetadata(m.Metadata, metaStatusKey) if s == string(StatusAnswered) || s == string(StatusRejected) { status = s respondedAt = m.UpdatedAt break } } if status == "" { return "", nil, false } answers := make([]Answer, 0, len(ordered)) for _, m := range ordered { if a, ok := answerFromMessageMetadata(m.ID, m.Metadata, log); ok { answers = append(answers, a) } } return status, &Response{ PendingID: pendingID, Answers: answers, Rejected: status == string(StatusRejected), RejectReason: "", RespondedAt: respondedAt, }, true } // answerFromMessageMetadata reads one message's stored metadata.response, if // present, as an Answer. A message that carries no response contributes no // entry. A malformed stored value is skipped and logged rather than aborting // the whole reconstruction. func answerFromMessageMetadata(messageID string, meta map[string]any, log *logger.Logger) (Answer, bool) { raw, present := meta["response"] if !present { return Answer{}, false } rawMap, ok := raw.(map[string]any) if !ok { if log != nil { log.Warn("clarification message has malformed response metadata; skipping in replay", zap.String("message_id", messageID)) } return Answer{}, false } answer := Answer{QuestionID: questionIDFromMetadata(meta)} if opts, ok := rawMap["selected_options"].([]interface{}); ok { for _, o := range opts { if s, ok := o.(string); ok { answer.SelectedOptions = append(answer.SelectedOptions, s) } } } if s, ok := rawMap["custom_text"].(string); ok { answer.CustomText = s } return answer, true } // deliverClaimedResolution is R7: the winner's delivery and resume path is // upstream's, reached unchanged. It calls the existing // Store.RespondWithDeliveryConfirmation path and SHALL NOT re-implement live // delivery, detached resume dispatch, or the RestoreActiveClarificationBundle // rollback. func (r *Resolver) deliverClaimedResolution( ctx context.Context, pendingID, status string, response *Response, completedMessages []*taskmodels.Message, ) error { claim := &clarificationResponseClaim{ response: response, terminalStatus: status, messages: completedMessages, } // Durable current-turn ownership is claimed before touching the live waiter. // This closes the window where a newer turn exists but its canceller has not // yet drained the superseded in-memory request. deliveryCtx, cancelDelivery := context.WithTimeout( context.WithoutCancel(ctx), clarificationPersistenceTimeout+5*time.Second, ) defer cancelDelivery() // The callback may outlive this handler's bounded wait. Keep its result in // callback-owned storage so the immutable claim remains safe for recovery. finalizedMessages := make(chan []*taskmodels.Message, 1) deliveryErr := r.store.RespondWithDeliveryConfirmation( deliveryCtx, pendingID, claim.response, func() error { finalized, confirmErr := r.confirmLiveClarificationResponseDelivery(ctx, pendingID, claim) if confirmErr == nil { finalizedMessages <- finalized } return confirmErr }, ) if deliveryErr == nil { finalized := <-finalizedMessages r.publishClarificationBundleUpdates(ctx, pendingID, finalized) r.publishPrimaryAnsweredEvent( ctx, pendingID, response.Answers, response.Rejected, response.RejectReason, r.clarificationClaimTurnID(pendingID, finalized), ) r.logger.Info("clarification answered via primary path (same turn)", zap.String("pending_id", pendingID), zap.Int("answers", len(response.Answers)), zap.Bool("rejected", response.Rejected)) return nil } if errors.Is(deliveryErr, ErrAlreadyResponded) { // Defensive only: the durable claim above is exclusive. Retain this as a // safety net if the in-memory waiter ever diverges from durable state. if finalized, ok := r.finalizeClarificationResponseDelivery(ctx, pendingID, claim); ok { r.publishClarificationBundleUpdates(ctx, pendingID, finalized) } r.logger.Warn("duplicate response attempt", zap.String("pending_id", pendingID)) return errors.New("response already submitted") } if !errors.Is(deliveryErr, ErrNotFound) { restored := r.restoreFailedClarificationClaim(ctx, pendingID, claim.terminalStatus, claim.messages) r.logger.Error("failed to deliver clarification response", zap.String("pending_id", pendingID), zap.Bool("restored", restored), zap.Error(deliveryErr)) if restored { return errors.New("failed to deliver clarification response; response can be retried") } return errors.New("failed to deliver clarification response and recover pending clarification state") } return r.deliverDetachedClarificationResponse(ctx, pendingID, response, claim, deliveryErr) } func (r *Resolver) deliverDetachedClarificationResponse( ctx context.Context, pendingID string, response *Response, claim *clarificationResponseClaim, deliveryErr error, ) error { // Fallback path: entry not found (agent timed out, entry was cleaned up). // If the user rejected (clicked X to dismiss), they're discarding a stale // overlay — not continuing the conversation. Treat as a no-op so we don't // surprise them by resuming the agent with "User declined to answer". if response.Rejected { finalized, ok := r.finalizeClarificationResponseDelivery(ctx, pendingID, claim) if !ok { return errors.New("clarification rejection was recorded, but delivery state could not be finalized") } r.publishStaleDismissedEvent(ctx, pendingID) r.publishClarificationBundleUpdates(ctx, pendingID, finalized) r.logger.Info("clarification rejected after agent moved on; no-op", zap.String("pending_id", pendingID)) return nil } // User is providing an affirmative answer after the agent moved on. Ask the // orchestrator to accept a new turn containing the answer before publishing // the terminal clarification update. r.logger.Info("clarification entry not found, using acknowledged resume fallback", zap.String("pending_id", pendingID), zap.String("error", deliveryErr.Error())) if err := r.resumeDetachedClarification( ctx, pendingID, response.Answers, response.Rejected, response.RejectReason, claim.messages, ); err != nil { if detachedResumeWasAccepted(err) { // The prompt reached agentctl. Keep the durable answer terminal so a // retry cannot dispatch it again, even though turn publication failed // and the caller must receive a server error. if finalized, ok := r.finalizeClarificationResponseDelivery(ctx, pendingID, claim); ok { r.publishClarificationBundleUpdates(ctx, pendingID, finalized) } r.logger.Error("accepted clarification resume was not durably published", zap.String("pending_id", pendingID), zap.Error(err)) return errors.New("clarification response was accepted, but dispatch state could not be finalized") } restored := r.restoreFailedClarificationClaim(ctx, pendingID, claim.terminalStatus, claim.messages) r.logger.Error("failed to resume detached clarification", zap.String("pending_id", pendingID), zap.Error(err)) if restored { return errors.New("failed to resume clarification; response can be retried") } return errors.New("failed to resume clarification and recover pending clarification state") } finalized, ok := r.finalizeClarificationResponseDelivery(ctx, pendingID, claim) if !ok { return errors.New("clarification response was accepted, but delivery state could not be finalized") } r.publishClarificationBundleUpdates(ctx, pendingID, finalized) return nil } func (r *Resolver) confirmLiveClarificationResponseDelivery( ctx context.Context, pendingID string, claim *clarificationResponseClaim, ) ([]*taskmodels.Message, error) { finalizedMessages, finalized := r.finalizeClarificationResponseDelivery(ctx, pendingID, claim) if !finalized { return nil, errors.New("clarification delivery confirmation failed") } return finalizedMessages, nil } func (r *Resolver) finalizeClarificationResponseDelivery( ctx context.Context, pendingID string, claim *clarificationResponseClaim, ) ([]*taskmodels.Message, bool) { persistenceCtx, cancel := clarificationPersistenceContext(ctx) defer cancel() finalizedMessages, finalized, err := r.messages.FinalizeClarificationResponseDelivery( persistenceCtx, pendingID, claim.terminalStatus, claim.messages, ) if err != nil { r.logger.Error("failed to finalize clarification response delivery", zap.String("pending_id", pendingID), zap.Error(err)) return nil, false } if !finalized { r.logger.Warn("clarification response delivery was not finalizable", zap.String("pending_id", pendingID)) return nil, false } // The initial claim (completeClaimedClarificationMessages) is the only // write that touches updated_at; finalization only clears an internal // delivery-recovery marker, so the column is stable from the moment the // claim commits. Stamp the winner's own response with that same // column value (claim.response is the same pointer returned to the // caller) so both it and a later caller's R2b reconstruction, which // re-reads this column, report the identical instant. if claim.response != nil && len(finalizedMessages) > 0 { claim.response.RespondedAt = finalizedMessages[0].UpdatedAt } return finalizedMessages, true } func (r *Resolver) clarificationClaimTurnID(pendingID string, messages []*taskmodels.Message) string { turnID, err := clarificationClaimTurnIdentity(messages) if err != nil { r.logger.Warn("clarification bundle has inconsistent turn IDs", zap.String("pending_id", pendingID), zap.Error(err)) return "" } return turnID } func clarificationClaimTurnIdentity(messages []*taskmodels.Message) (string, error) { turnID := "" turnMessageID := "" for _, message := range messages { if message == nil || message.ID == "" || message.TurnID == "" { continue } if turnID == "" { turnID = message.TurnID turnMessageID = message.ID continue } if message.TurnID != turnID { return "", fmt.Errorf( "clarification bundle mixes turn %q from message %s with turn %q from message %s", turnID, turnMessageID, message.TurnID, message.ID, ) } } return turnID, nil } func (r *Resolver) publishPrimaryAnsweredEvent( ctx context.Context, pendingID string, answers []Answer, rejected bool, rejectReason, clarificationTurnID string, ) { if r.eventBus == nil { return } persistenceCtx, cancel := clarificationPersistenceContext(ctx) defer cancel() clarificationCtx, err := r.resolveClarificationEventContext(persistenceCtx, pendingID) if err != nil { r.logger.Warn("failed to resolve context for primary clarification event", zap.String("pending_id", pendingID), zap.Error(err)) return } if clarificationCtx.SessionID == "" || clarificationCtx.TaskID == "" { r.logger.Warn("missing session/task for primary clarification event", zap.String("pending_id", pendingID), zap.String("session_id", clarificationCtx.SessionID), zap.String("task_id", clarificationCtx.TaskID)) return } answerText := buildAnswerSummary(clarificationCtx.Questions, answers, rejected, rejectReason) eventData := map[string]any{ metaSessionIDKey: clarificationCtx.SessionID, metaTaskIDKey: clarificationCtx.TaskID, metaPendingIDKey: pendingID, "clarification_turn_id": clarificationTurnID, metaQuestionKey: clarificationCtx.QuestionSummary, "answer_text": answerText, metaRejectedKey: rejected, "reject_reason": rejectReason, } if err := r.eventBus.Publish(persistenceCtx, events.ClarificationPrimaryAnswered, bus.NewEvent( events.ClarificationPrimaryAnswered, "clarification-resolver", eventData, )); err != nil { r.logger.Warn("failed to publish primary clarification event", zap.String("pending_id", pendingID), zap.String("session_id", clarificationCtx.SessionID), zap.Error(err)) } } func (r *Resolver) publishClarificationBundleUpdates( ctx context.Context, pendingID string, messages []*taskmodels.Message, ) { publishCtx, cancel := clarificationPersistenceContext(ctx) defer cancel() if err := r.messages.PublishClarificationBundleUpdates(publishCtx, messages); err != nil { r.logger.Error("failed to publish clarification bundle updates", zap.String("pending_id", pendingID), zap.Error(err)) } } func (r *Resolver) restoreFailedClarificationClaim( ctx context.Context, pendingID, terminalStatus string, claimedMessages []*taskmodels.Message, ) bool { persistenceCtx, cancel := clarificationPersistenceContext(ctx) defer cancel() restoredMessages, restored, err := r.messages.RestoreActiveClarificationBundle( persistenceCtx, pendingID, terminalStatus, claimedMessages, ) if err != nil { r.logger.Error("failed to restore clarification after delivery failure", zap.String("pending_id", pendingID), zap.Error(err)) return false } if !restored { r.logger.Warn("clarification claim was not restorable after delivery failure", zap.String("pending_id", pendingID)) return false } if err := r.messages.PublishClarificationBundleUpdates(persistenceCtx, restoredMessages); err != nil { r.logger.Error("failed to converge restored clarification state", zap.String("pending_id", pendingID), zap.Error(err)) // The durable bundle is pending again, so retry remains safe even when // its best-effort live projection could not be acknowledged. return true } return true } func (r *Resolver) publishStaleDismissedEvent(ctx context.Context, pendingID string) { if r.eventBus == nil { return } persistenceCtx, cancel := clarificationPersistenceContext(ctx) defer cancel() clarificationCtx, err := r.resolveClarificationEventContext(persistenceCtx, pendingID) if err != nil || clarificationCtx.SessionID == "" || clarificationCtx.TaskID == "" { r.logger.Warn("failed to resolve context for stale-dismissed clarification event", zap.String("pending_id", pendingID), zap.Error(err)) return } eventData := map[string]any{ "session_id": clarificationCtx.SessionID, "task_id": clarificationCtx.TaskID, "pending_id": pendingID, } if err := r.eventBus.Publish(persistenceCtx, events.ClarificationStaleDismissed, bus.NewEvent( events.ClarificationStaleDismissed, "clarification-resolver", eventData, )); err != nil { r.logger.Warn("failed to publish stale-dismissed clarification event", zap.String("pending_id", pendingID), zap.String("session_id", clarificationCtx.SessionID), zap.Error(err)) } } // resumeDetachedClarification synchronously asks the orchestrator to accept a // new turn. Used when the original clarification waiter has gone away. func (r *Resolver) resumeDetachedClarification( ctx context.Context, pendingID string, answers []Answer, rejected bool, rejectReason string, claimedMessages []*taskmodels.Message, ) error { if r.detachedResumer == nil { return errors.New("detached clarification resumer unavailable") } persistenceCtx, cancel := clarificationPersistenceContext(ctx) defer cancel() clarificationCtx, err := r.resolveClarificationEventContext(persistenceCtx, pendingID) if err != nil { return fmt.Errorf("resolve clarification fallback context: %w", err) } if clarificationCtx.SessionID == "" || clarificationCtx.TaskID == "" { return fmt.Errorf( "missing session/task for clarification fallback event: session=%q task=%q", clarificationCtx.SessionID, clarificationCtx.TaskID, ) } answerText := buildAnswerSummary(clarificationCtx.Questions, answers, rejected, rejectReason) clarificationTurnID, claimedMessageIDs, err := clarificationClaimRecovery(claimedMessages) if err != nil { return fmt.Errorf("build clarification claim recovery: %w", err) } request := DetachedClarificationResume{ SessionID: clarificationCtx.SessionID, TaskID: clarificationCtx.TaskID, PendingID: pendingID, ClarificationTurnID: clarificationTurnID, ClaimedMessageIDs: claimedMessageIDs, Question: clarificationCtx.QuestionSummary, AnswerText: answerText, Rejected: rejected, RejectReason: rejectReason, } if err := r.detachedResumer.ResumeDetachedClarification(persistenceCtx, request); err != nil { return fmt.Errorf("resume detached clarification: %w", err) } r.logger.Info("clarification answered via acknowledged fallback (new turn)", zap.String("pending_id", pendingID), zap.String("session_id", clarificationCtx.SessionID), zap.String("task_id", clarificationCtx.TaskID)) return nil } func clarificationClaimRecovery(messages []*taskmodels.Message) (string, []string, error) { turnID, err := clarificationClaimTurnIdentity(messages) if err != nil { return "", nil, err } messageIDs := make([]string, 0, len(messages)) for _, message := range messages { if message == nil || message.ID == "" { continue } messageIDs = append(messageIDs, message.ID) } return turnID, messageIDs, nil } func detachedResumeWasAccepted(err error) bool { var accepted interface{ DetachedResumeAccepted() bool } return errors.As(err, &accepted) && accepted.DetachedResumeAccepted() } type clarificationEventContext struct { SessionID string TaskID string Questions []Question // Source-of-truth questions used to label answers; falls back to a single synthetic Question when only metadata is available. QuestionSummary string // Pre-formatted multi-line "Q1: ...\nQ2: ..." used by the orchestrator resume prompt. } func (r *Resolver) resolveClarificationEventContext(ctx context.Context, pendingID string) (clarificationEventContext, error) { var out clarificationEventContext if req, ok := r.store.GetRequest(pendingID); ok && req != nil { out.SessionID = req.SessionID out.TaskID = req.TaskID out.Questions = req.Questions out.QuestionSummary = formatQuestionSummary(req.Questions) if out.SessionID != "" && out.TaskID != "" && len(out.Questions) > 0 { return out, nil } } if r.repo == nil { return out, fmt.Errorf("message repository unavailable") } msgs, err := r.repo.FindMessagesByPendingID(ctx, pendingID) if err != nil { return out, err } if len(msgs) == 0 { return out, fmt.Errorf("no messages for pending_id %s", pendingID) } if out.SessionID == "" { out.SessionID = msgs[0].TaskSessionID } if out.TaskID == "" { out.TaskID = msgs[0].TaskID } if len(out.Questions) == 0 { out.Questions = questionsFromMessages(msgs) out.QuestionSummary = formatQuestionSummary(out.Questions) } return out, nil } // questionsFromMessages reconstructs a Question slice from persisted // clarification messages, ordered by metadata.question_index so the rebuilt // summary matches the bundle the agent originally sent. Used as a fallback // when the in-store request has been cleaned up but the persisted metadata // still carries the original question text. Unlike bundleQuestions // (bundle_question.go), used for validation and answer application, this // does not parse options: it feeds only the resume-summary text. func questionsFromMessages(msgs []*taskmodels.Message) []Question { sorted := make([]*taskmodels.Message, len(msgs)) copy(sorted, msgs) sort.SliceStable(sorted, func(i, j int) bool { return questionIndexFromMetadata(sorted[i].Metadata) < questionIndexFromMetadata(sorted[j].Metadata) }) out := make([]Question, 0, len(sorted)) for _, m := range sorted { out = append(out, questionFromMessageMetadata(m.Metadata)) } return out } func questionFromMessageMetadata(meta map[string]any) Question { q := Question{ID: stringFromMetadata(meta, metaQuestionIDKey)} qData, ok := meta[metaQuestionKey].(map[string]any) if !ok { return q } if v, ok := qData["prompt"].(string); ok { q.Prompt = v } if v, ok := qData["title"].(string); ok { q.Title = v } if q.ID == "" { if v, ok := qData["id"].(string); ok { q.ID = v } } return q } func questionIndexFromMetadata(meta map[string]any) int { if meta == nil { return 0 } switch v := meta["question_index"].(type) { case int: return v case int64: return int(v) case float64: return int(v) } return 0 } func formatQuestionSummary(questions []Question) string { if len(questions) == 0 { return "" } if len(questions) == 1 { return questions[0].Prompt } parts := make([]string, 0, len(questions)) for i, q := range questions { parts = append(parts, fmt.Sprintf("Q%d: %s", i+1, q.Prompt)) } return strings.Join(parts, "\n") } // buildAnswerSummary constructs a human-readable summary of the user's response // across every question in the bundle. Used in the orchestrator resume prompt // and for chat history rendering. func buildAnswerSummary(questions []Question, answers []Answer, rejected bool, rejectReason string) string { if rejected { if rejectReason != "" { return fmt.Sprintf("User declined to answer. Reason: %s", rejectReason) } return "User declined to answer." } if len(answers) == 0 { return "User provided no specific answer." } if len(questions) <= 1 && len(answers) == 1 { return formatSingleAnswer(answers[0]) } answersByID := make(map[string]Answer, len(answers)) for _, a := range answers { answersByID[a.QuestionID] = a } parts := make([]string, 0, len(answers)) for i, q := range questions { ans, ok := answersByID[q.ID] if !ok { continue } parts = append(parts, fmt.Sprintf("A%d: %s", i+1, formatAnswerBody(ans))) } if len(parts) == 0 { // No matches by id — fall back to positional formatting so we still // surface the answers rather than silently dropping them. for i, a := range answers { parts = append(parts, fmt.Sprintf("A%d: %s", i+1, formatAnswerBody(a))) } } return strings.Join(parts, "\n") } func formatSingleAnswer(a Answer) string { if a.CustomText != "" { return fmt.Sprintf("User answered: %s", a.CustomText) } if len(a.SelectedOptions) > 0 { return fmt.Sprintf("User selected: %v", a.SelectedOptions) } return "User provided no specific answer." } func formatAnswerBody(a Answer) string { if a.CustomText != "" { return a.CustomText } if len(a.SelectedOptions) > 0 { return fmt.Sprintf("%v", a.SelectedOptions) } return "(no answer)" }