diff --git a/internal/feishuadapter/adapter.go b/internal/feishuadapter/adapter.go index ba5e1671..d02edad9 100644 --- a/internal/feishuadapter/adapter.go +++ b/internal/feishuadapter/adapter.go @@ -32,6 +32,65 @@ type approvalEntry struct { Decision string // "pending", "allow_once", "reject" } +// approvalRequestState 定义审批请求在严格状态机中的生命周期状态。 +type approvalRequestState string + +const ( + approvalRequestStateQueued approvalRequestState = "queued" + approvalRequestStateDisplayingPending approvalRequestState = "displaying_pending" + approvalRequestStateResolving approvalRequestState = "resolving" + approvalRequestStateResolvedApproved approvalRequestState = "resolved_approved" + approvalRequestStateResolvedRejected approvalRequestState = "resolved_rejected" + approvalRequestStateArchived approvalRequestState = "archived" +) + +// approvalRequestNode 保存单个审批请求在状态机中的状态与渲染元数据。 +type approvalRequestNode struct { + RequestID string + ToolName string + Operation string + Target string + Reason string + Decision string + State approvalRequestState + UpdatedVer int64 +} + +// approvalFSMState 是 run 级审批状态唯一真相,集中维护 active/pending/requests。 +type approvalFSMState struct { + Generation int64 + Version int64 + CardID string + ActiveRequestID string + PendingStack []string + Requests map[string]approvalRequestNode +} + +// approvalOutboxKind 表示审批状态机迁移后需要执行的网络副作用类型。 +type approvalOutboxKind string + +const ( + approvalOutboxUpdateStatusCard approvalOutboxKind = "update_status_card" + approvalOutboxSendPermissionCard approvalOutboxKind = "send_permission_card" + approvalOutboxUpdatePendingCard approvalOutboxKind = "update_pending_card" + approvalOutboxUpdateResolvedCard approvalOutboxKind = "update_resolved_card" + approvalOutboxUpdateResolvedRecord approvalOutboxKind = "update_resolved_record" +) + +// approvalOutboxOperation 承载一次迁移后待执行的网络副作用快照。 +type approvalOutboxOperation struct { + RunKey string + Generation int64 + Version int64 + Kind approvalOutboxKind + ChatID string + CardID string + RequestID string + PendingCard PermissionCardPayload + Resolved ResolvedPermissionCardPayload + StatusCard StatusCardPayload +} + type userQuestionEntry struct { RequestID string QuestionID string @@ -70,22 +129,24 @@ type Adapter struct { nowFn func() time.Time - mu sync.RWMutex - activeRuns map[string]sessionBinding - sessionChats map[string]string - requestRuns map[string]string - lastProgressAt map[string]time.Time - permissionCards map[string]string // requestID -> card message_id - runPermissionCards map[string]string // runKey -> card message_id - // runPermissionCardHistory 记录一个 run 曾经创建/复用过的所有审批卡,用于终态时统一收敛,避免旧卡残留。 + mu sync.RWMutex + activeRuns map[string]sessionBinding + sessionChats map[string]string + requestRuns map[string]string + lastProgressAt map[string]time.Time + userQuestionCards map[string]string // requestID -> card message_id + pendingQuestions map[string]userQuestionEntry + + // approvalFSMByRun 是审批状态唯一真相,按 runKey 管理审批生命周期。 + approvalFSMByRun map[string]*approvalFSMState + // approvalRequestRunIndex 建立 run 内 request_id 到 runKey 的索引。 + approvalRequestRunIndex map[string]string // scoped request key(runKey|requestID) -> runKey + // approvalRequestIDRunIndex 为回调严格校验提供 request_id 到 runKey 的快速索引。 + approvalRequestIDRunIndex map[string]string // request_id -> runKey + // approvalCardRunIndex 建立审批卡 message_id 到 runKey 的索引。 + approvalCardRunIndex map[string]string // card_id -> runKey + // runPermissionCardHistory 记录一个 run 曾经创建/复用过的所有审批卡,用于终态统一收敛。 runPermissionCardHistory map[string]map[string]struct{} // runKey -> set(card message_id) - permissionCardRuns map[string]string // card message_id -> runKey - // runPermissionActiveRequest 记录当前审批卡正在展示的 request_id,用于避免旧审批回写覆盖新审批。 - runPermissionActiveRequest map[string]string // runKey -> requestID - // resolvedPermissions 记录已完成审批的 request_id,避免重复事件将状态回滚为 pending。 - resolvedPermissions map[string]string // requestID -> decision - userQuestionCards map[string]string // requestID -> card message_id - pendingQuestions map[string]userQuestionEntry permissionCardDismissDelay time.Duration } @@ -115,14 +176,13 @@ func New(cfg Config, gateway GatewayClient, messenger Messenger, logger *log.Log sessionChats: make(map[string]string), requestRuns: make(map[string]string), lastProgressAt: make(map[string]time.Time), - permissionCards: make(map[string]string), - runPermissionCards: make(map[string]string), - runPermissionCardHistory: make(map[string]map[string]struct{}), - permissionCardRuns: make(map[string]string), - runPermissionActiveRequest: make(map[string]string), - resolvedPermissions: make(map[string]string), userQuestionCards: make(map[string]string), pendingQuestions: make(map[string]userQuestionEntry), + approvalFSMByRun: make(map[string]*approvalFSMState), + approvalRequestRunIndex: make(map[string]string), + approvalRequestIDRunIndex: make(map[string]string), + approvalCardRunIndex: make(map[string]string), + runPermissionCardHistory: make(map[string]map[string]struct{}), permissionCardDismissDelay: defaultPermissionCardDismissDelay, }, nil } @@ -272,12 +332,16 @@ func (a *Adapter) HandleCardAction(ctx context.Context, event FeishuCardActionEv if !isApprovalApprovedDecision(decision) && !isApprovalRejectedDecision(decision) { return nil } - resolvedRequestID, err := a.resolvePermissionWithFallback(callCtx, requestID, strings.TrimSpace(event.CardID), decision) + resolved, err := a.applyPermissionDecisionStrict(callCtx, requestID, strings.TrimSpace(event.CardID), decision) if err != nil { a.safeLog("resolve permission failed: %v", err) return err } - a.updateApprovalStatus(resolvedRequestID, decision) + if !resolved { + // 严格状态机下不匹配的回调直接忽略。 + succeeded = true + return nil + } case "user_question": status := strings.TrimSpace(strings.ToLower(event.Status)) if status == "" { @@ -300,8 +364,8 @@ func (a *Adapter) HandleCardAction(ctx context.Context, event FeishuCardActionEv return nil } -// resolvePermissionWithFallback 优先按卡片回调 request_id 提交审批;若请求已过期则回退到当前待审批请求。 -func (a *Adapter) resolvePermissionWithFallback( +// resolvePermissionStrict 仅在回调匹配当前 run 的 active displaying_pending 请求时提交审批。 +func (a *Adapter) resolvePermissionStrict( ctx context.Context, requestID string, cardID string, @@ -312,114 +376,102 @@ func (a *Adapter) resolvePermissionWithFallback( return "", nil } normalizedDecision := normalizeApprovalDecision(decision) - if a.isResolvedPermissionDecision(normalizedRequestID, normalizedDecision) { - a.safeLog( - "permission action ignored for already resolved request request_id=%s decision=%s", - normalizedRequestID, - normalizedDecision, - ) - return normalizedRequestID, nil - } - - primaryErr := a.gateway.ResolvePermission(ctx, normalizedRequestID, decision) - if primaryErr == nil { - return normalizedRequestID, nil - } - if a.isResolvedPermissionDecision(normalizedRequestID, normalizedDecision) { + runKey, ok := a.validatePermissionCallbackStrict(normalizedRequestID, strings.TrimSpace(cardID)) + if !ok { a.safeLog( - "permission request already resolved after primary error request_id=%s decision=%s err=%v", + "permission callback ignored by strict fsm request_id=%s card_id=%s", normalizedRequestID, - normalizedDecision, - primaryErr, + strings.TrimSpace(cardID), ) - return normalizedRequestID, nil - } - if !isPermissionRequestNotFoundError(primaryErr) { - return "", primaryErr - } - - fallbackRequestID := a.lookupPendingPermissionRequestByCard(normalizedRequestID, strings.TrimSpace(cardID)) - if fallbackRequestID == "" || fallbackRequestID == normalizedRequestID { - a.safeLog("permission request expired request_id=%s card_id=%s", normalizedRequestID, strings.TrimSpace(cardID)) - return normalizedRequestID, nil + return "", nil } - - fallbackErr := a.gateway.ResolvePermission(ctx, fallbackRequestID, decision) - if fallbackErr != nil { - if isPermissionRequestNotFoundError(primaryErr) && isPermissionRequestNotFoundError(fallbackErr) { + if err := a.gateway.ResolvePermission(ctx, normalizedRequestID, decision); err != nil { + if isPermissionRequestNotFoundError(err) { + // 目标请求已被消费/关闭时保持幂等,不做 remap。 a.safeLog( - "permission fallback request expired request_id=%s fallback_request_id=%s card_id=%s", + "permission callback strict target not found request_id=%s card_id=%s err=%v", normalizedRequestID, - fallbackRequestID, strings.TrimSpace(cardID), + err, ) - return fallbackRequestID, nil + return "", nil } - return "", fallbackErr + return "", err } a.safeLog( - "permission request remapped request_id=%s resolved_request_id=%s card_id=%s primary_err=%v", + "permission callback strict resolved run_key=%s request_id=%s decision=%s", + runKey, normalizedRequestID, - fallbackRequestID, - strings.TrimSpace(cardID), - primaryErr, + normalizedDecision, ) - return fallbackRequestID, nil + return normalizedRequestID, nil } -// isResolvedPermissionDecision 判断 request 是否已按同一决议完成,避免旧卡回调误触发 fallback 审批下一条请求。 -func (a *Adapter) isResolvedPermissionDecision(requestID string, decision string) bool { - normalizedRequestID := strings.TrimSpace(requestID) - if normalizedRequestID == "" { - return false +// applyPermissionDecisionStrict 执行严格审批决议;返回是否真正提交并完成状态迁移。 +func (a *Adapter) applyPermissionDecisionStrict( + ctx context.Context, + requestID string, + cardID string, + decision string, +) (bool, error) { + resolvedRequestID, err := a.resolvePermissionStrict(ctx, requestID, cardID, decision) + if err != nil { + return false, err } - a.mu.RLock() - resolvedDecision, ok := a.resolvedPermissions[normalizedRequestID] - a.mu.RUnlock() - if !ok { - return false + if strings.TrimSpace(resolvedRequestID) == "" { + return false, nil } - return normalizeApprovalDecision(resolvedDecision) == normalizeApprovalDecision(decision) + a.updateApprovalStatus(resolvedRequestID, decision) + return true, nil } -// lookupPendingPermissionRequestByCard 从当前运行状态中定位仍待处理的审批 request_id。 -func (a *Adapter) lookupPendingPermissionRequestByCard(requestID string, cardID string) string { +// validatePermissionCallbackStrict 按严格状态机校验审批回调是否命中当前 active pending 请求。 +func (a *Adapter) validatePermissionCallbackStrict(requestID string, cardID string) (string, bool) { + normalizedRequestID := strings.TrimSpace(requestID) + if normalizedRequestID == "" { + return "", false + } + normalizedCardID := strings.TrimSpace(cardID) + a.mu.RLock() defer a.mu.RUnlock() - runKey := strings.TrimSpace(a.requestRuns[strings.TrimSpace(requestID)]) - if runKey == "" && strings.TrimSpace(cardID) != "" { - runKey = strings.TrimSpace(a.permissionCardRuns[strings.TrimSpace(cardID)]) + + runKeyByRequest := strings.TrimSpace(a.resolveApprovalRunKeyByRequestLocked(normalizedRequestID)) + runKeyByCard := strings.TrimSpace(a.approvalCardRunIndex[normalizedCardID]) + runKey := runKeyByRequest + if runKey == "" { + runKey = runKeyByCard } if runKey == "" { - return "" + return "", false } - binding, ok := a.activeRuns[runKey] + if runKeyByRequest != "" && runKeyByCard != "" && runKeyByRequest != runKeyByCard { + return "", false + } + fsm := a.approvalFSMByRun[runKey] + if fsm == nil { + return "", false + } + if normalizedCardID != "" && strings.TrimSpace(fsm.CardID) != normalizedCardID { + return "", false + } + if strings.TrimSpace(fsm.ActiveRequestID) != normalizedRequestID { + return "", false + } + requestNode, ok := fsm.Requests[normalizedRequestID] if !ok { - return "" + return "", false } - activeRequestID := strings.TrimSpace(a.runPermissionActiveRequest[runKey]) - if activeRequestID != "" { - for _, entry := range binding.ApprovalRecords { - if strings.TrimSpace(entry.RequestID) != activeRequestID { - continue - } - if !isApprovalPendingDecision(entry.Decision) { - break - } - return activeRequestID - } + if requestNode.State == approvalRequestStateResolvedApproved || requestNode.State == approvalRequestStateResolvedRejected { + return runKey, false } - for _, entry := range binding.ApprovalRecords { - candidate := strings.TrimSpace(entry.RequestID) - if candidate == "" { - continue - } - if !isApprovalPendingDecision(entry.Decision) { - continue - } - return candidate + if requestNode.State != approvalRequestStateDisplayingPending { + return "", false } - return "" + if strings.TrimSpace(a.approvalRequestRunIndex[approvalRequestScopedKey(runKey, normalizedRequestID)]) != runKey { + return "", false + } + return runKey, true } // rememberRunPermissionCardLocked 记录 run 关联过的审批卡,用于后续统一收敛旧卡。 @@ -595,26 +647,34 @@ func (a *Adapter) untrackRun(sessionID string, runID string) { a.mu.Lock() defer a.mu.Unlock() key := runBindingKey(sessionID, runID) - if binding, ok := a.activeRuns[key]; ok { + if _, ok := a.activeRuns[key]; ok { for requestID, requestRunKey := range a.requestRuns { if requestRunKey == key { - if cardID := strings.TrimSpace(a.permissionCards[requestID]); cardID != "" { - delete(a.permissionCardRuns, cardID) - } delete(a.requestRuns, requestID) - delete(a.permissionCards, requestID) delete(a.userQuestionCards, requestID) delete(a.pendingQuestions, requestID) } } - if cardID := strings.TrimSpace(a.runPermissionCards[key]); cardID != "" { - delete(a.permissionCardRuns, cardID) + for scopedKey, runKey := range a.approvalRequestRunIndex { + if strings.TrimSpace(runKey) != key { + continue + } + delete(a.approvalRequestRunIndex, scopedKey) + if idx := strings.LastIndex(scopedKey, "|"); idx >= 0 && idx+1 < len(scopedKey) { + requestID := strings.TrimSpace(scopedKey[idx+1:]) + if strings.TrimSpace(a.approvalRequestIDRunIndex[requestID]) == key { + delete(a.approvalRequestIDRunIndex, requestID) + } + } } - delete(a.runPermissionCards, key) + for cardID, runKey := range a.approvalCardRunIndex { + if strings.TrimSpace(runKey) == key { + delete(a.approvalCardRunIndex, cardID) + } + } + delete(a.approvalFSMByRun, key) delete(a.runPermissionCardHistory, key) - delete(a.runPermissionActiveRequest, key) delete(a.lastProgressAt, key) - _ = binding } delete(a.activeRuns, key) } @@ -671,21 +731,7 @@ func (a *Adapter) handleGatewayEvent(ctx context.Context, raw json.RawMessage) { if strings.EqualFold(runtimeType, "permission_requested") { requestID, toolName, operation, target, reason := extractPermissionRequest(envelope) if requestID != "" { - if a.markPermissionPending(sessionID, runID, requestID, toolName, operation, target, reason) { - a.upsertPermissionCard( - ctx, - sessionID, - runID, - chatID, - PermissionCardPayload{ - RequestID: requestID, - ToolName: toolName, - Operation: operation, - Target: target, - Message: reason, - }, - ) - } + a.processPermissionRequested(ctx, sessionID, runID, chatID, requestID, toolName, operation, target, reason) return } } else if strings.EqualFold(runtimeType, "permission_resolved") { @@ -939,157 +985,453 @@ func (a *Adapter) handleRunProgressCard(ctx context.Context, sessionID string, r } } -// markPermissionPending 将权限请求映射到 run 卡片,并决定是否需要刷新审批交互卡片。 -// 同一 run 只允许一个"当前审批"占用审批卡,其余请求先留在状态卡队列中,待当前审批处理后再切换。 -func (a *Adapter) markPermissionPending( +// processPermissionRequested 处理 permission_requested 事件,状态迁移在锁内完成,卡片更新通过 outbox 在锁外执行。 +func (a *Adapter) processPermissionRequested( + ctx context.Context, sessionID string, runID string, + chatID string, requestID string, toolName string, operation string, target string, reason string, -) bool { +) { + ops := a.transitionPermissionRequested(sessionID, runID, chatID, requestID, toolName, operation, target, reason) + a.executeApprovalOutbox(ctx, ops) +} + +// transitionPermissionRequested 在锁内执行 pending 入队与 active 选举,并返回副作用 outbox。 +func (a *Adapter) transitionPermissionRequested( + sessionID string, + runID string, + chatID string, + requestID string, + toolName string, + operation string, + target string, + reason string, +) []approvalOutboxOperation { normalizedRequestID := strings.TrimSpace(requestID) if normalizedRequestID == "" { - return false + return nil } - key := runBindingKey(sessionID, runID) + runKey := runBindingKey(sessionID, runID) a.mu.Lock() - binding, ok := a.activeRuns[key] + defer a.mu.Unlock() + binding, ok := a.activeRuns[runKey] if !ok { - a.mu.Unlock() - return false + return nil } - if _, resolved := a.resolvedPermissions[normalizedRequestID]; resolved { - a.mu.Unlock() - return false + fsm := a.ensureApprovalFSMLocked(runKey) + fsm.Version++ + node, exists := fsm.Requests[normalizedRequestID] + if !exists { + node = approvalRequestNode{ + RequestID: normalizedRequestID, + State: approvalRequestStateQueued, + Decision: "pending", + } + } else if node.State == approvalRequestStateResolvedApproved || + node.State == approvalRequestStateResolvedRejected || + node.State == approvalRequestStateArchived { + // 已完成请求禁止回退到 pending。 + return nil + } + if strings.TrimSpace(toolName) != "" { + node.ToolName = strings.TrimSpace(toolName) } + if strings.TrimSpace(operation) != "" { + node.Operation = strings.TrimSpace(operation) + } + if strings.TrimSpace(target) != "" { + node.Target = strings.TrimSpace(target) + } + if strings.TrimSpace(reason) != "" { + node.Reason = strings.TrimSpace(reason) + binding.LastSummary = strings.TrimSpace(reason) + } + node.Decision = "pending" + node.UpdatedVer = fsm.Version - alreadyPending := false - binding.ApprovalStatus = "pending" - found := false - for i := range binding.ApprovalRecords { - if binding.ApprovalRecords[i].RequestID != normalizedRequestID { - continue + if strings.TrimSpace(fsm.ActiveRequestID) == "" { + node.State = approvalRequestStateDisplayingPending + fsm.ActiveRequestID = normalizedRequestID + } else if strings.TrimSpace(fsm.ActiveRequestID) == normalizedRequestID { + node.State = approvalRequestStateDisplayingPending + } else if !exists || node.State == approvalRequestStateQueued { + node.State = approvalRequestStateQueued + if !containsApprovalRequest(fsm.PendingStack, normalizedRequestID) { + fsm.PendingStack = append(fsm.PendingStack, normalizedRequestID) } - found = true - alreadyPending = isApprovalPendingDecision(binding.ApprovalRecords[i].Decision) - if strings.TrimSpace(binding.ApprovalRecords[i].ToolName) == "" && strings.TrimSpace(toolName) != "" { - binding.ApprovalRecords[i].ToolName = strings.TrimSpace(toolName) + } + + fsm.Requests[normalizedRequestID] = node + a.approvalRequestRunIndex[approvalRequestScopedKey(runKey, normalizedRequestID)] = runKey + a.approvalRequestIDRunIndex[normalizedRequestID] = runKey + + a.syncBindingApprovalsFromFSMLocked(&binding, fsm) + a.activeRuns[runKey] = binding + + ops := make([]approvalOutboxOperation, 0, 2) + if strings.TrimSpace(binding.CardID) != "" { + ops = append(ops, approvalOutboxOperation{ + RunKey: runKey, + Generation: fsm.Generation, + Version: fsm.Version, + Kind: approvalOutboxUpdateStatusCard, + CardID: strings.TrimSpace(binding.CardID), + StatusCard: binding.statusCardPayload(), + }) + } + + if strings.TrimSpace(fsm.ActiveRequestID) == normalizedRequestID { + activeNode := fsm.Requests[normalizedRequestID] + pendingPayload := PermissionCardPayload{ + RequestID: normalizedRequestID, + ToolName: activeNode.ToolName, + Operation: activeNode.Operation, + Target: activeNode.Target, + Message: activeNode.Reason, + } + if strings.TrimSpace(fsm.CardID) == "" { + ops = append(ops, approvalOutboxOperation{ + RunKey: runKey, + Generation: fsm.Generation, + Version: fsm.Version, + Kind: approvalOutboxSendPermissionCard, + ChatID: strings.TrimSpace(chatID), + RequestID: normalizedRequestID, + PendingCard: pendingPayload, + }) + } else { + ops = append(ops, approvalOutboxOperation{ + RunKey: runKey, + Generation: fsm.Generation, + Version: fsm.Version, + Kind: approvalOutboxUpdatePendingCard, + CardID: strings.TrimSpace(fsm.CardID), + RequestID: normalizedRequestID, + PendingCard: pendingPayload, + }) } - if strings.TrimSpace(binding.ApprovalRecords[i].Operation) == "" && strings.TrimSpace(operation) != "" { - binding.ApprovalRecords[i].Operation = strings.TrimSpace(operation) + } + return ops +} + +// executeApprovalOutbox 执行审批状态机迁移后产生的网络副作用,并做 generation/version 确认。 +func (a *Adapter) executeApprovalOutbox(ctx context.Context, ops []approvalOutboxOperation) { + for _, op := range ops { + if !a.shouldExecuteApprovalOutbox(op) { + continue } - if strings.TrimSpace(binding.ApprovalRecords[i].Target) == "" && strings.TrimSpace(target) != "" { - binding.ApprovalRecords[i].Target = strings.TrimSpace(target) + var err error + switch op.Kind { + case approvalOutboxUpdateStatusCard: + if strings.TrimSpace(op.CardID) == "" { + continue + } + err = a.messenger.UpdateCard(ctx, op.CardID, op.StatusCard) + case approvalOutboxSendPermissionCard: + if strings.TrimSpace(op.ChatID) == "" { + continue + } + var cardID string + cardID, err = a.messenger.SendPermissionCard(ctx, op.ChatID, op.PendingCard) + if err == nil && strings.TrimSpace(cardID) != "" { + normalizedCardID := strings.TrimSpace(cardID) + shouldAttach := false + a.mu.Lock() + fsm := a.approvalFSMByRun[op.RunKey] + if fsm != nil && fsm.Generation == op.Generation && fsm.Version == op.Version { + shouldAttach = true + fsm.CardID = normalizedCardID + a.approvalCardRunIndex[fsm.CardID] = op.RunKey + a.rememberRunPermissionCardLocked(op.RunKey, fsm.CardID) + } + a.mu.Unlock() + if !shouldAttach { + a.cleanupStalePermissionCard(op, normalizedCardID) + } + } + case approvalOutboxUpdatePendingCard: + if strings.TrimSpace(op.CardID) == "" { + continue + } + err = a.messenger.UpdatePendingPermissionCard(ctx, op.CardID, op.PendingCard) + case approvalOutboxUpdateResolvedCard, approvalOutboxUpdateResolvedRecord: + if strings.TrimSpace(op.CardID) == "" { + continue + } + err = a.messenger.UpdatePermissionCard(ctx, op.CardID, op.Resolved) } - if strings.TrimSpace(reason) != "" { - binding.ApprovalRecords[i].Reason = strings.TrimSpace(reason) + + if err != nil { + a.safeLog("approval outbox failed kind=%s run_key=%s request_id=%s err=%v", op.Kind, op.RunKey, op.RequestID, err) + continue } - break + a.confirmApprovalOutbox(op) } - if !found { - binding.ApprovalRecords = append(binding.ApprovalRecords, approvalEntry{ - RequestID: normalizedRequestID, - ToolName: strings.TrimSpace(toolName), - Operation: strings.TrimSpace(operation), - Target: strings.TrimSpace(target), - Reason: strings.TrimSpace(reason), - Decision: "pending", - }) +} + +// cleanupStalePermissionCard 在发送审批卡后若发现版本已过期,则立即回收该游离卡片。 +func (a *Adapter) cleanupStalePermissionCard(op approvalOutboxOperation, cardID string) { + normalizedCardID := strings.TrimSpace(cardID) + if normalizedCardID == "" { + return } - if strings.TrimSpace(reason) != "" { - binding.LastSummary = strings.TrimSpace(reason) + timeout := a.cfg.RequestTimeout + if timeout <= 0 { + timeout = 3 * time.Second } - - activeRequestID := strings.TrimSpace(a.runPermissionActiveRequest[key]) - - shouldDisplayOnPermissionCard := false - switch { - case activeRequestID == "": - a.runPermissionActiveRequest[key] = normalizedRequestID - shouldDisplayOnPermissionCard = true - case activeRequestID == normalizedRequestID: - shouldDisplayOnPermissionCard = true - case !isApprovalPendingDecision(findApprovalDecision(binding.ApprovalRecords, activeRequestID)): - // 当前卡面若已是"已审批"态,允许新请求直接抢占刷新为待审批。 - a.runPermissionActiveRequest[key] = normalizedRequestID - shouldDisplayOnPermissionCard = true - default: - // 当前已有待审批请求正在展示,新请求仅记录到状态卡队列。 - shouldDisplayOnPermissionCard = false + callCtx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + if err := a.messenger.DeleteMessage(callCtx, normalizedCardID); err != nil { + a.safeLog( + "cleanup stale approval card failed kind=%s run_key=%s request_id=%s card_id=%s err=%v", + op.Kind, + op.RunKey, + op.RequestID, + normalizedCardID, + err, + ) + return } - a.activeRuns[key] = binding - a.requestRuns[normalizedRequestID] = key + a.safeLog( + "cleanup stale approval card success kind=%s run_key=%s request_id=%s card_id=%s", + op.Kind, + op.RunKey, + op.RequestID, + normalizedCardID, + ) +} - cardID := "" - payload := StatusCardPayload{} - cardID = strings.TrimSpace(binding.CardID) - payload = binding.statusCardPayload() - a.mu.Unlock() - if cardID != "" { - if err := a.messenger.UpdateCard(context.Background(), cardID, payload); err != nil { - a.safeLog("update pending approval card failed: %v", err) - } +// shouldExecuteApprovalOutbox 在发送副作用前进行代际/版本栅栏校验,避免旧 outbox 覆写新状态。 +func (a *Adapter) shouldExecuteApprovalOutbox(op approvalOutboxOperation) bool { + currentGeneration, currentVersion, ok := a.snapshotApprovalFSMVersion(op.RunKey) + if !ok { + return false } - if found && alreadyPending { + if currentGeneration != op.Generation || currentVersion != op.Version { + a.safeLog( + "approval outbox stale preflight dropped kind=%s run_key=%s op_gen=%d op_ver=%d current_gen=%d current_ver=%d", + op.Kind, + op.RunKey, + op.Generation, + op.Version, + currentGeneration, + currentVersion, + ) return false } - return shouldDisplayOnPermissionCard + return true } -// upsertPermissionCard 在同一 run 内复用一张审批卡片,后续审批请求覆盖刷新该卡片。 -func (a *Adapter) upsertPermissionCard( - ctx context.Context, - sessionID string, - runID string, - chatID string, - payload PermissionCardPayload, -) { - key := runBindingKey(sessionID, runID) - normalizedRequestID := strings.TrimSpace(payload.RequestID) - if key == "|" || normalizedRequestID == "" { - return +// snapshotApprovalFSMVersion 获取当前 run 对应审批状态机的 generation/version 快照。 +func (a *Adapter) snapshotApprovalFSMVersion(runKey string) (int64, int64, bool) { + normalizedRunKey := strings.TrimSpace(runKey) + if normalizedRunKey == "" { + return 0, 0, false } + a.mu.RLock() + fsm := a.approvalFSMByRun[normalizedRunKey] + if fsm == nil { + a.mu.RUnlock() + return 0, 0, false + } + generation := fsm.Generation + version := fsm.Version + a.mu.RUnlock() + return generation, version, true +} +// confirmApprovalOutbox 校验副作用确认是否仍匹配当前 FSM 代际与版本。 +func (a *Adapter) confirmApprovalOutbox(op approvalOutboxOperation) { a.mu.RLock() - existingCardID := strings.TrimSpace(a.runPermissionCards[key]) + fsm := a.approvalFSMByRun[op.RunKey] + if fsm == nil { + a.mu.RUnlock() + return + } + currentGeneration := fsm.Generation + currentVersion := fsm.Version a.mu.RUnlock() + if currentGeneration != op.Generation || currentVersion != op.Version { + a.safeLog( + "approval outbox stale dropped kind=%s run_key=%s op_gen=%d op_ver=%d current_gen=%d current_ver=%d", + op.Kind, + op.RunKey, + op.Generation, + op.Version, + currentGeneration, + currentVersion, + ) + } +} - if existingCardID == "" { - cardID, err := a.messenger.SendPermissionCard(ctx, chatID, payload) - if err != nil { - a.safeLog("permission card create failed request_id=%s err=%v", normalizedRequestID, err) +// ensureApprovalFSMLocked 获取或初始化 run 级审批状态机;调用方必须持有写锁。 +func (a *Adapter) ensureApprovalFSMLocked(runKey string) *approvalFSMState { + normalizedRunKey := strings.TrimSpace(runKey) + if normalizedRunKey == "" { + return nil + } + if state, ok := a.approvalFSMByRun[normalizedRunKey]; ok && state != nil { + return state + } + state := &approvalFSMState{ + Generation: a.nowFn().UnixNano(), + Version: 0, + PendingStack: make([]string, 0), + Requests: make(map[string]approvalRequestNode), + } + a.approvalFSMByRun[normalizedRunKey] = state + return state +} + +// syncBindingApprovalsFromFSMLocked 将审批状态机快照映射为 sessionBinding 派生渲染字段。 +func (a *Adapter) syncBindingApprovalsFromFSMLocked(binding *sessionBinding, fsm *approvalFSMState) { + if binding == nil || fsm == nil { + return + } + records := make([]approvalEntry, 0, len(fsm.Requests)) + pending := 0 + approved := 0 + rejected := 0 + + // 先输出 active,再输出其余请求,保证渲染可读性。 + appendNode := func(requestID string) { + node, ok := fsm.Requests[requestID] + if !ok { return } - cardID = strings.TrimSpace(cardID) - if cardID == "" { - return + decision := strings.TrimSpace(node.Decision) + switch node.State { + case approvalRequestStateDisplayingPending, approvalRequestStateQueued, approvalRequestStateResolving: + decision = "pending" + pending++ + case approvalRequestStateResolvedApproved: + decision = "allow_once" + approved++ + case approvalRequestStateResolvedRejected: + decision = "reject" + rejected++ + default: + if isApprovalPendingDecision(decision) { + pending++ + } else if isApprovalApprovedDecision(decision) { + approved++ + } else if isApprovalRejectedDecision(decision) { + rejected++ + } } - a.mu.Lock() - a.runPermissionCards[key] = cardID - a.rememberRunPermissionCardLocked(key, cardID) - a.permissionCardRuns[cardID] = key - a.permissionCards[normalizedRequestID] = cardID - a.runPermissionActiveRequest[key] = normalizedRequestID - a.mu.Unlock() - a.safeLog("permission card created request_id=%s card_id=%s run_key=%s", normalizedRequestID, cardID, key) - return + records = append(records, approvalEntry{ + RequestID: node.RequestID, + ToolName: node.ToolName, + Operation: node.Operation, + Target: node.Target, + Reason: node.Reason, + Decision: decision, + }) + } + activeID := strings.TrimSpace(fsm.ActiveRequestID) + if activeID != "" { + appendNode(activeID) + } + for requestID := range fsm.Requests { + if strings.TrimSpace(requestID) == activeID { + continue + } + appendNode(requestID) } + binding.ApprovalRecords = records + switch { + case pending > 0: + binding.ApprovalStatus = "pending" + case rejected > 0 && approved == 0: + binding.ApprovalStatus = "rejected" + case approved > 0 && rejected == 0: + binding.ApprovalStatus = "approved" + case approved > 0 && rejected > 0: + binding.ApprovalStatus = "mixed" + default: + binding.ApprovalStatus = "none" + } +} - a.safeLog("permission card update start request_id=%s card_id=%s", normalizedRequestID, existingCardID) - if err := a.messenger.UpdatePendingPermissionCard(ctx, existingCardID, payload); err != nil { - a.safeLog("permission card update failed request_id=%s card_id=%s err=%v", normalizedRequestID, existingCardID, err) - return +// containsApprovalRequest 判断审批请求是否已存在于 pending 栈中。 +func containsApprovalRequest(pending []string, requestID string) bool { + normalizedRequestID := strings.TrimSpace(requestID) + if normalizedRequestID == "" { + return false } - a.mu.Lock() - a.rememberRunPermissionCardLocked(key, existingCardID) - a.permissionCardRuns[existingCardID] = key - a.permissionCards[normalizedRequestID] = existingCardID - a.runPermissionActiveRequest[key] = normalizedRequestID - a.mu.Unlock() - a.safeLog("permission card update done request_id=%s card_id=%s", normalizedRequestID, existingCardID) + for _, current := range pending { + if strings.TrimSpace(current) == normalizedRequestID { + return true + } + } + return false +} + +// resolveApprovalRunKeyByRequestLocked 在持锁状态下解析 request_id 对应 runKey,避免跨 run request_id 冲突误判。 +func (a *Adapter) resolveApprovalRunKeyByRequestLocked(requestID string) string { + normalizedRequestID := strings.TrimSpace(requestID) + if normalizedRequestID == "" { + return "" + } + runKey := strings.TrimSpace(a.approvalRequestIDRunIndex[normalizedRequestID]) + if runKey != "" { + if fsm := a.approvalFSMByRun[runKey]; fsm != nil { + if _, exists := fsm.Requests[normalizedRequestID]; exists { + if strings.TrimSpace(a.approvalRequestRunIndex[approvalRequestScopedKey(runKey, normalizedRequestID)]) == runKey { + return runKey + } + } + } + } + suffix := "|" + normalizedRequestID + selectedRunKey := "" + selectedActive := false + for scopedKey, candidateRunKey := range a.approvalRequestRunIndex { + if !strings.HasSuffix(strings.TrimSpace(scopedKey), suffix) { + continue + } + normalizedRunKey := strings.TrimSpace(candidateRunKey) + if normalizedRunKey == "" { + continue + } + fsm := a.approvalFSMByRun[normalizedRunKey] + if fsm == nil { + continue + } + if _, exists := fsm.Requests[normalizedRequestID]; !exists { + continue + } + isActive := strings.TrimSpace(fsm.ActiveRequestID) == normalizedRequestID + if selectedRunKey == "" || (isActive && !selectedActive) { + selectedRunKey = normalizedRunKey + selectedActive = isActive + } + if selectedActive { + break + } + } + return selectedRunKey +} + +// buildPendingPermissionPayloadFromNode 按请求节点构造待审批卡片载荷。 +func buildPendingPermissionPayloadFromNode(node approvalRequestNode) PermissionCardPayload { + return PermissionCardPayload{ + RequestID: strings.TrimSpace(node.RequestID), + ToolName: strings.TrimSpace(node.ToolName), + Operation: strings.TrimSpace(node.Operation), + Target: strings.TrimSpace(node.Target), + Message: strings.TrimSpace(node.Reason), + } +} + +// approvalRequestScopedKey 生成 run 作用域请求键,避免不同 run 的 request_id 冲突。 +func approvalRequestScopedKey(runKey string, requestID string) string { + return strings.TrimSpace(runKey) + "|" + strings.TrimSpace(requestID) } // markUserQuestionPending 记录 ask_user 待回答问题,并挂接到 run 状态卡上下文。 @@ -1187,210 +1529,130 @@ func (a *Adapter) updateApprovalStatus(requestID string, decision string) { if normalizedDecision == "" { return } + a.mu.Lock() - if alreadyDecision, resolved := a.resolvedPermissions[normalizedRequestID]; resolved && - normalizeApprovalDecision(alreadyDecision) == normalizedDecision { + runKey := strings.TrimSpace(a.resolveApprovalRunKeyByRequestLocked(normalizedRequestID)) + if runKey == "" { a.mu.Unlock() return } - key := a.requestRuns[normalizedRequestID] - binding, ok := a.activeRuns[key] - var resolvedApproval *approvalEntry - hasNextPendingPayload := false - activeRequestID := strings.TrimSpace(a.runPermissionActiveRequest[key]) - shouldTouchPermissionCard := activeRequestID == "" || activeRequestID == normalizedRequestID - if ok { - for i := range binding.ApprovalRecords { - if binding.ApprovalRecords[i].RequestID == normalizedRequestID { - binding.ApprovalRecords[i].Decision = normalizedDecision - entry := binding.ApprovalRecords[i] - resolvedApproval = &entry - } - } - approved := 0 - rejected := 0 - pending := 0 - for _, entry := range binding.ApprovalRecords { - switch { - case isApprovalApprovedDecision(entry.Decision): - approved++ - case isApprovalRejectedDecision(entry.Decision): - rejected++ - case isApprovalPendingDecision(entry.Decision): - pending++ - } - } - switch { - case pending > 0: - binding.ApprovalStatus = "pending" - case rejected > 0 && approved == 0: - binding.ApprovalStatus = "rejected" - case approved > 0 && rejected == 0: - binding.ApprovalStatus = "approved" - case approved > 0 && rejected > 0: - binding.ApprovalStatus = "mixed" - default: - binding.ApprovalStatus = "none" - } - if shouldTouchPermissionCard { - for _, entry := range binding.ApprovalRecords { - candidate := strings.TrimSpace(entry.RequestID) - if candidate == "" || candidate == normalizedRequestID { - continue - } - if !isApprovalPendingDecision(entry.Decision) { - continue - } - if _, ok := buildPendingPermissionPayload(binding, candidate); ok { - hasNextPendingPayload = true - break - } - } - } - a.activeRuns[key] = binding + fsm := a.approvalFSMByRun[runKey] + binding, ok := a.activeRuns[runKey] + if !ok || fsm == nil { + a.mu.Unlock() + return } - statusCardID := "" - statusPayload := StatusCardPayload{} - if ok { - statusCardID = strings.TrimSpace(binding.CardID) - statusPayload = binding.statusCardPayload() + node, exists := fsm.Requests[normalizedRequestID] + if !exists { + a.mu.Unlock() + return } - permCardID := strings.TrimSpace(a.permissionCards[normalizedRequestID]) - if permCardID == "" { - permCardID = strings.TrimSpace(a.runPermissionCards[key]) + if node.State == approvalRequestStateResolvedApproved || node.State == approvalRequestStateResolvedRejected { + a.mu.Unlock() + return } - historyCardIDs := a.runPermissionCardIDsLocked(key) - a.resolvedPermissions[normalizedRequestID] = normalizedDecision - a.mu.Unlock() - // 更新状态卡片 - if statusCardID != "" { - if err := a.messenger.UpdateCard(context.Background(), statusCardID, statusPayload); err != nil { - a.safeLog("update approval status card failed: %v", err) - } + fsm.Version++ + node.UpdatedVer = fsm.Version + node.Decision = normalizedDecision + if isApprovalApprovedDecision(normalizedDecision) { + node.State = approvalRequestStateResolvedApproved + } else { + node.State = approvalRequestStateResolvedRejected } + fsm.Requests[normalizedRequestID] = node - // 更新权限卡片为已处理状态(去掉按钮,显示结果)。 - // 仅当当前审批卡正在展示该 request 时才更新,避免旧请求回写覆盖新请求卡面。 - resolvedCardUpdated := false - if permCardID != "" && shouldTouchPermissionCard { - a.safeLog("permission card update start request_id=%s card_id=%s decision=%s", normalizedRequestID, permCardID, normalizedDecision) - resolvedPayload := ResolvedPermissionCardPayload{ - RequestID: normalizedRequestID, - Approved: normalizedDecision != "reject", - } - if resolvedApproval != nil { - resolvedPayload.ToolName = resolvedApproval.ToolName - resolvedPayload.Message = resolvedApproval.Reason + nextRequestID := "" + for len(fsm.PendingStack) > 0 { + candidate := strings.TrimSpace(fsm.PendingStack[len(fsm.PendingStack)-1]) + fsm.PendingStack = fsm.PendingStack[:len(fsm.PendingStack)-1] + if candidate == "" || candidate == normalizedRequestID { + continue } - if err := a.messenger.UpdatePermissionCard(context.Background(), permCardID, resolvedPayload); err != nil { - a.safeLog("update permission card failed: %v", err) - } else { - resolvedCardUpdated = true - a.safeLog("permission card update done request_id=%s card_id=%s decision=%s", normalizedRequestID, permCardID, normalizedDecision) + candidateNode, candidateExists := fsm.Requests[candidate] + if !candidateExists || candidateNode.State != approvalRequestStateQueued { + continue } + nextRequestID = candidate + candidateNode.State = approvalRequestStateDisplayingPending + candidateNode.Decision = "pending" + candidateNode.UpdatedVer = fsm.Version + fsm.Requests[candidate] = candidateNode + break } - if resolvedCardUpdated { - resolvedPayload := ResolvedPermissionCardPayload{ - RequestID: normalizedRequestID, - Approved: normalizedDecision != "reject", - } - if resolvedApproval != nil { - resolvedPayload.ToolName = resolvedApproval.ToolName - resolvedPayload.Operation = resolvedApproval.Operation - resolvedPayload.Target = resolvedApproval.Target - resolvedPayload.Message = resolvedApproval.Reason - } - for _, cardID := range historyCardIDs { - if strings.TrimSpace(cardID) == "" || strings.TrimSpace(cardID) == strings.TrimSpace(permCardID) { - continue - } - if err := a.messenger.UpdatePermissionCard(context.Background(), cardID, resolvedPayload); err != nil { - a.safeLog( - "update historical permission card failed request_id=%s card_id=%s err=%v", - normalizedRequestID, - cardID, - err, - ) - continue - } - a.safeLog( - "update historical permission card done request_id=%s card_id=%s decision=%s", - normalizedRequestID, - cardID, - normalizedDecision, - ) - } + fsm.ActiveRequestID = nextRequestID + + a.syncBindingApprovalsFromFSMLocked(&binding, fsm) + a.activeRuns[runKey] = binding + + ops := make([]approvalOutboxOperation, 0, 3) + if strings.TrimSpace(binding.CardID) != "" { + ops = append(ops, approvalOutboxOperation{ + RunKey: runKey, + Generation: fsm.Generation, + Version: fsm.Version, + Kind: approvalOutboxUpdateStatusCard, + CardID: strings.TrimSpace(binding.CardID), + StatusCard: binding.statusCardPayload(), + }) } - if resolvedCardUpdated && permCardID != "" && hasNextPendingPayload { - // 先短暂展示"已审批",再切换到下一条待审批。 - time.Sleep(1200 * time.Millisecond) - // 重新在锁下获取当前 pending 请求,避免延迟期间被其他回调更改。 - a.mu.Lock() - currentBinding, bindingStillActive := a.activeRuns[key] - activeAfterDelay := strings.TrimSpace(a.runPermissionActiveRequest[key]) - stillCurrent := bindingStillActive && activeAfterDelay == normalizedRequestID - var refreshedNextPayload PermissionCardPayload - hasRefreshedNext := false - if stillCurrent { - for _, entry := range currentBinding.ApprovalRecords { - candidate := strings.TrimSpace(entry.RequestID) - if candidate == "" || candidate == normalizedRequestID { - continue - } - if !isApprovalPendingDecision(entry.Decision) { - continue - } - if payload, ok := buildPendingPermissionPayload(currentBinding, candidate); ok { - refreshedNextPayload = payload - hasRefreshedNext = true - break - } - } - } - a.mu.Unlock() - if stillCurrent && hasRefreshedNext { - if err := a.messenger.UpdatePendingPermissionCard(context.Background(), permCardID, refreshedNextPayload); err != nil { - a.safeLog( - "restore pending permission card failed request_id=%s next_request_id=%s card_id=%s err=%v", - normalizedRequestID, - refreshedNextPayload.RequestID, - permCardID, - err, - ) - } else { - a.mu.Lock() - a.runPermissionActiveRequest[key] = strings.TrimSpace(refreshedNextPayload.RequestID) - a.permissionCards[strings.TrimSpace(refreshedNextPayload.RequestID)] = permCardID - a.mu.Unlock() - a.safeLog( - "restore pending permission card done request_id=%s next_request_id=%s card_id=%s", - normalizedRequestID, - refreshedNextPayload.RequestID, - permCardID, - ) - } - } else if stillCurrent && !hasRefreshedNext { - a.mu.Lock() - if strings.TrimSpace(a.runPermissionActiveRequest[key]) == normalizedRequestID { - delete(a.runPermissionActiveRequest, key) + + cardID := strings.TrimSpace(fsm.CardID) + if cardID != "" { + ops = append(ops, approvalOutboxOperation{ + RunKey: runKey, + Generation: fsm.Generation, + Version: fsm.Version, + Kind: approvalOutboxUpdateResolvedCard, + CardID: cardID, + RequestID: normalizedRequestID, + Resolved: ResolvedPermissionCardPayload{ + RequestID: normalizedRequestID, + ToolName: node.ToolName, + Operation: node.Operation, + Target: node.Target, + Message: node.Reason, + Approved: isApprovalApprovedDecision(normalizedDecision), + }, + }) + for _, historyCardID := range a.runPermissionCardIDsLocked(runKey) { + normalizedHistoryCardID := strings.TrimSpace(historyCardID) + if normalizedHistoryCardID == "" || normalizedHistoryCardID == cardID { + continue } - a.mu.Unlock() + ops = append(ops, approvalOutboxOperation{ + RunKey: runKey, + Generation: fsm.Generation, + Version: fsm.Version, + Kind: approvalOutboxUpdateResolvedRecord, + CardID: normalizedHistoryCardID, + RequestID: normalizedRequestID, + Resolved: ResolvedPermissionCardPayload{ + RequestID: normalizedRequestID, + ToolName: node.ToolName, + Operation: node.Operation, + Target: node.Target, + Message: node.Reason, + Approved: isApprovalApprovedDecision(normalizedDecision), + }, + }) } - } else if shouldTouchPermissionCard && resolvedCardUpdated { - a.mu.Lock() - if strings.TrimSpace(a.runPermissionActiveRequest[key]) == normalizedRequestID { - delete(a.runPermissionActiveRequest, key) + if nextRequestID != "" { + nextNode := fsm.Requests[nextRequestID] + ops = append(ops, approvalOutboxOperation{ + RunKey: runKey, + Generation: fsm.Generation, + Version: fsm.Version, + Kind: approvalOutboxUpdatePendingCard, + CardID: cardID, + RequestID: nextRequestID, + PendingCard: buildPendingPermissionPayloadFromNode(nextNode), + }) } - a.mu.Unlock() - } - shouldCleanupRequestMapping := true - if shouldCleanupRequestMapping { - a.mu.Lock() - delete(a.permissionCards, normalizedRequestID) - delete(a.requestRuns, normalizedRequestID) - a.mu.Unlock() } + a.mu.Unlock() + + a.executeApprovalOutbox(context.Background(), ops) } // schedulePermissionCardDismiss 在审批结果展示短暂停留后收起卡片,避免页面残留。 @@ -1416,8 +1678,12 @@ func (a *Adapter) schedulePermissionCardDismiss(requestID string, cardID string) } a.mu.Lock() defer a.mu.Unlock() - if strings.TrimSpace(a.permissionCards[normalizedRequestID]) == normalizedCardID { - delete(a.permissionCards, normalizedRequestID) + runKey := strings.TrimSpace(a.approvalCardRunIndex[normalizedCardID]) + delete(a.approvalCardRunIndex, normalizedCardID) + if runKey != "" { + if fsm := a.approvalFSMByRun[runKey]; fsm != nil && strings.TrimSpace(fsm.CardID) == normalizedCardID { + fsm.CardID = "" + } } }() } @@ -1425,11 +1691,6 @@ func (a *Adapter) schedulePermissionCardDismiss(requestID string, cardID string) // markRunTerminal 在 run 结束时合并结果摘要并刷新状态卡片。 func (a *Adapter) markRunTerminal(sessionID string, runID string, result string, summary string, fallback string) { key := runBindingKey(sessionID, runID) - type permissionFinalize struct { - requestID string - cardID string - payload ResolvedPermissionCardPayload - } a.mu.Lock() binding, ok := a.activeRuns[key] if !ok { @@ -1444,16 +1705,13 @@ func (a *Adapter) markRunTerminal(sessionID string, runID string, result string, normalizedResult := strings.TrimSpace(strings.ToLower(result)) binding.Result = normalizedResult binding.Status = terminalStatusFromResult(normalizedResult) - finalizeCards := make([]permissionFinalize, 0) - runPermissionCardID := strings.TrimSpace(a.runPermissionCards[key]) + if fsm := a.approvalFSMByRun[key]; fsm != nil { + a.syncBindingApprovalsFromFSMLocked(&binding, fsm) + } pendingApprovals := 0 lastResolvedApproval := approvalEntry{} hasLastResolvedApproval := false for _, entry := range binding.ApprovalRecords { - requestID := strings.TrimSpace(entry.RequestID) - if requestID == "" { - continue - } decision := normalizeApprovalDecision(entry.Decision) if isApprovalPendingDecision(decision) { pendingApprovals++ @@ -1464,26 +1722,30 @@ func (a *Adapter) markRunTerminal(sessionID string, runID string, result string, } lastResolvedApproval = entry hasLastResolvedApproval = true - cardID := strings.TrimSpace(a.permissionCards[requestID]) - if cardID == "" { + } + permissionCardIDs := a.runPermissionCardIDsLocked(key) + if fsm := a.approvalFSMByRun[key]; fsm != nil { + if strings.TrimSpace(fsm.CardID) != "" { + permissionCardIDs = append(permissionCardIDs, strings.TrimSpace(fsm.CardID)) + } + } + uniquePermissionCardIDs := make([]string, 0, len(permissionCardIDs)) + seenCardID := make(map[string]struct{}, len(permissionCardIDs)) + for _, cardID := range permissionCardIDs { + normalizedCardID := strings.TrimSpace(cardID) + if normalizedCardID == "" { continue } - finalizeCards = append(finalizeCards, permissionFinalize{ - requestID: requestID, - cardID: cardID, - payload: ResolvedPermissionCardPayload{ - RequestID: requestID, - ToolName: strings.TrimSpace(entry.ToolName), - Operation: strings.TrimSpace(entry.Operation), - Target: strings.TrimSpace(entry.Target), - Message: strings.TrimSpace(entry.Reason), - Approved: isApprovalApprovedDecision(decision), - }, - }) + if _, exists := seenCardID[normalizedCardID]; exists { + continue + } + seenCardID[normalizedCardID] = struct{}{} + uniquePermissionCardIDs = append(uniquePermissionCardIDs, normalizedCardID) } + var finalizePayload *ResolvedPermissionCardPayload if pendingApprovals == 0 && hasLastResolvedApproval { decision := normalizeApprovalDecision(lastResolvedApproval.Decision) - payload := ResolvedPermissionCardPayload{ + payload := &ResolvedPermissionCardPayload{ RequestID: strings.TrimSpace(lastResolvedApproval.RequestID), ToolName: strings.TrimSpace(lastResolvedApproval.ToolName), Operation: strings.TrimSpace(lastResolvedApproval.Operation), @@ -1491,52 +1753,27 @@ func (a *Adapter) markRunTerminal(sessionID string, runID string, result string, Message: strings.TrimSpace(lastResolvedApproval.Reason), Approved: isApprovalApprovedDecision(decision), } - queuedCardIDs := make(map[string]struct{}) - for _, item := range finalizeCards { - queuedCardIDs[strings.TrimSpace(item.cardID)] = struct{}{} - } - if runPermissionCardID != "" { - if _, exists := queuedCardIDs[runPermissionCardID]; !exists { - finalizeCards = append(finalizeCards, permissionFinalize{ - requestID: strings.TrimSpace(lastResolvedApproval.RequestID), - cardID: runPermissionCardID, - payload: payload, - }) - queuedCardIDs[runPermissionCardID] = struct{}{} - } - } - for _, historyCardID := range a.runPermissionCardIDsLocked(key) { - if historyCardID == "" { - continue - } - if _, exists := queuedCardIDs[historyCardID]; exists { - continue - } - finalizeCards = append(finalizeCards, permissionFinalize{ - requestID: strings.TrimSpace(lastResolvedApproval.RequestID), - cardID: historyCardID, - payload: payload, - }) - queuedCardIDs[historyCardID] = struct{}{} - } + finalizePayload = payload } cardID := strings.TrimSpace(binding.CardID) chatID := strings.TrimSpace(binding.ChatID) payload := binding.statusCardPayload() a.activeRuns[key] = binding a.mu.Unlock() - for _, item := range finalizeCards { - callCtx, cancel := context.WithTimeout(context.Background(), a.cfg.RequestTimeout) - err := a.messenger.UpdatePermissionCard(callCtx, item.cardID, item.payload) - cancel() - if err != nil { - a.safeLog("finalize permission card failed request_id=%s card_id=%s err=%v", item.requestID, item.cardID, err) - continue + if finalizePayload != nil { + for _, finalizedCardID := range uniquePermissionCardIDs { + callCtx, cancel := context.WithTimeout(context.Background(), a.cfg.RequestTimeout) + err := a.messenger.UpdatePermissionCard(callCtx, finalizedCardID, *finalizePayload) + cancel() + if err != nil { + a.safeLog( + "finalize permission card failed request_id=%s card_id=%s err=%v", + finalizePayload.RequestID, + finalizedCardID, + err, + ) + } } - a.mu.Lock() - delete(a.permissionCards, item.requestID) - delete(a.requestRuns, item.requestID) - a.mu.Unlock() } if cardID != "" { callCtx, cancel := context.WithTimeout(context.Background(), a.cfg.RequestTimeout) @@ -1633,15 +1870,15 @@ func (a *Adapter) tryHandleTextAction(ctx context.Context, chatID string, text s if requestID == "" { return true, nil } - err := a.HandleCardAction(ctx, FeishuCardActionEvent{ - ActionType: "permission", - RequestID: requestID, - Decision: "allow_once", - }) + resolved, err := a.applyPermissionDecisionStrict(ctx, requestID, "", "allow_once") if err != nil { _ = a.messenger.SendText(context.Background(), chatID, "审批提交失败,请稍后重试。") return true, err } + if !resolved { + _ = a.messenger.SendText(context.Background(), chatID, "审批未命中当前待处理请求,已忽略。") + return true, nil + } _ = a.messenger.SendText(context.Background(), chatID, "审批已提交:允许一次。") return true, nil case strings.HasPrefix(normalized, "拒绝 "): @@ -1649,15 +1886,15 @@ func (a *Adapter) tryHandleTextAction(ctx context.Context, chatID string, text s if requestID == "" { return true, nil } - err := a.HandleCardAction(ctx, FeishuCardActionEvent{ - ActionType: "permission", - RequestID: requestID, - Decision: "reject", - }) + resolved, err := a.applyPermissionDecisionStrict(ctx, requestID, "", "reject") if err != nil { _ = a.messenger.SendText(context.Background(), chatID, "审批提交失败,请稍后重试。") return true, err } + if !resolved { + _ = a.messenger.SendText(context.Background(), chatID, "审批未命中当前待处理请求,已忽略。") + return true, nil + } _ = a.messenger.SendText(context.Background(), chatID, "审批已提交:拒绝。") return true, nil case strings.HasPrefix(normalized, "跳过 "): diff --git a/internal/feishuadapter/adapter_test.go b/internal/feishuadapter/adapter_test.go index dc38ba69..3f3a6585 100644 --- a/internal/feishuadapter/adapter_test.go +++ b/internal/feishuadapter/adapter_test.go @@ -137,13 +137,14 @@ type sentMessage struct { } type fakeMessenger struct { - mu sync.Mutex - messages []sentMessage - nextID int - sendTextErr error - sendCardErr error - updateCardErr error - deleteCardErr error + mu sync.Mutex + messages []sentMessage + nextID int + sendTextErr error + sendCardErr error + updateCardErr error + deleteCardErr error + sendPermissionCardHook func(cardID string, payload PermissionCardPayload) } func (m *fakeMessenger) SendText(_ context.Context, chatID string, text string) error { @@ -155,10 +156,14 @@ func (m *fakeMessenger) SendText(_ context.Context, chatID string, text string) func (m *fakeMessenger) SendPermissionCard(_ context.Context, chatID string, payload PermissionCardPayload) (string, error) { m.mu.Lock() - defer m.mu.Unlock() m.nextID++ cardID := fmt.Sprintf("perm-card-%d", m.nextID) m.messages = append(m.messages, sentMessage{chatID: chatID, kind: "card", card: payload, cardID: cardID}) + hook := m.sendPermissionCardHook + m.mu.Unlock() + if hook != nil { + hook(cardID, payload) + } return cardID, nil } @@ -984,7 +989,62 @@ func TestPermissionQueuedRequestDoesNotOverrideActiveCardBeforeResolve(t *testin } } -func TestPermissionActionFallsBackToCurrentPendingRequestWhenStale(t *testing.T) { +func TestPermissionQueueSwitchPrefersNewestPendingAfterResolve(t *testing.T) { + adapter := newTestAdapter(t) + if err := adapter.bindThenRun(context.Background(), "session-queue-newest", "run-queue-newest", "chat-queue-newest", "执行审批新队列优先任务"); err != nil { + t.Fatalf("bindThenRun: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go adapter.consumeGatewayEvents(ctx) + + pushGatewayEvent(t, adapterTestGateway(adapter), "session-queue-newest", "run-queue-newest", "run_progress", map[string]any{ + "runtime_event_type": "permission_requested", + "payload": map[string]any{ + "request_id": "perm-newest-1", + "reason": "审批一", + }, + }) + pushGatewayEvent(t, adapterTestGateway(adapter), "session-queue-newest", "run-queue-newest", "run_progress", map[string]any{ + "runtime_event_type": "permission_requested", + "payload": map[string]any{ + "request_id": "perm-newest-2", + "reason": "审批二", + }, + }) + pushGatewayEvent(t, adapterTestGateway(adapter), "session-queue-newest", "run-queue-newest", "run_progress", map[string]any{ + "runtime_event_type": "permission_requested", + "payload": map[string]any{ + "request_id": "perm-newest-3", + "reason": "审批三", + }, + }) + time.Sleep(80 * time.Millisecond) + + if err := adapter.HandleCardAction(context.Background(), FeishuCardActionEvent{ + RequestID: "perm-newest-1", + Decision: "allow_once", + }); err != nil { + t.Fatalf("handle first card action: %v", err) + } + time.Sleep(1500 * time.Millisecond) + + msgs := adapterTestMessenger(adapter).snapshot() + lastPendingRequestID := "" + for _, message := range msgs { + if message.kind == "update_pending_perm_card" && message.updatedPendingCard != nil { + lastPendingRequestID = message.updatedPendingCard.RequestID + } + } + if lastPendingRequestID == "" { + t.Fatalf("expected pending switch update after resolving first approval, msgs=%#v", msgs) + } + if lastPendingRequestID != "perm-newest-3" { + t.Fatalf("pending switch should prefer newest request, got %q; msgs=%#v", lastPendingRequestID, msgs) + } +} + +func TestPermissionActionStrictlyIgnoresStaleCallbackWithoutFallback(t *testing.T) { adapter := newTestAdapter(t) if err := adapter.bindThenRun(context.Background(), "session-stale", "run-stale", "chat-stale", "执行审批回退任务"); err != nil { t.Fatalf("bindThenRun: %v", err) @@ -1043,36 +1103,26 @@ func TestPermissionActionFallsBackToCurrentPendingRequestWhenStale(t *testing.T) CardID: permissionCardID, Decision: "reject", }); err != nil { - t.Fatalf("resolve stale permission should fallback: %v", err) + t.Fatalf("resolve stale permission should be ignored in strict mode: %v", err) } time.Sleep(30 * time.Millisecond) calls := gateway.snapshotCalls() resolveStale := 0 - resolveFallback := 0 + resolveUnexpectedFallback := 0 for _, call := range calls { if call == "resolve:perm-stale-1:reject" { resolveStale++ } if call == "resolve:perm-stale-2:reject" { - resolveFallback++ + resolveUnexpectedFallback++ } } - if resolveStale == 0 || resolveFallback == 0 { - t.Fatalf("expected stale+fallback resolve calls, got %#v", calls) - } - - msgs := adapterTestMessenger(adapter).snapshot() - foundRejected := false - for _, message := range msgs { - if message.kind == "update_perm_card" && message.resolvedCard != nil && - message.resolvedCard.RequestID == "perm-stale-2" && !message.resolvedCard.Approved { - foundRejected = true - break - } + if resolveStale != 0 { + t.Fatalf("stale callback should not call resolve in strict mode, got %#v", calls) } - if !foundRejected { - t.Fatalf("expected fallback request resolved card update, msgs=%#v", msgs) + if resolveUnexpectedFallback != 0 { + t.Fatalf("stale callback must not fallback-resolve next pending request, got %#v", calls) } } @@ -1131,11 +1181,11 @@ func TestPermissionActionIgnoresAlreadyResolvedRequestOnOpaquePrimaryError(t *te gateway.mu.Unlock() err := adapter.HandleCardAction(context.Background(), FeishuCardActionEvent{ - EventID: "evt-opaque-fallback", - RequestID: "perm-opaque-1", - Decision: "allow_once", + EventID: "evt-opaque-fallback", + RequestID: "perm-opaque-1", + Decision: "allow_once", ActionType: "permission", - CardID: permissionCardID, + CardID: permissionCardID, }) if err != nil { t.Fatalf("resolve opaque stale permission should be ignored: %v", err) @@ -1159,6 +1209,125 @@ func TestPermissionActionIgnoresAlreadyResolvedRequestOnOpaquePrimaryError(t *te } } +func TestPermissionCallbackStrictRejectsNonActiveRequest(t *testing.T) { + adapter := newTestAdapter(t) + if err := adapter.bindThenRun(context.Background(), "session-strict-active", "run-strict-active", "chat-strict-active", "执行严格回调任务"); err != nil { + t.Fatalf("bindThenRun: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go adapter.consumeGatewayEvents(ctx) + + pushGatewayEvent(t, adapterTestGateway(adapter), "session-strict-active", "run-strict-active", "run_progress", map[string]any{ + "runtime_event_type": "permission_requested", + "payload": map[string]any{ + "request_id": "perm-strict-active-1", + "reason": "审批一", + }, + }) + pushGatewayEvent(t, adapterTestGateway(adapter), "session-strict-active", "run-strict-active", "run_progress", map[string]any{ + "runtime_event_type": "permission_requested", + "payload": map[string]any{ + "request_id": "perm-strict-active-2", + "reason": "审批二", + }, + }) + time.Sleep(60 * time.Millisecond) + + if err := adapter.HandleCardAction(context.Background(), FeishuCardActionEvent{ + RequestID: "perm-strict-active-2", + Decision: "reject", + }); err != nil { + t.Fatalf("strict callback for non-active request should be ignored: %v", err) + } + time.Sleep(20 * time.Millisecond) + + calls := adapterTestGateway(adapter).snapshotCalls() + for _, call := range calls { + if call == "resolve:perm-strict-active-2:reject" { + t.Fatalf("non-active request must not be resolved in strict mode, calls=%#v", calls) + } + } +} + +func TestPermissionCallbackStrictRejectsCardRunMismatch(t *testing.T) { + adapter := newTestAdapter(t) + if err := adapter.bindThenRun(context.Background(), "session-strict-card", "run-strict-card", "chat-strict-card", "执行严格回调任务"); err != nil { + t.Fatalf("bindThenRun: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go adapter.consumeGatewayEvents(ctx) + + pushGatewayEvent(t, adapterTestGateway(adapter), "session-strict-card", "run-strict-card", "run_progress", map[string]any{ + "runtime_event_type": "permission_requested", + "payload": map[string]any{ + "request_id": "perm-strict-card-1", + "reason": "审批一", + }, + }) + time.Sleep(40 * time.Millisecond) + + if err := adapter.HandleCardAction(context.Background(), FeishuCardActionEvent{ + RequestID: "perm-strict-card-1", + CardID: "perm-card-unrelated", + Decision: "allow_once", + }); err != nil { + t.Fatalf("strict callback with card mismatch should be ignored: %v", err) + } + time.Sleep(20 * time.Millisecond) + + calls := adapterTestGateway(adapter).snapshotCalls() + for _, call := range calls { + if call == "resolve:perm-strict-card-1:allow_once" { + t.Fatalf("card mismatch must not call resolve, calls=%#v", calls) + } + } +} + +func TestPermissionCallbackStrictRejectsNonDisplayingPendingState(t *testing.T) { + adapter := newTestAdapter(t) + if err := adapter.bindThenRun(context.Background(), "session-strict-state", "run-strict-state", "chat-strict-state", "执行严格回调任务"); err != nil { + t.Fatalf("bindThenRun: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go adapter.consumeGatewayEvents(ctx) + + pushGatewayEvent(t, adapterTestGateway(adapter), "session-strict-state", "run-strict-state", "run_progress", map[string]any{ + "runtime_event_type": "permission_requested", + "payload": map[string]any{ + "request_id": "perm-strict-state-1", + "reason": "审批一", + }, + }) + time.Sleep(40 * time.Millisecond) + + runKey := runBindingKey("session-strict-state", "run-strict-state") + adapter.mu.Lock() + if fsm := adapter.approvalFSMByRun[runKey]; fsm != nil { + node := fsm.Requests["perm-strict-state-1"] + node.State = approvalRequestStateQueued + fsm.Requests["perm-strict-state-1"] = node + } + adapter.mu.Unlock() + + if err := adapter.HandleCardAction(context.Background(), FeishuCardActionEvent{ + RequestID: "perm-strict-state-1", + Decision: "allow_once", + }); err != nil { + t.Fatalf("strict callback with non-displaying state should be ignored: %v", err) + } + time.Sleep(20 * time.Millisecond) + + calls := adapterTestGateway(adapter).snapshotCalls() + for _, call := range calls { + if call == "resolve:perm-strict-state-1:allow_once" { + t.Fatalf("non-displaying-pending state must not call resolve, calls=%#v", calls) + } + } +} + func TestHandleCardActionAcceptsAllowAliasDecision(t *testing.T) { adapter := newTestAdapter(t) if err := adapter.bindThenRun(context.Background(), "session-allow-alias", "run-allow-alias", "chat-allow-alias", "执行审批别名任务"); err != nil { @@ -1341,16 +1510,32 @@ func TestUpdateApprovalStatusFinalizesHistoricalPermissionCards(t *testing.T) { }, } adapter.activeRuns[key] = binding - adapter.requestRuns["perm-history-1"] = key - adapter.permissionCards["perm-history-1"] = "perm-card-current" - adapter.runPermissionCards[key] = "perm-card-current" - adapter.permissionCardRuns["perm-card-current"] = key - adapter.permissionCardRuns["perm-card-old"] = key + adapter.approvalFSMByRun[key] = &approvalFSMState{ + Generation: 1, + Version: 1, + CardID: "perm-card-current", + ActiveRequestID: "perm-history-1", + Requests: map[string]approvalRequestNode{ + "perm-history-1": { + RequestID: "perm-history-1", + ToolName: "filesystem_write_file", + Operation: "write_file", + Target: "1.txt", + Reason: "需要写文件权限", + Decision: "pending", + State: approvalRequestStateDisplayingPending, + UpdatedVer: 1, + }, + }, + } + adapter.approvalRequestRunIndex[approvalRequestScopedKey(key, "perm-history-1")] = key + adapter.approvalRequestIDRunIndex["perm-history-1"] = key + adapter.approvalCardRunIndex["perm-card-current"] = key + adapter.approvalCardRunIndex["perm-card-old"] = key adapter.runPermissionCardHistory[key] = map[string]struct{}{ "perm-card-current": {}, "perm-card-old": {}, } - adapter.runPermissionActiveRequest[key] = "perm-history-1" adapter.mu.Unlock() adapter.updateApprovalStatus("perm-history-1", "allow_once") @@ -1398,15 +1583,32 @@ func TestRunTerminalFinalizesPermissionCardUsingRunScopedCardFallback(t *testing }, } adapter.activeRuns[key] = binding - adapter.runPermissionCards[key] = "perm-card-fallback" - adapter.permissionCardRuns["perm-card-fallback"] = key - adapter.permissionCardRuns["perm-card-stale"] = key + adapter.approvalFSMByRun[key] = &approvalFSMState{ + Generation: 1, + Version: 1, + CardID: "perm-card-fallback", + ActiveRequestID: "", + Requests: map[string]approvalRequestNode{ + "perm-terminal-1": { + RequestID: "perm-terminal-1", + ToolName: "filesystem_write_file", + Operation: "write_file", + Target: "1.txt", + Reason: "需要写文件权限", + Decision: "allow_once", + State: approvalRequestStateResolvedApproved, + UpdatedVer: 1, + }, + }, + } + adapter.approvalRequestRunIndex[approvalRequestScopedKey(key, "perm-terminal-1")] = key + adapter.approvalRequestIDRunIndex["perm-terminal-1"] = key + adapter.approvalCardRunIndex["perm-card-fallback"] = key + adapter.approvalCardRunIndex["perm-card-stale"] = key adapter.runPermissionCardHistory[key] = map[string]struct{}{ "perm-card-fallback": {}, "perm-card-stale": {}, } - delete(adapter.permissionCards, "perm-terminal-1") - delete(adapter.requestRuns, "perm-terminal-1") adapter.mu.Unlock() adapter.markRunTerminal(sessionID, runID, "success", "done", "") @@ -1627,6 +1829,18 @@ func TestRunProgressInternalEventsAreNotUserFacing(t *testing.T) { func TestCardCallbackDedupeResolveOnce(t *testing.T) { adapter := newTestAdapter(t) + adapter.trackSession("session-card-dedupe", "run-card-dedupe", "chat-card-dedupe", "card dedupe task") + adapter.processPermissionRequested( + context.Background(), + "session-card-dedupe", + "run-card-dedupe", + "chat-card-dedupe", + "perm-2", + "filesystem_write_file", + "write_file", + "dedupe.txt", + "需要审批", + ) body := `{"action":{"value":{"request_id":"perm-2","decision":"allow_once"}},"token":"verify"}` for i := 0; i < 2; i++ { request := signedRequest(t, adapter.cfg.SigningSecret, body) @@ -1643,6 +1857,18 @@ func TestCardCallbackDedupeResolveOnce(t *testing.T) { func TestCardCallbackResolveFailureReturns500(t *testing.T) { adapter := newTestAdapter(t) + adapter.trackSession("session-card-failure", "run-card-failure", "chat-card-failure", "card failure task") + adapter.processPermissionRequested( + context.Background(), + "session-card-failure", + "run-card-failure", + "chat-card-failure", + "perm-3", + "filesystem_write_file", + "write_file", + "failure.txt", + "需要审批", + ) gateway := adapterTestGateway(adapter) gateway.mu.Lock() gateway.resolveErr = assertErr("deny") @@ -1922,9 +2148,17 @@ func TestEnsureRunCardUpdatesExistingCard(t *testing.T) { func TestTryHandleTextPermissionHandlesApprovalCommands(t *testing.T) { adapter := newTestAdapter(t) adapter.trackSession("session-approve", "run-approve", "chat-approve", "approve task") - adapter.mu.Lock() - adapter.requestRuns["perm-approve"] = "session-approve|run-approve" - adapter.mu.Unlock() + adapter.processPermissionRequested( + context.Background(), + "session-approve", + "run-approve", + "chat-approve", + "perm-approve", + "filesystem_write_file", + "write_file", + "approve.txt", + "需要审批", + ) handled, err := adapter.tryHandleTextPermission(context.Background(), "chat-approve", "允许 perm-approve") if err != nil || !handled { @@ -1942,9 +2176,17 @@ func TestTryHandleTextPermissionHandlesApprovalCommands(t *testing.T) { func TestTryHandleTextPermissionHandlesRejectCommand(t *testing.T) { adapter := newTestAdapter(t) adapter.trackSession("session-reject-ok", "run-reject-ok", "chat-reject-ok", "reject task") - adapter.mu.Lock() - adapter.requestRuns["perm-reject-ok"] = "session-reject-ok|run-reject-ok" - adapter.mu.Unlock() + adapter.processPermissionRequested( + context.Background(), + "session-reject-ok", + "run-reject-ok", + "chat-reject-ok", + "perm-reject-ok", + "filesystem_write_file", + "write_file", + "reject-ok.txt", + "需要审批", + ) handled, err := adapter.tryHandleTextPermission(context.Background(), "chat-reject-ok", "拒绝 perm-reject-ok") if err != nil || !handled { @@ -1956,21 +2198,55 @@ func TestTryHandleTextPermissionHandlesRejectCommand(t *testing.T) { } } +func TestTryHandleTextPermissionRepliesIgnoredWhenRequestNotActive(t *testing.T) { + adapter := newTestAdapter(t) + adapter.trackSession("session-text-ignored", "run-text-ignored", "chat-text-ignored", "text ignored task") + adapter.processPermissionRequested( + context.Background(), + "session-text-ignored", + "run-text-ignored", + "chat-text-ignored", + "perm-text-ignored", + "filesystem_write_file", + "write_file", + "text-ignored.txt", + "需要审批", + ) + if err := adapter.HandleCardAction(context.Background(), FeishuCardActionEvent{ + ActionType: "permission", + RequestID: "perm-text-ignored", + Decision: "allow_once", + }); err != nil { + t.Fatalf("resolve permission before text retry: %v", err) + } + + handled, err := adapter.tryHandleTextPermission(context.Background(), "chat-text-ignored", "允许 perm-text-ignored") + if err != nil || !handled { + t.Fatalf("allow stale command = handled:%v err:%v", handled, err) + } + msgs := adapterTestMessenger(adapter).snapshot() + if len(msgs) == 0 || msgs[len(msgs)-1].text != "审批未命中当前待处理请求,已忽略。" { + t.Fatalf("unexpected stale allow reply: %#v", msgs) + } + if adapterTestGateway(adapter).resolveCount != 1 { + t.Fatalf("resolve count = %d, want 1", adapterTestGateway(adapter).resolveCount) + } +} + func TestTryHandleTextPermissionRejectFailureRepliesRetryable(t *testing.T) { adapter := newTestAdapter(t) adapter.trackSession("session-reject", "run-reject", "chat-reject", "reject task") - adapter.mu.Lock() - adapter.requestRuns["perm-reject"] = "session-reject|run-reject" - adapter.activeRuns["session-reject|run-reject"] = sessionBinding{ - SessionID: "session-reject", - RunID: "run-reject", - ChatID: "chat-reject", - TaskName: "reject task", - Status: "running", - ApprovalStatus: "pending", - Result: "pending", - } - adapter.mu.Unlock() + adapter.processPermissionRequested( + context.Background(), + "session-reject", + "run-reject", + "chat-reject", + "perm-reject", + "filesystem_write_file", + "write_file", + "reject.txt", + "需要审批", + ) gateway := adapterTestGateway(adapter) gateway.mu.Lock() gateway.resolveErr = assertErr("boom") @@ -1986,6 +2262,126 @@ func TestTryHandleTextPermissionRejectFailureRepliesRetryable(t *testing.T) { } } +func TestApprovalOutboxPreflightDropsStaleOperation(t *testing.T) { + adapter := newTestAdapter(t) + adapter.trackSession("session-outbox-stale", "run-outbox-stale", "chat-outbox-stale", "outbox stale task") + adapter.processPermissionRequested( + context.Background(), + "session-outbox-stale", + "run-outbox-stale", + "chat-outbox-stale", + "perm-outbox-stale", + "filesystem_write_file", + "write_file", + "outbox-stale.txt", + "需要审批", + ) + + runKey := runBindingKey("session-outbox-stale", "run-outbox-stale") + adapter.mu.RLock() + fsm := adapter.approvalFSMByRun[runKey] + if fsm == nil { + adapter.mu.RUnlock() + t.Fatal("expected approval fsm for stale outbox test") + } + cardID := strings.TrimSpace(fsm.CardID) + generation := fsm.Generation + staleVersion := fsm.Version - 1 + adapter.mu.RUnlock() + if cardID == "" { + t.Fatal("expected permission card id for stale outbox test") + } + + before := len(adapterTestMessenger(adapter).snapshot()) + adapter.executeApprovalOutbox(context.Background(), []approvalOutboxOperation{ + { + RunKey: runKey, + Generation: generation, + Version: staleVersion, + Kind: approvalOutboxUpdatePendingCard, + CardID: cardID, + RequestID: "perm-outbox-stale", + PendingCard: PermissionCardPayload{ + RequestID: "perm-outbox-stale", + ToolName: "filesystem_write_file", + Operation: "write_file", + Target: "outbox-stale.txt", + Message: "stale should drop", + }, + }, + }) + after := len(adapterTestMessenger(adapter).snapshot()) + if after != before { + t.Fatalf("stale outbox should be dropped before send, before=%d after=%d", before, after) + } +} + +func TestApprovalOutboxSendCardCleanupWhenVersionAdvancedDuringSend(t *testing.T) { + adapter := newTestAdapter(t) + sessionID := "session-outbox-race-send" + runID := "run-outbox-race-send" + runKey := runBindingKey(sessionID, runID) + adapter.trackSession(sessionID, runID, "chat-outbox-race-send", "outbox race send task") + + var createdCardID string + var hookOnce sync.Once + messenger := adapterTestMessenger(adapter) + messenger.mu.Lock() + messenger.sendPermissionCardHook = func(cardID string, _ PermissionCardPayload) { + createdCardID = strings.TrimSpace(cardID) + hookOnce.Do(func() { + adapter.mu.Lock() + if fsm := adapter.approvalFSMByRun[runKey]; fsm != nil { + fsm.Version++ + } + adapter.mu.Unlock() + }) + } + messenger.mu.Unlock() + + adapter.processPermissionRequested( + context.Background(), + sessionID, + runID, + "chat-outbox-race-send", + "perm-outbox-race-send", + "filesystem_write_file", + "write_file", + "outbox-race-send.txt", + "需要审批", + ) + if strings.TrimSpace(createdCardID) == "" { + t.Fatal("expected permission card to be sent") + } + + msgs := adapterTestMessenger(adapter).snapshot() + foundDelete := false + for _, message := range msgs { + if message.kind == "delete_card" && message.chatID == createdCardID { + foundDelete = true + break + } + } + if !foundDelete { + t.Fatalf("expected stale sent permission card to be deleted, msgs=%#v", msgs) + } + + adapter.mu.RLock() + fsm := adapter.approvalFSMByRun[runKey] + storedCardID := "" + if fsm != nil { + storedCardID = strings.TrimSpace(fsm.CardID) + } + _, indexed := adapter.approvalCardRunIndex[createdCardID] + adapter.mu.RUnlock() + if storedCardID != "" { + t.Fatalf("stale card should not be attached to fsm, got %q", storedCardID) + } + if indexed { + t.Fatalf("stale card should not remain in approvalCardRunIndex: %s", createdCardID) + } +} + func TestTryHandleTextPermissionHandlesAskUserAnswerAndSkip(t *testing.T) { adapter := newTestAdapter(t) sessionID := BuildSessionID("chat-ask-text-cmd") @@ -2200,6 +2596,14 @@ func TestBuildTaskNameTruncatesLongFirstLine(t *testing.T) { } func TestExtractHookNotificationSummaryAndHintFallbacks(t *testing.T) { + if summary := extractHookNotificationSummary(nil); summary != "" { + t.Fatalf("summary = %q, want empty", summary) + } + if summary := extractHookNotificationSummary(map[string]any{ + "payload": map[string]any{"summary": "summary"}, + }); summary != "summary" { + t.Fatalf("summary = %q, want summary", summary) + } if summary := extractHookNotificationSummary(map[string]any{ "payload": map[string]any{"notification": "notify"}, }); summary != "notify" { @@ -2210,11 +2614,19 @@ func TestExtractHookNotificationSummaryAndHintFallbacks(t *testing.T) { }); summary != "message" { t.Fatalf("summary = %q, want message", summary) } + if hint := extractHookNotificationHint(map[string]any{ + "payload": map[string]any{"reason": "retry"}, + }); hint != "retry" { + t.Fatalf("hint = %q, want retry", hint) + } if hint := extractHookNotificationHint(map[string]any{ "payload": map[string]any{"status": "async"}, }); hint != "async" { t.Fatalf("hint = %q, want async", hint) } + if hint := extractHookNotificationHint(nil); hint != "" { + t.Fatalf("hint = %q, want empty", hint) + } } func TestDeriveRunStatusAdditionalBranches(t *testing.T) { @@ -2263,6 +2675,22 @@ func TestExtractUserVisibleDoneTextHandlesTextFieldAndTypedParts(t *testing.T) { }); text != "keep" { t.Fatalf("parts text = %q, want keep", text) } + if text := extractUserVisibleDoneText(map[string]any{ + "payload": map[string]any{ + "parts": []any{ + map[string]any{"type": "text", "text": "line one"}, + map[string]any{"type": "", "content": "line two"}, + "ignored", + }, + }, + }); text != "line one\nline two" { + t.Fatalf("parts text = %q, want joined lines", text) + } + if text := extractUserVisibleDoneText(map[string]any{ + "payload": map[string]any{"parts": []any{}}, + }); text != "" { + t.Fatalf("done text = %q, want empty", text) + } } func TestConsumeGatewayEventsIgnoresNonGatewayNotifications(t *testing.T) { @@ -2350,6 +2778,21 @@ func TestHelperFunctionsCoverFallbackBranches(t *testing.T) { }); text != "本机 Runner 未连接,请在电脑上启动 `neocode runner`" { t.Fatalf("runner error text = %q", text) } + if text := extractUserVisibleErrorText(map[string]any{ + "payload": map[string]any{"message": "capability_denied"}, + }); text != "权限不足:当前能力令牌不允许此操作" { + t.Fatalf("capability error text = %q", text) + } + if text := extractUserVisibleErrorText(map[string]any{ + "payload": map[string]any{"message": "tool_execution_failed: bash"}, + }); text != "工具执行失败:tool_execution_failed: bash" { + t.Fatalf("tool execution error text = %q", text) + } + if text := extractUserVisibleErrorText(map[string]any{ + "message": "timed out waiting for runner", + }); text != "本机 Runner 响应超时,请检查网络连接和 Runner 状态" { + t.Fatalf("timeout error text = %q", text) + } if text := extractUserVisibleErrorText(nil); text != "" { t.Fatalf("error text = %q, want empty", text) } @@ -2394,9 +2837,15 @@ func TestHelperFunctionsCoverFallbackBranches(t *testing.T) { if status := terminalStatusFromResult("failure"); status != "failure" { t.Fatalf("terminal status = %q, want failure", status) } + if status := terminalStatusFromResult("interrupted"); status != "interrupted" { + t.Fatalf("terminal status = %q, want interrupted", status) + } if status := terminalStatusFromResult("unknown"); status != "running" { t.Fatalf("terminal status = %q, want running fallback", status) } + if text := buildTerminalFallbackText("success", ""); text != "任务已完成。" { + t.Fatalf("terminal fallback text = %q, want success default", text) + } if text := buildTerminalFallbackText("success", "执行完成"); text != "任务已完成:\n执行完成" { t.Fatalf("terminal fallback text = %q, want success summary", text) } @@ -2558,6 +3007,908 @@ func TestIsMentionCurrentBotMatchesMentionAppID(t *testing.T) { } } +func TestBuildPendingPermissionPayloadAndFindApprovalDecision(t *testing.T) { + binding := sessionBinding{ + ApprovalRecords: []approvalEntry{ + { + RequestID: "req-pending", + ToolName: "bash", + Operation: "exec", + Target: "pwd", + Reason: "need confirm", + Decision: "pending", + }, + { + RequestID: "req-approved", + Decision: "allow_once", + }, + }, + } + + payload, ok := buildPendingPermissionPayload(binding, " req-pending ") + if !ok { + t.Fatal("expected pending payload") + } + if payload.RequestID != "req-pending" || payload.ToolName != "bash" || payload.Operation != "exec" || + payload.Target != "pwd" || payload.Message != "need confirm" { + t.Fatalf("unexpected payload: %+v", payload) + } + + if _, ok := buildPendingPermissionPayload(binding, "req-approved"); ok { + t.Fatal("expected resolved request to not build pending payload") + } + if _, ok := buildPendingPermissionPayload(binding, ""); ok { + t.Fatal("expected empty request id to fail") + } + + if got := findApprovalDecision(binding.ApprovalRecords, " req-approved "); got != "allow_once" { + t.Fatalf("expected approval decision, got %q", got) + } + if got := findApprovalDecision(binding.ApprovalRecords, "missing"); got != "" { + t.Fatalf("expected missing decision to be empty, got %q", got) + } +} + +func TestSyncBindingApprovalsFromFSMLocked(t *testing.T) { + adapter := newTestAdapter(t) + binding := sessionBinding{} + fsm := &approvalFSMState{ + ActiveRequestID: "req-active", + Requests: map[string]approvalRequestNode{ + "req-active": { + RequestID: "req-active", + ToolName: "bash", + Operation: "exec", + Target: "pwd", + Reason: "pending reason", + Decision: "pending", + State: approvalRequestStateDisplayingPending, + }, + "req-approved": { + RequestID: "req-approved", + ToolName: "fs", + Operation: "read", + Target: "file", + Reason: "approved reason", + Decision: "allow", + State: approvalRequestStateResolvedApproved, + }, + "req-rejected": { + RequestID: "req-rejected", + ToolName: "net", + Operation: "post", + Target: "url", + Reason: "rejected reason", + Decision: "deny", + State: approvalRequestStateResolvedRejected, + }, + "req-archived": { + RequestID: "req-archived", + ToolName: "other", + Operation: "noop", + Target: "x", + Reason: "archived reason", + Decision: "pending", + State: approvalRequestStateArchived, + }, + }, + } + + adapter.syncBindingApprovalsFromFSMLocked(&binding, fsm) + if binding.ApprovalStatus != "pending" { + t.Fatalf("expected pending approval status, got %q", binding.ApprovalStatus) + } + if len(binding.ApprovalRecords) != 4 { + t.Fatalf("expected 4 approval records, got %d", len(binding.ApprovalRecords)) + } + if binding.ApprovalRecords[0].RequestID != "req-active" { + t.Fatalf("expected active request first, got %+v", binding.ApprovalRecords) + } + + statusCases := []struct { + name string + fsm *approvalFSMState + want string + }{ + { + name: "rejected", + fsm: &approvalFSMState{ + Requests: map[string]approvalRequestNode{ + "req": {RequestID: "req", Decision: "reject", State: approvalRequestStateResolvedRejected}, + }, + }, + want: "rejected", + }, + { + name: "approved", + fsm: &approvalFSMState{ + Requests: map[string]approvalRequestNode{ + "req": {RequestID: "req", Decision: "allow_once", State: approvalRequestStateResolvedApproved}, + }, + }, + want: "approved", + }, + { + name: "mixed", + fsm: &approvalFSMState{ + Requests: map[string]approvalRequestNode{ + "req-a": {RequestID: "req-a", Decision: "allow_once", State: approvalRequestStateResolvedApproved}, + "req-r": {RequestID: "req-r", Decision: "reject", State: approvalRequestStateResolvedRejected}, + }, + }, + want: "mixed", + }, + { + name: "none", + fsm: &approvalFSMState{ + Requests: map[string]approvalRequestNode{ + "req": {RequestID: "req", Decision: "", State: approvalRequestStateArchived}, + }, + }, + want: "none", + }, + } + for _, tc := range statusCases { + t.Run(tc.name, func(t *testing.T) { + derived := sessionBinding{} + adapter.syncBindingApprovalsFromFSMLocked(&derived, tc.fsm) + if derived.ApprovalStatus != tc.want { + t.Fatalf("expected %q, got %q", tc.want, derived.ApprovalStatus) + } + }) + } +} + +func TestApprovalHelperLookups(t *testing.T) { + if containsApprovalRequest([]string{" req-1 ", "req-2"}, "req-1") != true { + t.Fatal("expected request to be found in pending stack") + } + if containsApprovalRequest([]string{"req-1"}, " ") { + t.Fatal("expected empty request id to be rejected") + } + if containsApprovalRequest([]string{"req-1"}, "req-2") { + t.Fatal("expected missing request to not be found") + } + + adapter := newTestAdapter(t) + adapter.mu.Lock() + adapter.approvalFSMByRun["run-a"] = &approvalFSMState{ + ActiveRequestID: "shared", + Requests: map[string]approvalRequestNode{ + "shared": {RequestID: "shared"}, + }, + } + adapter.approvalFSMByRun["run-b"] = &approvalFSMState{ + Requests: map[string]approvalRequestNode{ + "shared": {RequestID: "shared"}, + }, + } + adapter.approvalRequestRunIndex[approvalRequestScopedKey("run-a", "shared")] = "run-a" + adapter.approvalRequestRunIndex[approvalRequestScopedKey("run-b", "shared")] = "other-run" + adapter.approvalRequestIDRunIndex["shared"] = "run-b" + adapter.mu.Unlock() + + adapter.mu.RLock() + got := adapter.resolveApprovalRunKeyByRequestLocked("shared") + adapter.mu.RUnlock() + if got != "run-a" { + t.Fatalf("expected active run to win fallback scan, got %q", got) + } + + adapter.mu.Lock() + delete(adapter.approvalRequestRunIndex, approvalRequestScopedKey("run-a", "shared")) + adapter.approvalRequestRunIndex[approvalRequestScopedKey("run-b", "shared")] = "run-b" + adapter.mu.Unlock() + + adapter.mu.RLock() + got = adapter.resolveApprovalRunKeyByRequestLocked("shared") + adapter.mu.RUnlock() + if got != "run-b" { + t.Fatalf("expected indexed run to be used after scoped entry removal, got %q", got) + } + + adapter.mu.RLock() + if got = adapter.resolveApprovalRunKeyByRequestLocked("missing"); got != "" { + adapter.mu.RUnlock() + t.Fatalf("expected missing request id to resolve empty run key, got %q", got) + } + adapter.mu.RUnlock() +} + +func TestApprovalOutboxVersionGuardsAndCleanup(t *testing.T) { + adapter := newTestAdapter(t) + messenger := adapterTestMessenger(adapter) + + if gen, ver, ok := adapter.snapshotApprovalFSMVersion(""); ok || gen != 0 || ver != 0 { + t.Fatalf("expected empty run key snapshot miss, got %d %d %v", gen, ver, ok) + } + if adapter.shouldExecuteApprovalOutbox(approvalOutboxOperation{RunKey: "missing"}) { + t.Fatal("expected missing run key to fail preflight") + } + + adapter.mu.Lock() + adapter.approvalFSMByRun["run-1"] = &approvalFSMState{Generation: 11, Version: 22} + adapter.mu.Unlock() + + if gen, ver, ok := adapter.snapshotApprovalFSMVersion("run-1"); !ok || gen != 11 || ver != 22 { + t.Fatalf("unexpected snapshot: %d %d %v", gen, ver, ok) + } + + match := approvalOutboxOperation{RunKey: "run-1", Generation: 11, Version: 22} + if !adapter.shouldExecuteApprovalOutbox(match) { + t.Fatal("expected matching outbox to pass preflight") + } + if adapter.shouldExecuteApprovalOutbox(approvalOutboxOperation{RunKey: "run-1", Generation: 11, Version: 21}) { + t.Fatal("expected stale outbox to fail preflight") + } + + adapter.confirmApprovalOutbox(match) + adapter.confirmApprovalOutbox(approvalOutboxOperation{RunKey: "run-1", Generation: 10, Version: 22}) + adapter.confirmApprovalOutbox(approvalOutboxOperation{RunKey: "missing", Generation: 1, Version: 1}) + + adapter.cleanupStalePermissionCard(match, "") + adapter.cleanupStalePermissionCard(match, "card-cleanup") + messages := messenger.snapshot() + if len(messages) == 0 || messages[len(messages)-1].kind != "delete_card" || messages[len(messages)-1].chatID != "card-cleanup" { + t.Fatalf("expected cleanup to delete stale card, got %+v", messages) + } +} + +func TestSchedulePermissionCardDismiss(t *testing.T) { + adapter := newTestAdapter(t) + adapter.permissionCardDismissDelay = 5 * time.Millisecond + messenger := adapterTestMessenger(adapter) + + adapter.mu.Lock() + adapter.approvalFSMByRun["run-1"] = &approvalFSMState{CardID: "card-1"} + adapter.approvalCardRunIndex["card-1"] = "run-1" + adapter.mu.Unlock() + + adapter.schedulePermissionCardDismiss("req-1", "card-1") + time.Sleep(30 * time.Millisecond) + + messages := messenger.snapshot() + if len(messages) == 0 || messages[len(messages)-1].kind != "delete_card" || messages[len(messages)-1].chatID != "card-1" { + t.Fatalf("expected permission card delete, got %+v", messages) + } + + adapter.mu.RLock() + defer adapter.mu.RUnlock() + if _, ok := adapter.approvalCardRunIndex["card-1"]; ok { + t.Fatal("expected approval card index removed after dismiss") + } + if got := adapter.approvalFSMByRun["run-1"].CardID; got != "" { + t.Fatalf("expected fsm card id cleared, got %q", got) + } +} + +func TestMarkRunTerminalFallbackPaths(t *testing.T) { + t.Run("empty card id sends terminal text", func(t *testing.T) { + adapter := newTestAdapter(t) + sessionID := BuildSessionID("chat-terminal-empty-card") + runID := BuildRunID("run-terminal-empty-card") + key := runBindingKey(sessionID, runID) + + adapter.mu.Lock() + adapter.activeRuns[key] = sessionBinding{ + SessionID: sessionID, + RunID: runID, + ChatID: "chat-terminal-empty-card", + Status: "running", + Result: "pending", + } + adapter.mu.Unlock() + + adapter.markRunTerminal(sessionID, runID, "success", "", "fallback summary") + + messages := adapterTestMessenger(adapter).snapshot() + if len(messages) == 0 || messages[len(messages)-1].kind != "text" { + t.Fatalf("expected fallback terminal text, got %+v", messages) + } + if !strings.Contains(messages[len(messages)-1].text, "fallback summary") { + t.Fatalf("expected fallback summary in terminal text, got %+v", messages[len(messages)-1]) + } + + adapter.mu.RLock() + binding := adapter.activeRuns[key] + adapter.mu.RUnlock() + if binding.LastSummary != "fallback summary" || binding.Status != "success" || binding.Result != "success" { + t.Fatalf("unexpected terminal binding: %+v", binding) + } + }) + + t.Run("card update failure falls back to text", func(t *testing.T) { + adapter := newTestAdapter(t) + messenger := adapterTestMessenger(adapter) + messenger.updateCardErr = fmt.Errorf("update failed") + sessionID := BuildSessionID("chat-terminal-update-fail") + runID := BuildRunID("run-terminal-update-fail") + key := runBindingKey(sessionID, runID) + + adapter.mu.Lock() + adapter.activeRuns[key] = sessionBinding{ + SessionID: sessionID, + RunID: runID, + ChatID: "chat-terminal-update-fail", + CardID: "status-card-1", + Status: "running", + Result: "pending", + LastSummary: "old summary", + } + adapter.mu.Unlock() + + adapter.markRunTerminal(sessionID, runID, "failure", "new summary", "") + + messages := messenger.snapshot() + if len(messages) < 2 { + t.Fatalf("expected update failure followed by fallback text, got %+v", messages) + } + last := messages[len(messages)-1] + if last.kind != "text" || !strings.Contains(last.text, "new summary") { + t.Fatalf("expected fallback text with new summary, got %+v", last) + } + }) +} + +func TestHandleMessageBranches(t *testing.T) { + t.Run("rejects missing identifiers", func(t *testing.T) { + adapter := newTestAdapter(t) + err := adapter.HandleMessage(context.Background(), FeishuMessageEvent{ + MessageID: "", + ChatID: "chat", + ContentText: "run", + }) + if err == nil || !strings.Contains(err.Error(), "missing message_id or chat_id") { + t.Fatalf("expected missing identifier error, got %v", err) + } + }) + + t.Run("ignores empty text and duplicate event", func(t *testing.T) { + adapter := newTestAdapter(t) + event := FeishuMessageEvent{ + EventID: "evt-empty", + MessageID: "msg-empty", + ChatID: "chat-empty", + ContentText: " ", + } + if err := adapter.HandleMessage(context.Background(), event); err != nil { + t.Fatalf("handle empty text: %v", err) + } + if err := adapter.HandleMessage(context.Background(), event); err != nil { + t.Fatalf("handle duplicate empty text: %v", err) + } + if calls := adapterTestGateway(adapter).snapshotCalls(); len(calls) != 0 { + t.Fatalf("expected no gateway calls for empty text, got %v", calls) + } + }) + + t.Run("run failure sends fallback text", func(t *testing.T) { + adapter := newTestAdapter(t) + gateway := adapterTestGateway(adapter) + gateway.runErr = fmt.Errorf("run failed") + + err := adapter.HandleMessage(context.Background(), FeishuMessageEvent{ + EventID: "evt-run-fail", + MessageID: "msg-run-fail", + ChatID: "chat-run-fail", + ChatType: "group", + ContentText: "执行失败分支", + }) + if err == nil || !strings.Contains(err.Error(), "run failed") { + t.Fatalf("expected run failure, got %v", err) + } + + msgs := adapterTestMessenger(adapter).snapshot() + if len(msgs) == 0 || msgs[len(msgs)-1].kind != "text" || msgs[len(msgs)-1].text != "任务受理失败,请稍后重试。" { + t.Fatalf("expected fallback text after run failure, got %+v", msgs) + } + + sessionID := BuildSessionID("chat-run-fail") + runID := BuildRunID("msg-run-fail") + adapter.mu.RLock() + _, exists := adapter.activeRuns[runBindingKey(sessionID, runID)] + adapter.mu.RUnlock() + if exists { + t.Fatal("expected failed run to be untracked") + } + }) +} + +func TestParseUserQuestionTextAnswerAndHelpers(t *testing.T) { + adapter := newTestAdapter(t) + + if values, message, ok := adapter.parseUserQuestionTextAnswer("missing", " free text "); !ok || len(values) != 1 || + values[0] != "free text" || message != "free text" { + t.Fatalf("expected fallback free text answer, got values=%v message=%q ok=%v", values, message, ok) + } + if _, _, ok := adapter.parseUserQuestionTextAnswer("missing", " "); ok { + t.Fatal("expected empty fallback answer to fail") + } + + adapter.mu.Lock() + adapter.pendingQuestions["text"] = userQuestionEntry{RequestID: "text", Kind: "text"} + adapter.pendingQuestions["single-empty-options"] = userQuestionEntry{RequestID: "single-empty-options", Kind: "single_choice"} + adapter.pendingQuestions["single"] = userQuestionEntry{ + RequestID: "single", + Kind: "single_choice", + Options: []UserQuestionCardOption{ + {Label: "Alpha"}, + {Label: "Beta Option"}, + }, + } + adapter.pendingQuestions["multi"] = userQuestionEntry{ + RequestID: "multi", + Kind: "multi_choice", + MaxChoices: 2, + Options: []UserQuestionCardOption{ + {Label: "One"}, + {Label: "Two"}, + {Label: "Three"}, + }, + } + adapter.pendingQuestions["other"] = userQuestionEntry{RequestID: "other", Kind: "other"} + adapter.mu.Unlock() + + if _, _, ok := adapter.parseUserQuestionTextAnswer("text", " "); ok { + t.Fatal("expected empty text answer to fail") + } + if values, message, ok := adapter.parseUserQuestionTextAnswer("text", "hello"); !ok || message != "hello" || values[0] != "hello" { + t.Fatalf("unexpected text answer parse result: values=%v message=%q ok=%v", values, message, ok) + } + if values, message, ok := adapter.parseUserQuestionTextAnswer("single-empty-options", "custom"); !ok || len(values) != 1 || + values[0] != "custom" || message != "" { + t.Fatalf("unexpected single-choice without options result: values=%v message=%q ok=%v", values, message, ok) + } + if values, message, ok := adapter.parseUserQuestionTextAnswer("single", "2"); !ok || len(values) != 1 || + values[0] != "Beta Option" || message != "" { + t.Fatalf("unexpected single-choice index result: values=%v message=%q ok=%v", values, message, ok) + } + if _, _, ok := adapter.parseUserQuestionTextAnswer("single", "missing"); ok { + t.Fatal("expected unknown single-choice label to fail") + } + if values, message, ok := adapter.parseUserQuestionTextAnswer("multi", "one, Two,one"); !ok || len(values) != 2 || + values[0] != "One" || values[1] != "Two" || message != "" { + t.Fatalf("unexpected multi-choice result: values=%v message=%q ok=%v", values, message, ok) + } + if _, _, ok := adapter.parseUserQuestionTextAnswer("multi", "one two three"); ok { + t.Fatal("expected max-choices violation to fail") + } + if values, message, ok := adapter.parseUserQuestionTextAnswer("other", "free"); !ok || len(values) != 1 || + values[0] != "free" || message != "free" { + t.Fatalf("unexpected default answer parse result: values=%v message=%q ok=%v", values, message, ok) + } + + if got, ok := resolveChoiceLabel(" beta option ", []UserQuestionCardOption{{Label: "Alpha"}, {Label: "Beta Option"}}); !ok || got != "Beta Option" { + t.Fatalf("expected normalized label match, got %q %v", got, ok) + } + if _, ok := resolveChoiceLabel("9", []UserQuestionCardOption{{Label: "Alpha"}}); ok { + t.Fatal("expected out-of-range index to fail") + } + + requestID, body := splitRequestAndBody(" req-1 line one line two ") + if requestID != "req-1" || body != "line one line two" { + t.Fatalf("unexpected request/body split: %q %q", requestID, body) + } + if requestID, body := splitRequestAndBody(" "); requestID != "" || body != "" { + t.Fatalf("expected empty split result, got %q %q", requestID, body) + } + + tokens := splitMultiChoiceTokens(" one,two|three ; two ") + if len(tokens) != 3 || tokens[0] != "one" || tokens[1] != "two" || tokens[2] != "three" { + t.Fatalf("unexpected tokens: %v", tokens) + } + tokens = splitMultiChoiceTokens(" one two one ") + if len(tokens) != 2 || tokens[0] != "one" || tokens[1] != "two" { + t.Fatalf("unexpected whitespace tokens: %v", tokens) + } + + unique := uniqueNonEmptyStrings([]string{" Alpha ", "alpha", "", "Beta"}) + if len(unique) != 2 || unique[0] != "Alpha" || unique[1] != "Beta" { + t.Fatalf("unexpected unique strings: %v", unique) + } +} + +func TestExtractUserQuestionRequestAndApprovalDecisionHelpers(t *testing.T) { + entry := extractUserQuestionRequest(map[string]any{ + "payload": map[string]any{ + "request_id": " req-1 ", + "question_id": " q-1 ", + "title": " 选择环境 ", + "description": " 请确认发布目标 ", + "kind": " Multi_Choice ", + "allow_skip": true, + "max_choices": int32(2), + "options": []any{ + " 测试 ", + map[string]any{"label": " 生产 ", "description": " 正式环境 "}, + map[string]any{"label": " "}, + 123, + }, + }, + }) + if entry.RequestID != "req-1" || entry.QuestionID != "q-1" || entry.Title != "选择环境" || + entry.Description != "请确认发布目标" || entry.Kind != "multi_choice" || + !entry.AllowSkip || entry.MaxChoices != 2 { + t.Fatalf("unexpected user question entry: %+v", entry) + } + if len(entry.Options) != 2 || entry.Options[0].Label != "测试" || entry.Options[1].Label != "生产" || + entry.Options[1].Description != "正式环境" { + t.Fatalf("unexpected user question options: %+v", entry.Options) + } + if fallback := extractUserQuestionRequest(nil); fallback.RequestID != "" || len(fallback.Options) != 0 { + t.Fatalf("expected nil envelope fallback, got %+v", fallback) + } + + requestID, decision := extractPermissionResolved(map[string]any{ + "payload": map[string]any{ + "request_id": " req-2 ", + "decision": " Allow ", + }, + }) + if requestID != "req-2" || decision != "allow" { + t.Fatalf("unexpected resolved permission payload: request_id=%q decision=%q", requestID, decision) + } + if requestID, decision := extractPermissionResolved(nil); requestID != "" || decision != "" { + t.Fatalf("expected nil permission resolved payload, got %q %q", requestID, decision) + } + + if got := normalizeApprovalDecision(" Denied "); got != "reject" { + t.Fatalf("expected denied alias normalized to reject, got %q", got) + } + if got := normalizeApprovalDecision("allow_session"); got != "allow_session" { + t.Fatalf("expected allow_session to stay stable, got %q", got) + } + if !isPermissionRequestNotFoundError(fmt.Errorf("permission request abc not found")) { + t.Fatal("expected not-found error to be detected") + } + if isPermissionRequestNotFoundError(nil) { + t.Fatal("expected nil error to not match") + } + if isPermissionRequestNotFoundError(fmt.Errorf("other error")) { + t.Fatal("expected unrelated error to not match") + } + if readBool(nil, "ok") { + t.Fatal("expected nil map bool lookup to fall back to false") + } + if !readBool(map[string]any{"ok": true}, "ok") { + t.Fatal("expected bool field to be read") + } + if readBool(map[string]any{}, "ok") { + t.Fatal("expected missing bool field to fall back to false") + } + if readBool(map[string]any{"ok": "true"}, "ok") { + t.Fatal("expected non-bool field to fall back to false") + } +} + +func TestUpdateUserQuestionStatusUpdatesCardsAndState(t *testing.T) { + adapter := newTestAdapter(t) + sessionID := BuildSessionID("chat-user-question-status") + runID := BuildRunID("run-user-question-status") + key := runBindingKey(sessionID, runID) + + adapter.mu.Lock() + adapter.activeRuns[key] = sessionBinding{ + SessionID: sessionID, + RunID: runID, + ChatID: "chat-user-question-status", + CardID: "status-card-1", + Status: "running", + Result: "pending", + } + adapter.pendingQuestions["ask-1"] = userQuestionEntry{ + RequestID: "ask-1", + Title: "选择环境", + Kind: "single_choice", + } + adapter.userQuestionCards["ask-1"] = "ask-card-1" + adapter.requestRuns["ask-1"] = key + adapter.mu.Unlock() + + adapter.updateUserQuestionStatus("ask-1", "answered", []string{"测试"}, "已确认") + + msgs := adapterTestMessenger(adapter).snapshot() + if len(msgs) < 2 { + t.Fatalf("expected status card and ask-user card updates, got %+v", msgs) + } + foundStatus := false + foundAskCard := false + for _, msg := range msgs { + if msg.kind == "update_card" && msg.cardID == "status-card-1" { + foundStatus = true + } + if msg.kind == "update_ask_card" && msg.chatID == "ask-card-1" && msg.resolvedUserQuestion != nil && + msg.resolvedUserQuestion.RequestID == "ask-1" && msg.resolvedUserQuestion.Status == "answered" { + foundAskCard = true + } + } + if !foundStatus || !foundAskCard { + t.Fatalf("expected both card updates, got %+v", msgs) + } + + adapter.mu.RLock() + binding := adapter.activeRuns[key] + _, pendingExists := adapter.pendingQuestions["ask-1"] + _, askCardExists := adapter.userQuestionCards["ask-1"] + _, requestExists := adapter.requestRuns["ask-1"] + adapter.mu.RUnlock() + if !strings.Contains(binding.LastSummary, "已确认") { + t.Fatalf("expected resolved summary recorded, got %+v", binding) + } + if pendingExists || askCardExists || requestExists { + t.Fatalf("expected ask-user indexes cleaned, pending=%v card=%v request=%v", pendingExists, askCardExists, requestExists) + } +} + +func TestUpdateApprovalStatusPromotesNextPendingAndUpdatesCards(t *testing.T) { + adapter := newTestAdapter(t) + sessionID := BuildSessionID("chat-approval-update") + runID := BuildRunID("run-approval-update") + key := runBindingKey(sessionID, runID) + + adapter.mu.Lock() + adapter.activeRuns[key] = sessionBinding{ + SessionID: sessionID, + RunID: runID, + ChatID: "chat-approval-update", + CardID: "status-card-1", + Status: "waiting_approval", + Result: "pending", + } + adapter.approvalFSMByRun[key] = &approvalFSMState{ + Generation: 7, + Version: 3, + CardID: "perm-card-1", + ActiveRequestID: "req-1", + PendingStack: []string{"req-2"}, + Requests: map[string]approvalRequestNode{ + "req-1": { + RequestID: "req-1", + ToolName: "bash", + Operation: "exec", + Target: "pwd", + Reason: "need first approval", + Decision: "pending", + State: approvalRequestStateDisplayingPending, + }, + "req-2": { + RequestID: "req-2", + ToolName: "fs", + Operation: "write", + Target: "file.txt", + Reason: "need second approval", + Decision: "pending", + State: approvalRequestStateQueued, + }, + }, + } + adapter.approvalRequestRunIndex[approvalRequestScopedKey(key, "req-1")] = key + adapter.approvalRequestRunIndex[approvalRequestScopedKey(key, "req-2")] = key + adapter.approvalRequestIDRunIndex["req-1"] = key + adapter.approvalRequestIDRunIndex["req-2"] = key + adapter.approvalCardRunIndex["perm-card-1"] = key + adapter.runPermissionCardHistory[key] = map[string]struct{}{ + "perm-card-1": {}, + "perm-card-history": {}, + } + adapter.mu.Unlock() + + adapter.updateApprovalStatus("req-1", "allow") + + msgs := adapterTestMessenger(adapter).snapshot() + kinds := map[string]int{} + for _, msg := range msgs { + kinds[msg.kind]++ + } + if kinds["update_card"] == 0 || kinds["update_perm_card"] == 0 || kinds["update_pending_perm_card"] == 0 { + t.Fatalf("expected status/resolved/pending card updates, got %+v", msgs) + } + + adapter.mu.RLock() + fsm := adapter.approvalFSMByRun[key] + binding := adapter.activeRuns[key] + adapter.mu.RUnlock() + if fsm.Version != 4 { + t.Fatalf("expected fsm version incremented, got %d", fsm.Version) + } + if fsm.ActiveRequestID != "req-2" { + t.Fatalf("expected queued request promoted, got %q", fsm.ActiveRequestID) + } + if fsm.Requests["req-1"].State != approvalRequestStateResolvedApproved { + t.Fatalf("expected first request resolved approved, got %+v", fsm.Requests["req-1"]) + } + if fsm.Requests["req-2"].State != approvalRequestStateDisplayingPending { + t.Fatalf("expected second request promoted to displaying_pending, got %+v", fsm.Requests["req-2"]) + } + if binding.ApprovalStatus != "pending" { + t.Fatalf("expected binding approval status stay pending after promotion, got %+v", binding) + } +} + +func TestUntrackRunCleansDerivedState(t *testing.T) { + adapter := newTestAdapter(t) + sessionID := BuildSessionID("chat-untrack") + runID := BuildRunID("run-untrack") + key := runBindingKey(sessionID, runID) + + adapter.mu.Lock() + adapter.activeRuns[key] = sessionBinding{ + SessionID: sessionID, + RunID: runID, + ChatID: "chat-untrack", + CardID: "status-card-1", + } + adapter.requestRuns["ask-1"] = key + adapter.userQuestionCards["ask-1"] = "ask-card-1" + adapter.pendingQuestions["ask-1"] = userQuestionEntry{RequestID: "ask-1"} + adapter.approvalRequestRunIndex[approvalRequestScopedKey(key, "perm-1")] = key + adapter.approvalRequestIDRunIndex["perm-1"] = key + adapter.approvalCardRunIndex["perm-card-1"] = key + adapter.approvalFSMByRun[key] = &approvalFSMState{ + Generation: 1, + Requests: map[string]approvalRequestNode{ + "perm-1": {RequestID: "perm-1"}, + }, + } + adapter.runPermissionCardHistory[key] = map[string]struct{}{"perm-card-1": {}} + adapter.lastProgressAt[key] = time.Now().UTC() + adapter.mu.Unlock() + + adapter.untrackRun(sessionID, runID) + + adapter.mu.RLock() + defer adapter.mu.RUnlock() + if _, ok := adapter.activeRuns[key]; ok { + t.Fatal("expected active run removed") + } + if _, ok := adapter.requestRuns["ask-1"]; ok { + t.Fatal("expected requestRuns cleaned") + } + if _, ok := adapter.userQuestionCards["ask-1"]; ok { + t.Fatal("expected userQuestionCards cleaned") + } + if _, ok := adapter.pendingQuestions["ask-1"]; ok { + t.Fatal("expected pendingQuestions cleaned") + } + if _, ok := adapter.approvalRequestRunIndex[approvalRequestScopedKey(key, "perm-1")]; ok { + t.Fatal("expected approvalRequestRunIndex cleaned") + } + if _, ok := adapter.approvalRequestIDRunIndex["perm-1"]; ok { + t.Fatal("expected approvalRequestIDRunIndex cleaned") + } + if _, ok := adapter.approvalCardRunIndex["perm-card-1"]; ok { + t.Fatal("expected approvalCardRunIndex cleaned") + } + if _, ok := adapter.approvalFSMByRun[key]; ok { + t.Fatal("expected approval FSM cleaned") + } + if _, ok := adapter.runPermissionCardHistory[key]; ok { + t.Fatal("expected permission card history cleaned") + } + if _, ok := adapter.lastProgressAt[key]; ok { + t.Fatal("expected progress throttle state cleaned") + } +} + +func TestExtractProgressLineAndProgressHelpers(t *testing.T) { + cases := []struct { + name string + runtimeType string + envelope map[string]any + want string + }{ + { + name: "phase changed", + runtimeType: "phase_changed", + envelope: map[string]any{"payload": map[string]any{"to": "tool_call"}}, + want: "进入阶段:tool_call", + }, + { + name: "tool start", + runtimeType: "tool_start", + envelope: map[string]any{"payload": map[string]any{"tool_name": "bash", "operation": "exec", "target": "pwd"}}, + want: "开始工具:bash · exec · pwd", + }, + { + name: "tool result status", + runtimeType: "tool_result", + envelope: map[string]any{"payload": map[string]any{"tool_name": "bash", "status": "ok"}}, + want: "bash完成:ok", + }, + { + name: "tool result default name", + runtimeType: "tool_result", + envelope: map[string]any{"payload": map[string]any{}}, + want: "工具完成", + }, + { + name: "permission requested default tool", + runtimeType: "permission_requested", + envelope: map[string]any{"payload": map[string]any{}}, + want: "等待审批:工具操作", + }, + { + name: "permission rejected", + runtimeType: "permission_resolved", + envelope: map[string]any{"payload": map[string]any{"decision": "reject"}}, + want: "审批结果:已拒绝", + }, + { + name: "permission approved", + runtimeType: "permission_resolved", + envelope: map[string]any{"payload": map[string]any{"decision": "allow"}}, + want: "审批结果:已通过", + }, + { + name: "user question requested", + runtimeType: "user_question_requested", + envelope: map[string]any{}, + want: "等待用户回答问题", + }, + { + name: "user question answered", + runtimeType: "user_question_answered", + envelope: map[string]any{}, + want: "用户已回答问题", + }, + { + name: "user question skipped", + runtimeType: "user_question_skipped", + envelope: map[string]any{}, + want: "用户已跳过问题", + }, + { + name: "run error", + runtimeType: "run_error", + envelope: map[string]any{"payload": map[string]any{"message": "boom"}}, + want: "执行失败:boom", + }, + { + name: "run done", + runtimeType: "run_done", + envelope: map[string]any{}, + want: "执行完成", + }, + { + name: "unknown", + runtimeType: "other", + envelope: map[string]any{}, + want: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := extractProgressLine(tc.runtimeType, tc.envelope); got != tc.want { + t.Fatalf("expected %q, got %q", tc.want, got) + } + }) + } + + trail := appendProgressTrail([]string{"a"}, "a", 3) + if len(trail) != 1 || trail[0] != "a" { + t.Fatalf("expected duplicate line ignored, got %v", trail) + } + trail = appendProgressTrail(trail, "b", 2) + trail = appendProgressTrail(trail, "c", 2) + if len(trail) != 2 || trail[0] != "b" || trail[1] != "c" { + t.Fatalf("expected tail truncation, got %v", trail) + } + trail = appendProgressTrail(trail, " ", 2) + if len(trail) != 2 { + t.Fatalf("expected blank line ignored, got %v", trail) + } + + if !equalStringSlices([]string{"a", "b"}, []string{"a", "b"}) { + t.Fatal("expected equal slices to match") + } + if equalStringSlices([]string{"a"}, []string{"b"}) { + t.Fatal("expected differing slices to not match") + } + if equalStringSlices([]string{"a"}, []string{"a", "b"}) { + t.Fatal("expected differing lengths to not match") + } +} + func newTestAdapter(t *testing.T) *Adapter { t.Helper() gateway := newFakeGatewayClient() diff --git a/internal/feishuadapter/gateway_client_test.go b/internal/feishuadapter/gateway_client_test.go index 0f07d954..f76aafe4 100644 --- a/internal/feishuadapter/gateway_client_test.go +++ b/internal/feishuadapter/gateway_client_test.go @@ -124,6 +124,13 @@ func TestGatewayRPCClientDelegatesRPCMethods(t *testing.T) { if err := client.Run(ctx, "session-1", "run-1", "hello"); err != nil { t.Fatalf("run: %v", err) } + canceled, err := client.CancelRun(ctx, "session-1", "run-1") + if err != nil { + t.Fatalf("cancel run: %v", err) + } + if canceled { + t.Fatal("expected empty rpc result to decode canceled=false") + } if err := client.ResolvePermission(ctx, "perm-1", "allow_once"); err != nil { t.Fatalf("resolve permission: %v", err) } @@ -154,6 +161,7 @@ func TestGatewayRPCClientDelegatesRPCMethods(t *testing.T) { protocol.MethodGatewayAuthenticate, protocol.MethodGatewayBindStream, protocol.MethodGatewayRun, + protocol.MethodGatewayCancel, protocol.MethodGatewayResolvePermission, protocol.MethodGatewayUserQuestionAnswer, protocol.MethodGatewayPing, diff --git a/internal/feishuadapter/messenger_test.go b/internal/feishuadapter/messenger_test.go index 0c6497de..a9da6fdb 100644 --- a/internal/feishuadapter/messenger_test.go +++ b/internal/feishuadapter/messenger_test.go @@ -351,6 +351,82 @@ func TestBuildStatusCardAndHelpers(t *testing.T) { } } +func TestBuildStatusCardWithApprovalRecordsAndProgress(t *testing.T) { + card := buildStatusCard(StatusCardPayload{ + TaskName: "deploy", + Status: "interrupted", + Result: "pending", + Elapsed: "12s", + Summary: "处理中断", + ApprovalRecords: []ApprovalRecord{ + {ToolName: "bash", Decision: "allow_once"}, + {ToolName: "git", Decision: "reject"}, + }, + PendingCount: 1, + ProgressLines: []string{"拉取代码", " ", "执行部署"}, + AsyncRewakeHint: "等待重试", + }) + raw, err := json.Marshal(card) + if err != nil { + t.Fatalf("marshal status card: %v", err) + } + content := string(raw) + if !strings.Contains(content, "处理中断") || !strings.Contains(content, "等待重试") || + !strings.Contains(content, "**过程**") || !strings.Contains(content, "拉取代码") || + !strings.Contains(content, "执行部署") || !strings.Contains(content, "2/2 已审批") { + t.Fatalf("status card content = %s, want approval/progress/summary sections", content) + } + + iconCases := map[string][2]string{ + "running": {"⚙️", "indigo"}, + "pending": {"⏳", "yellow"}, + "approved": {"✅", "green"}, + "rejected": {"❌", "red"}, + "interrupted": {"⏹️", "orange"}, + "allow_once": {"✅", "green"}, + "deny": {"❌", "red"}, + } + for status, want := range iconCases { + icon, color := statusIconAndColor(status) + if icon != want[0] || color != want[1] { + t.Fatalf("status %q icon/color = %q/%q, want %q/%q", status, icon, color, want[0], want[1]) + } + } +} + +func TestBuildUserQuestionCardFallbacksAndKinds(t *testing.T) { + multiChoice := buildUserQuestionCard(UserQuestionCardPayload{ + RequestID: "ask-multi", + Kind: "multi_choice", + AllowSkip: true, + }) + rawMulti, err := json.Marshal(multiChoice) + if err != nil { + t.Fatalf("marshal multi choice card: %v", err) + } + multiContent := string(rawMulti) + if !strings.Contains(multiContent, "请回答问题") || !strings.Contains(multiContent, "回答 ask-multi") || + !strings.Contains(multiContent, "跳过") { + t.Fatalf("multi choice card = %s, want fallback title, reply hint and skip action", multiContent) + } + + textCard := buildUserQuestionCard(UserQuestionCardPayload{ + RequestID: "ask-text", + Kind: "text", + }) + rawText, err := json.Marshal(textCard) + if err != nil { + t.Fatalf("marshal text card: %v", err) + } + textContent := string(rawText) + if !strings.Contains(textContent, "回答 ask-text") { + t.Fatalf("text card = %s, want text reply hint", textContent) + } + if strings.Contains(textContent, "\"tag\":\"action\"") { + t.Fatalf("text card = %s, did not expect action block without options or skip", textContent) + } +} + func TestSendAndUpdateUserQuestionCard(t *testing.T) { client := &queuedHTTPClient{ responses: []queuedHTTPResponse{ @@ -509,3 +585,43 @@ func TestUpdatePermissionCardAndResolvedCardHelpers(t *testing.T) { t.Fatalf("timeout title = %#v, want default title", timeoutHeader["title"]) } } + +func TestUpdatePendingPermissionCardUsesPatch(t *testing.T) { + client := &queuedHTTPClient{ + responses: []queuedHTTPResponse{ + {status: 200, body: `{"code":0,"msg":"ok","tenant_access_token":"token","expire":7200}`}, + {status: 200, body: `{"code":0,"msg":"ok","data":{"message_id":"updated"}}`}, + }, + } + messenger := NewFeishuMessenger("app", "secret", client) + err := messenger.UpdatePendingPermissionCard(context.Background(), "perm-card", PermissionCardPayload{ + RequestID: "perm-1", + ToolName: "bash", + Operation: "exec", + Target: "pwd", + Message: "需要审批", + }) + if err != nil { + t.Fatalf("update pending permission card: %v", err) + } + if len(client.requests) != 2 { + t.Fatalf("request count = %d, want 2", len(client.requests)) + } + if client.requests[1].Method != http.MethodPatch { + t.Fatalf("update method = %s, want PATCH", client.requests[1].Method) + } + if !strings.Contains(string(client.bodies[1]), "perm-1") || !strings.Contains(string(client.bodies[1]), "allow_once") { + t.Fatalf("update body = %s, want pending permission card content", string(client.bodies[1])) + } +} + +func TestDeleteMessageSkipsBlankMessageID(t *testing.T) { + client := &queuedHTTPClient{} + messenger := NewFeishuMessenger("app", "secret", client) + if err := messenger.DeleteMessage(context.Background(), " "); err != nil { + t.Fatalf("delete blank message id: %v", err) + } + if len(client.requests) != 0 { + t.Fatalf("expected no http requests for blank message id, got %d", len(client.requests)) + } +} diff --git a/internal/feishuadapter/webhook_ingress_test.go b/internal/feishuadapter/webhook_ingress_test.go new file mode 100644 index 00000000..159a51d4 --- /dev/null +++ b/internal/feishuadapter/webhook_ingress_test.go @@ -0,0 +1,216 @@ +package feishuadapter + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +type failingIngressHandler struct { + messageErr error + cardErr error +} + +func (f *failingIngressHandler) HandleMessage(_ context.Context, _ FeishuMessageEvent) error { + return f.messageErr +} + +func (f *failingIngressHandler) HandleCardAction(_ context.Context, _ FeishuCardActionEvent) error { + return f.cardErr +} + +func TestNewWebhookIngressAndRunContextCancel(t *testing.T) { + ingress, ok := NewWebhookIngress(Config{ + ListenAddress: "127.0.0.1:0", + EventPath: "/events", + CardPath: "/cards", + }, nil).(*WebhookIngress) + if !ok { + t.Fatal("expected webhook ingress instance") + } + if ingress.nowFn == nil { + t.Fatal("expected default nowFn") + } + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- ingress.Run(ctx, &captureIngressHandler{}) + }() + + time.Sleep(20 * time.Millisecond) + cancel() + + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context canceled, got %v", err) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for webhook ingress shutdown") + } +} + +func TestNewWebhookIngressUsesProvidedClockAndRunReturnsListenError(t *testing.T) { + fixed := time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC) + nowFn := func() time.Time { return fixed } + ingress, ok := NewWebhookIngress(Config{ + ListenAddress: "bad::addr", + EventPath: "/events", + CardPath: "/cards", + }, nowFn).(*WebhookIngress) + if !ok { + t.Fatal("expected webhook ingress instance") + } + if got := ingress.nowFn(); !got.Equal(fixed) { + t.Fatalf("expected provided nowFn, got %v", got) + } + if err := ingress.Run(context.Background(), &captureIngressHandler{}); err == nil { + t.Fatal("expected listen error for invalid address") + } +} + +func TestWebhookIngressHandleFeishuEventVerificationIgnoreAndHandlerError(t *testing.T) { + ingress, ok := NewWebhookIngress(Config{ + VerifyToken: "verify", + SigningSecret: "sign-secret", + }, nil).(*WebhookIngress) + if !ok { + t.Fatal("expected webhook ingress instance") + } + + t.Run("url verification", func(t *testing.T) { + request := signedRequest(t, ingress.cfg.SigningSecret, `{"type":"url_verification","challenge":"hello","token":"verify"}`) + recorder := httptest.NewRecorder() + + ingress.handleFeishuEvent(&captureIngressHandler{}).ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) + } + if !strings.Contains(recorder.Body.String(), `"challenge":"hello"`) { + t.Fatalf("response = %s, want challenge body", recorder.Body.String()) + } + }) + + t.Run("unsupported event ignored before token check", func(t *testing.T) { + request := signedRequest(t, ingress.cfg.SigningSecret, `{"header":{"event_type":"other"}}`) + recorder := httptest.NewRecorder() + + ingress.handleFeishuEvent(&captureIngressHandler{}).ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) + } + if !strings.Contains(recorder.Body.String(), `"message":"ignored"`) { + t.Fatalf("response = %s, want ignored", recorder.Body.String()) + } + }) + + t.Run("handler error returns retryable response", func(t *testing.T) { + body := `{"header":{"event_id":"evt-1","event_type":"im.message.receive_v1","token":"verify"},"event":{"message":{"message_id":"msg-1","chat_id":"chat-1","content":"{\"text\":\"hello\"}"}}}` + request := signedRequest(t, ingress.cfg.SigningSecret, body) + recorder := httptest.NewRecorder() + + ingress.handleFeishuEvent(&failingIngressHandler{messageErr: errors.New("boom")}).ServeHTTP(recorder, request) + + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusInternalServerError) + } + if !strings.Contains(recorder.Body.String(), "retryable_error") { + t.Fatalf("response = %s, want retryable_error", recorder.Body.String()) + } + }) +} + +func TestWebhookIngressHandleCardCallbackActionResponses(t *testing.T) { + ingress, ok := NewWebhookIngress(Config{ + VerifyToken: "verify", + SigningSecret: "sign-secret", + }, nil).(*WebhookIngress) + if !ok { + t.Fatal("expected webhook ingress instance") + } + + t.Run("url verification", func(t *testing.T) { + request := signedRequest(t, ingress.cfg.SigningSecret, `{"type":"url_verification","challenge":"card-ok","token":"verify","header":{"token":"verify"}}`) + recorder := httptest.NewRecorder() + + ingress.handleCardCallback(&captureIngressHandler{}).ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) + } + if !strings.Contains(recorder.Body.String(), `"challenge":"card-ok"`) { + t.Fatalf("response = %s, want challenge body", recorder.Body.String()) + } + }) + + t.Run("invalid callback returns ready toast", func(t *testing.T) { + request := signedRequest(t, ingress.cfg.SigningSecret, `{"action":{"value":{"action_type":"permission","request_id":"perm-1","decision":"allow_all"}},"token":"verify","header":{"token":"verify"}}`) + recorder := httptest.NewRecorder() + + ingress.handleCardCallback(&captureIngressHandler{}).ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) + } + if !strings.Contains(recorder.Body.String(), "callback ready") { + t.Fatalf("response = %s, want callback ready", recorder.Body.String()) + } + }) + + t.Run("permission success toast", func(t *testing.T) { + request := signedRequest(t, ingress.cfg.SigningSecret, `{"action":{"value":{"request_id":"perm-2","decision":"allow_once"}},"token":"verify","header":{"event_id":"evt-perm","token":"verify"}}`) + recorder := httptest.NewRecorder() + handler := &captureIngressHandler{} + + ingress.handleCardCallback(handler).ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) + } + if len(handler.cards) != 1 || handler.cards[0].Decision != "allow_once" { + t.Fatalf("unexpected cards: %#v", handler.cards) + } + if !strings.Contains(recorder.Body.String(), "审批已提交") { + t.Fatalf("response = %s, want permission toast", recorder.Body.String()) + } + }) + + t.Run("user question success toast", func(t *testing.T) { + request := signedRequest(t, ingress.cfg.SigningSecret, `{"action":{"value":{"action_type":"user_question","request_id":"ask-1","status":"answered","value":"A"}},"open_message_id":"card-1","token":"verify","header":{"event_id":"evt-ask","token":"verify"}}`) + recorder := httptest.NewRecorder() + handler := &captureIngressHandler{} + + ingress.handleCardCallback(handler).ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) + } + if len(handler.cards) != 1 || handler.cards[0].ActionType != "user_question" { + t.Fatalf("unexpected cards: %#v", handler.cards) + } + if !strings.Contains(recorder.Body.String(), "回答已提交") { + t.Fatalf("response = %s, want user question toast", recorder.Body.String()) + } + }) + + t.Run("handler error returns server error", func(t *testing.T) { + request := signedRequest(t, ingress.cfg.SigningSecret, `{"action":{"value":{"request_id":"perm-3","decision":"reject"}},"token":"verify","header":{"token":"verify"}}`) + recorder := httptest.NewRecorder() + + ingress.handleCardCallback(&failingIngressHandler{cardErr: errors.New("boom")}).ServeHTTP(recorder, request) + + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusInternalServerError) + } + if !strings.Contains(recorder.Body.String(), "card action failed") { + t.Fatalf("response = %s, want card action failed", recorder.Body.String()) + } + }) +}