diff --git a/docs/gateway-rpc-api.md b/docs/gateway-rpc-api.md index babaf265..87653414 100644 --- a/docs/gateway-rpc-api.md +++ b/docs/gateway-rpc-api.md @@ -421,6 +421,27 @@ type ResolvePermissionParams struct { --- +## Method: gateway.userQuestionAnswer + +- Stability: Beta +- Auth Required: Yes +- Request Schema: + +```go +type UserQuestionAnswerParams struct { + RequestID string `json:"request_id"` // MUST + Status string `json:"status,omitempty"` // answered|skipped,默认 answered + Values []string `json:"values,omitempty"` // 可选:选择值 + Message string `json:"message,omitempty"` // 可选:文本回答 +} +``` + +- Response Schema: `ack`(提交成功)或标准 `error` +- Observation: + - `gateway_requests_total{method="gateway.userQuestionAnswer",...}` + +--- + ## Method: gateway.listProviders - Stability: Stable @@ -656,6 +677,14 @@ type GetRuntimeSnapshotParams struct { - Response Schema: - Success: `ack` + `payload.snapshot`(runtime facts、decision、todo snapshot) + - `payload.snapshot.pending_user_question`(可选): + - `request_id` + - `question_id` + - `title` / `description` + - `kind`(`text|single_choice|multi_choice`) + - `options` + - `required` / `allow_skip` + - `max_choices` / `timeout_sec` - Failure: 标准 `error` - Observation: - `gateway_requests_total{method="runtime.snapshot.get",...}` diff --git a/docs/guides/feishu-adapter.md b/docs/guides/feishu-adapter.md index b0585ae0..6db6f60c 100644 --- a/docs/guides/feishu-adapter.md +++ b/docs/guides/feishu-adapter.md @@ -12,6 +12,7 @@ - `gateway.bindStream` - `gateway.run` - `gateway.resolvePermission` + - `gateway.userQuestionAnswer` - `gateway.event` - 会话与运行 ID 保持实现一致: - `session_id = "feishu_" + stableHash(chat_id)` @@ -86,7 +87,7 @@ SDK 模式下不要求公网回调地址,不要求 `adapter.listen/event_path/ ## 6. 幂等与重试 - 消息去重键:`event_id + message_id` -- 卡片去重键:`request_id + decision` +- 卡片去重键:优先 `event_id`,无 `event_id` 时回退到动作键(如 `request_id + decision/status`) - 仅当 `gateway.run` 成功受理后才标记成功; - 若 `run` 失败会释放去重状态,Webhook 返回 `HTTP 500`,SDK 长连接回调返回失败 ACK,允许飞书重试恢复。 @@ -109,6 +110,22 @@ SDK 模式下不要求公网回调地址,不要求 `adapter.listen/event_path/ 以上两种路径都复用 `gateway.resolvePermission`,不新增 Gateway action。 +## 7.1 ask_user 统一交互(permission + user_question) + +飞书端卡片回调已升级为通用 `action_type`: + +- `action_type=permission` -> `gateway.resolvePermission` +- `action_type=user_question` -> `gateway.userQuestionAnswer` + +V1 交互策略: + +- 单选/跳过:优先卡片按钮提交 +- 文本/多选:回退文本指令提交 + - `回答 <内容>` + - `跳过 ` + +`webhook` 与 `sdk` 两条 ingress 使用同一动作映射与幂等规则,保证行为一致。 + ## 8. 安全要求 - 默认启用签名校验(Webhook); diff --git a/internal/cli/feishu_adapter_command_test.go b/internal/cli/feishu_adapter_command_test.go index 1518da72..352e76a9 100644 --- a/internal/cli/feishu_adapter_command_test.go +++ b/internal/cli/feishu_adapter_command_test.go @@ -214,6 +214,9 @@ func (s *stubFeishuGatewayClient) Run(context.Context, string, string, string) e func (s *stubFeishuGatewayClient) ResolvePermission(context.Context, string, string) error { return nil } +func (s *stubFeishuGatewayClient) ResolveUserQuestion(context.Context, string, string, []string, string) error { + return nil +} func (s *stubFeishuGatewayClient) Ping(context.Context) error { return nil } func (s *stubFeishuGatewayClient) Notifications() <-chan feishuadapter.GatewayNotification { ch := make(chan feishuadapter.GatewayNotification) @@ -234,6 +237,12 @@ func (stubFeishuMessenger) SendPermissionCard(context.Context, string, feishuada func (stubFeishuMessenger) UpdatePermissionCard(context.Context, string, feishuadapter.ResolvedPermissionCardPayload) error { return nil } +func (stubFeishuMessenger) SendUserQuestionCard(context.Context, string, feishuadapter.UserQuestionCardPayload) (string, error) { + return "", nil +} +func (stubFeishuMessenger) UpdateUserQuestionCard(context.Context, string, feishuadapter.ResolvedUserQuestionCardPayload) error { + return nil +} func (stubFeishuMessenger) SendStatusCard(context.Context, string, feishuadapter.StatusCardPayload) (string, error) { return "", nil } diff --git a/internal/cli/gateway_runtime_bridge.go b/internal/cli/gateway_runtime_bridge.go index 573bd42d..905741ad 100644 --- a/internal/cli/gateway_runtime_bridge.go +++ b/internal/cli/gateway_runtime_bridge.go @@ -1722,6 +1722,28 @@ func convertRuntimeSnapshot(snapshot agentruntime.RuntimeSnapshot) gateway.Runti "completed_count": snapshot.SubAgents.CompletedCount, "failed_count": snapshot.SubAgents.FailedCount, }, + PendingUserQuestion: convertRuntimePendingUserQuestion(snapshot.PendingUserQuestion), + } +} + +// convertRuntimePendingUserQuestion 把 runtime ask_user 待答快照映射为 gateway 契约结构。 +func convertRuntimePendingUserQuestion( + payload *agentruntime.UserQuestionRequestedPayload, +) *gateway.PendingUserQuestionSnapshot { + if payload == nil { + return nil + } + return &gateway.PendingUserQuestionSnapshot{ + RequestID: strings.TrimSpace(payload.RequestID), + QuestionID: strings.TrimSpace(payload.QuestionID), + Title: strings.TrimSpace(payload.Title), + Description: strings.TrimSpace(payload.Description), + Kind: strings.TrimSpace(payload.Kind), + Options: append([]any(nil), payload.Options...), + Required: payload.Required, + AllowSkip: payload.AllowSkip, + MaxChoices: payload.MaxChoices, + TimeoutSec: payload.TimeoutSec, } } diff --git a/internal/cli/gateway_runtime_bridge_test.go b/internal/cli/gateway_runtime_bridge_test.go index 5d9a6548..b2f2518e 100644 --- a/internal/cli/gateway_runtime_bridge_test.go +++ b/internal/cli/gateway_runtime_bridge_test.go @@ -23,22 +23,24 @@ import ( ) type runtimeStub struct { - submitInput agentruntime.PrepareInput - submitErr error - askInput agentruntime.AskInput - askErr error - deleteAskInput agentruntime.DeleteAskSessionInput - deleteAskResult bool - deleteAskErr error - compactInput agentruntime.CompactInput - compactResult agentruntime.CompactResult - compactErr error - systemToolInput agentruntime.SystemToolInput - systemToolRes tools.ToolResult - systemToolErr error - permissionInput agentruntime.PermissionResolutionInput - permissionErr error - activateSession struct { + submitInput agentruntime.PrepareInput + submitErr error + askInput agentruntime.AskInput + askErr error + deleteAskInput agentruntime.DeleteAskSessionInput + deleteAskResult bool + deleteAskErr error + compactInput agentruntime.CompactInput + compactResult agentruntime.CompactResult + compactErr error + systemToolInput agentruntime.SystemToolInput + systemToolRes tools.ToolResult + systemToolErr error + permissionInput agentruntime.PermissionResolutionInput + permissionErr error + userQuestionInput agentruntime.UserQuestionResolutionInput + userQuestionErr error + activateSession struct { sessionID string skillID string } @@ -129,7 +131,8 @@ func (s *runtimeStub) ResolvePermission(_ context.Context, input agentruntime.Pe } func (s *runtimeStub) ResolveUserQuestion(_ context.Context, input agentruntime.UserQuestionResolutionInput) error { - return nil + s.userQuestionInput = input + return s.userQuestionErr } func (s *runtimeStub) CancelActiveRun() bool { @@ -856,6 +859,21 @@ func TestGatewayRuntimePortBridgeRuntimeMethods(t *testing.T) { if stub.permissionInput.RequestID != "request-1" || string(stub.permissionInput.Decision) != "allow_session" { t.Fatalf("permission input = %#v, want trimmed request id and allow_session", stub.permissionInput) } + if err := bridge.ResolveUserQuestion(context.Background(), gateway.UserQuestionAnswerInput{ + SubjectID: testBridgeSubjectID, + RequestID: " ask-1 ", + Status: " answered ", + Values: []string{"A", "B"}, + Message: " final answer ", + }); err != nil { + t.Fatalf("resolve_user_question: %v", err) + } + if stub.userQuestionInput.RequestID != "ask-1" || stub.userQuestionInput.Status != "answered" { + t.Fatalf("user question input = %#v, want trimmed request id and status", stub.userQuestionInput) + } + if stub.userQuestionInput.Message != "final answer" || len(stub.userQuestionInput.Values) != 2 { + t.Fatalf("user question input = %#v, want message and values forwarded", stub.userQuestionInput) + } canceled, err := bridge.CancelRun(context.Background(), gateway.CancelInput{ SubjectID: testBridgeSubjectID, @@ -1059,6 +1077,7 @@ func TestGatewayRuntimePortBridgeRuntimeMethodErrors(t *testing.T) { compactErr: errors.New("compact failed"), systemToolErr: errors.New("system tool failed"), permissionErr: errors.New("permission failed"), + userQuestionErr: errors.New("user question failed"), activateSessionErr: errors.New("activate skill failed"), deactivateSessionErr: errors.New("deactivate skill failed"), sessionSkillsErr: errors.New("list session skills failed"), @@ -1115,6 +1134,11 @@ func TestGatewayRuntimePortBridgeRuntimeMethodErrors(t *testing.T) { }); err == nil { t.Fatal("expected resolve_permission error from runtime") } + if err := bridge.ResolveUserQuestion(context.Background(), gateway.UserQuestionAnswerInput{ + SubjectID: testBridgeSubjectID, + }); err == nil { + t.Fatal("expected resolve_user_question error from runtime") + } if _, err := bridge.ListSessions(context.Background()); err == nil { t.Fatal("expected list_sessions error from runtime") } diff --git a/internal/feishuadapter/adapter.go b/internal/feishuadapter/adapter.go index b54f8472..fe414fba 100644 --- a/internal/feishuadapter/adapter.go +++ b/internal/feishuadapter/adapter.go @@ -8,6 +8,7 @@ import ( "log" "math/rand" "net/http" + "strconv" "strings" "sync" "time" @@ -26,6 +27,17 @@ type approvalEntry struct { Decision string // "pending", "allow_once", "reject" } +type userQuestionEntry struct { + RequestID string + QuestionID string + Title string + Description string + Kind string + Options []UserQuestionCardOption + AllowSkip bool + MaxChoices int +} + type sessionBinding struct { SessionID string ChatID string @@ -51,12 +63,14 @@ 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 + 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 + userQuestionCards map[string]string // requestID -> card message_id + pendingQuestions map[string]userQuestionEntry } // New 创建飞书适配器实例。 @@ -74,17 +88,19 @@ func New(cfg Config, gateway GatewayClient, messenger Messenger, logger *log.Log logger = log.New(io.Discard, "", 0) } return &Adapter{ - cfg: cfg, - gateway: gateway, - messenger: messenger, - logger: logger, - idem: newIdempotencyStore(cfg.IdempotencyTTL), - nowFn: func() time.Time { return time.Now().UTC() }, - activeRuns: make(map[string]sessionBinding), - sessionChats: make(map[string]string), - requestRuns: make(map[string]string), - lastProgressAt: make(map[string]time.Time), - permissionCards: make(map[string]string), + cfg: cfg, + gateway: gateway, + messenger: messenger, + logger: logger, + idem: newIdempotencyStore(cfg.IdempotencyTTL), + nowFn: func() time.Time { return time.Now().UTC() }, + activeRuns: make(map[string]sessionBinding), + sessionChats: make(map[string]string), + requestRuns: make(map[string]string), + lastProgressAt: make(map[string]time.Time), + permissionCards: make(map[string]string), + userQuestionCards: make(map[string]string), + pendingQuestions: make(map[string]userQuestionEntry), }, nil } @@ -167,7 +183,7 @@ func (a *Adapter) HandleMessage(ctx context.Context, event FeishuMessageEvent) e if text == "" { return nil } - if handled, err := a.tryHandleTextPermission(ctx, event.ChatID, text); handled { + if handled, err := a.tryHandleTextAction(ctx, event.ChatID, text); handled { if err == nil { succeeded = true } @@ -188,11 +204,19 @@ func (a *Adapter) HandleMessage(ctx context.Context, event FeishuMessageEvent) e // HandleCardAction 处理标准化后的审批动作事件并映射到网关授权接口。 func (a *Adapter) HandleCardAction(ctx context.Context, event FeishuCardActionEvent) error { requestID := strings.TrimSpace(event.RequestID) - decision := strings.TrimSpace(strings.ToLower(event.Decision)) - if requestID == "" || (decision != "allow_once" && decision != "reject") { + if requestID == "" { return nil } - dedupeKey := "card:" + requestID + ":" + decision + actionType := strings.TrimSpace(strings.ToLower(event.ActionType)) + if actionType == "" { + if decision := strings.TrimSpace(strings.ToLower(event.Decision)); decision != "" { + actionType = "permission" + } else { + actionType = "user_question" + } + } + + dedupeKey := buildCardActionDedupeKey(event, actionType) if !a.idem.TryStart(dedupeKey, a.nowFn()) { return nil } @@ -207,11 +231,35 @@ func (a *Adapter) HandleCardAction(ctx context.Context, event FeishuCardActionEv callCtx, cancel := context.WithTimeout(ctx, a.cfg.RequestTimeout) defer cancel() - if err := a.gateway.ResolvePermission(callCtx, requestID, decision); err != nil { - a.safeLog("resolve permission failed: %v", err) - return err + switch actionType { + case "permission": + decision := strings.TrimSpace(strings.ToLower(event.Decision)) + if decision != "allow_once" && decision != "reject" { + return nil + } + if err := a.gateway.ResolvePermission(callCtx, requestID, decision); err != nil { + a.safeLog("resolve permission failed: %v", err) + return err + } + a.updateApprovalStatus(requestID, decision) + case "user_question": + status := strings.TrimSpace(strings.ToLower(event.Status)) + if status == "" { + status = "answered" + } + if status != "answered" && status != "skipped" { + return nil + } + values := append([]string(nil), event.Values...) + message := strings.TrimSpace(event.Message) + if err := a.gateway.ResolveUserQuestion(callCtx, requestID, status, values, message); err != nil { + a.safeLog("resolve user question failed: %v", err) + return err + } + a.updateUserQuestionStatus(requestID, status, values, message) + default: + return nil } - a.updateApprovalStatus(requestID, decision) succeeded = true return nil } @@ -272,6 +320,8 @@ func (a *Adapter) untrackRun(sessionID string, runID string) { if requestRunKey == key { delete(a.requestRuns, requestID) delete(a.permissionCards, requestID) + delete(a.userQuestionCards, requestID) + delete(a.pendingQuestions, requestID) } } delete(a.lastProgressAt, key) @@ -344,6 +394,40 @@ func (a *Adapter) handleGatewayEvent(ctx context.Context, raw json.RawMessage) { } return } + } else if strings.EqualFold(runtimeType, "user_question_requested") { + question := extractUserQuestionRequest(envelope) + if question.RequestID != "" { + if !a.markUserQuestionPending(sessionID, runID, question) { + return + } + if shouldSendAskUserCard(question) { + cardID, err := a.messenger.SendUserQuestionCard(ctx, chatID, UserQuestionCardPayload{ + RequestID: question.RequestID, + QuestionID: question.QuestionID, + Title: question.Title, + Description: question.Description, + Kind: question.Kind, + Options: append([]UserQuestionCardOption(nil), question.Options...), + AllowSkip: question.AllowSkip, + }) + if err == nil && strings.TrimSpace(cardID) != "" { + a.mu.Lock() + a.userQuestionCards[question.RequestID] = cardID + a.mu.Unlock() + } + } else { + _ = a.messenger.SendText(ctx, chatID, buildAskUserTextPrompt(question)) + } + return + } + } else if isUserQuestionResolvedEvent(runtimeType) { + resolved := extractUserQuestionResolved(envelope) + if resolved.Status == "" { + resolved.Status = userQuestionStatusFromRuntimeType(runtimeType) + } + if resolved.RequestID != "" { + a.updateUserQuestionStatus(resolved.RequestID, resolved.Status, resolved.Values, resolved.Message) + } } a.handleRunProgressCard(ctx, sessionID, runID, runtimeType, envelope) } @@ -547,6 +631,91 @@ func (a *Adapter) markPermissionPending(sessionID string, runID string, requestI } } +// markUserQuestionPending 记录 ask_user 待回答问题,并挂接到 run 状态卡上下文。 +func (a *Adapter) markUserQuestionPending(sessionID string, runID string, question userQuestionEntry) bool { + requestID := strings.TrimSpace(question.RequestID) + if requestID == "" { + return false + } + key := runBindingKey(sessionID, runID) + a.mu.Lock() + if _, exists := a.pendingQuestions[requestID]; exists { + a.mu.Unlock() + return false + } + if binding, ok := a.activeRuns[key]; ok { + summary := strings.TrimSpace(question.Title) + if summary == "" { + summary = strings.TrimSpace(question.Description) + } + if summary != "" { + binding.LastSummary = "等待用户回答:" + summary + a.activeRuns[key] = binding + } + } + a.requestRuns[requestID] = key + a.pendingQuestions[requestID] = userQuestionEntry{ + RequestID: requestID, + QuestionID: strings.TrimSpace(question.QuestionID), + Title: strings.TrimSpace(question.Title), + Description: strings.TrimSpace(question.Description), + Kind: strings.TrimSpace(strings.ToLower(question.Kind)), + Options: append([]UserQuestionCardOption(nil), question.Options...), + AllowSkip: question.AllowSkip, + MaxChoices: question.MaxChoices, + } + a.mu.Unlock() + return true +} + +// updateUserQuestionStatus 在 ask_user 提交后更新状态卡摘要,并将提问卡片更新为已处理态。 +func (a *Adapter) updateUserQuestionStatus(requestID string, status string, values []string, message string) { + normalizedRequestID := strings.TrimSpace(requestID) + if normalizedRequestID == "" { + return + } + normalizedStatus := strings.TrimSpace(strings.ToLower(status)) + if normalizedStatus == "" { + normalizedStatus = "answered" + } + + a.mu.Lock() + key := a.requestRuns[normalizedRequestID] + binding, ok := a.activeRuns[key] + question := a.pendingQuestions[normalizedRequestID] + if ok { + binding.LastSummary = buildUserQuestionResolvedSummary(question, normalizedStatus, values, message) + a.activeRuns[key] = binding + } + statusCardID := "" + statusPayload := StatusCardPayload{} + if ok { + statusCardID = strings.TrimSpace(binding.CardID) + statusPayload = binding.statusCardPayload() + } + cardID := strings.TrimSpace(a.userQuestionCards[normalizedRequestID]) + delete(a.pendingQuestions, normalizedRequestID) + delete(a.userQuestionCards, normalizedRequestID) + delete(a.requestRuns, normalizedRequestID) + a.mu.Unlock() + + if statusCardID != "" { + if err := a.messenger.UpdateCard(context.Background(), statusCardID, statusPayload); err != nil { + a.safeLog("update ask_user status card failed: %v", err) + } + } + if cardID != "" { + if err := a.messenger.UpdateUserQuestionCard(context.Background(), cardID, ResolvedUserQuestionCardPayload{ + RequestID: normalizedRequestID, + Title: question.Title, + Status: normalizedStatus, + Summary: buildUserQuestionResolvedSummary(question, normalizedStatus, values, message), + }); err != nil { + a.safeLog("update ask_user card failed: %v", err) + } + } +} + // updateApprovalStatus 在审批动作被网关受理后更新 run 卡片中的审批结论,并更新权限卡片为已处理状态。 func (a *Adapter) updateApprovalStatus(requestID string, decision string) { normalizedDecision := strings.TrimSpace(strings.ToLower(decision)) @@ -690,8 +859,8 @@ func isMentionCurrentBot(event FeishuMessageEvent, cfg Config) bool { return false } -// tryHandleTextPermission 处理 SDK 模式下的文本审批降级指令。 -func (a *Adapter) tryHandleTextPermission(ctx context.Context, chatID string, text string) (bool, error) { +// tryHandleTextAction 处理权限审批与 ask_user 的文本降级指令。 +func (a *Adapter) tryHandleTextAction(ctx context.Context, chatID string, text string) (bool, error) { trimmed := strings.TrimSpace(text) if trimmed == "" { return false, nil @@ -703,7 +872,11 @@ func (a *Adapter) tryHandleTextPermission(ctx context.Context, chatID string, te if requestID == "" { return true, nil } - err := a.HandleCardAction(ctx, FeishuCardActionEvent{RequestID: requestID, Decision: "allow_once"}) + err := a.HandleCardAction(ctx, FeishuCardActionEvent{ + ActionType: "permission", + RequestID: requestID, + Decision: "allow_once", + }) if err != nil { _ = a.messenger.SendText(context.Background(), chatID, "审批提交失败,请稍后重试。") return true, err @@ -715,18 +888,219 @@ func (a *Adapter) tryHandleTextPermission(ctx context.Context, chatID string, te if requestID == "" { return true, nil } - err := a.HandleCardAction(ctx, FeishuCardActionEvent{RequestID: requestID, Decision: "reject"}) + err := a.HandleCardAction(ctx, FeishuCardActionEvent{ + ActionType: "permission", + RequestID: requestID, + Decision: "reject", + }) if err != nil { _ = a.messenger.SendText(context.Background(), chatID, "审批提交失败,请稍后重试。") return true, err } _ = a.messenger.SendText(context.Background(), chatID, "审批已提交:拒绝。") return true, nil + case strings.HasPrefix(normalized, "跳过 "): + requestID := strings.TrimSpace(trimmed[len("跳过 "):]) + if requestID == "" { + return true, nil + } + err := a.HandleCardAction(ctx, FeishuCardActionEvent{ + ActionType: "user_question", + RequestID: requestID, + Status: "skipped", + }) + if err != nil { + _ = a.messenger.SendText(context.Background(), chatID, "回答提交失败,请稍后重试。") + return true, err + } + _ = a.messenger.SendText(context.Background(), chatID, "已提交:跳过当前问题。") + return true, nil + case strings.HasPrefix(normalized, "回答 "): + remainder := strings.TrimSpace(trimmed[len("回答 "):]) + requestID, answer := splitRequestAndBody(remainder) + if requestID == "" { + return true, nil + } + values, message, ok := a.parseUserQuestionTextAnswer(requestID, answer) + if !ok { + _ = a.messenger.SendText(context.Background(), chatID, "回答格式无效,请使用:回答 <内容>") + return true, nil + } + err := a.HandleCardAction(ctx, FeishuCardActionEvent{ + ActionType: "user_question", + RequestID: requestID, + Status: "answered", + Values: values, + Message: message, + }) + if err != nil { + _ = a.messenger.SendText(context.Background(), chatID, "回答提交失败,请稍后重试。") + return true, err + } + _ = a.messenger.SendText(context.Background(), chatID, "回答已提交。") + return true, nil default: return false, nil } } +// tryHandleTextPermission 为兼容旧测试与调用入口保留,内部复用统一文本动作处理。 +func (a *Adapter) tryHandleTextPermission(ctx context.Context, chatID string, text string) (bool, error) { + return a.tryHandleTextAction(ctx, chatID, text) +} + +// parseUserQuestionTextAnswer 根据 pending 问题元数据解析文本回答指令。 +func (a *Adapter) parseUserQuestionTextAnswer(requestID string, answer string) ([]string, string, bool) { + trimmedRequestID := strings.TrimSpace(requestID) + trimmedAnswer := strings.TrimSpace(answer) + a.mu.RLock() + question, ok := a.pendingQuestions[trimmedRequestID] + a.mu.RUnlock() + if !ok { + if trimmedAnswer == "" { + return nil, "", false + } + return []string{trimmedAnswer}, trimmedAnswer, true + } + + switch strings.TrimSpace(strings.ToLower(question.Kind)) { + case "text": + if trimmedAnswer == "" { + return nil, "", false + } + return []string{trimmedAnswer}, trimmedAnswer, true + case "single_choice": + if trimmedAnswer == "" { + return nil, "", false + } + if len(question.Options) == 0 { + return []string{trimmedAnswer}, "", true + } + matched, ok := resolveChoiceLabel(trimmedAnswer, question.Options) + if !ok { + return nil, "", false + } + return []string{matched}, "", true + case "multi_choice": + if trimmedAnswer == "" { + return nil, "", false + } + rawTokens := splitMultiChoiceTokens(trimmedAnswer) + if len(rawTokens) == 0 { + return nil, "", false + } + selected := make([]string, 0, len(rawTokens)) + for _, token := range rawTokens { + if len(question.Options) == 0 { + selected = append(selected, token) + continue + } + matched, ok := resolveChoiceLabel(token, question.Options) + if !ok { + return nil, "", false + } + selected = append(selected, matched) + } + selected = uniqueNonEmptyStrings(selected) + if question.MaxChoices > 0 && len(selected) > question.MaxChoices { + return nil, "", false + } + return selected, "", true + default: + if trimmedAnswer == "" { + return nil, "", false + } + return []string{trimmedAnswer}, trimmedAnswer, true + } +} + +// resolveChoiceLabel 解析单个选项输入,支持按标签文本或 1-based 序号匹配。 +func resolveChoiceLabel(raw string, options []UserQuestionCardOption) (string, bool) { + token := strings.TrimSpace(raw) + if token == "" { + return "", false + } + if index, err := strconv.Atoi(token); err == nil { + if index >= 1 && index <= len(options) { + label := strings.TrimSpace(options[index-1].Label) + if label != "" { + return label, true + } + } + } + normalizedToken := normalizeChoiceToken(token) + for _, option := range options { + label := strings.TrimSpace(option.Label) + if normalizeChoiceToken(label) == normalizedToken { + return label, true + } + } + return "", false +} + +// splitRequestAndBody 将“ ”文本分离为 request_id 与正文。 +func splitRequestAndBody(input string) (string, string) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "", "" + } + parts := strings.Fields(trimmed) + if len(parts) == 0 { + return "", "" + } + requestID := strings.TrimSpace(parts[0]) + body := "" + if len(parts) > 1 { + body = strings.TrimSpace(strings.TrimPrefix(trimmed, parts[0])) + } + return requestID, body +} + +// splitMultiChoiceTokens 支持“逗号/中文逗号/竖线/空格”分隔的多选文本输入。 +func splitMultiChoiceTokens(raw string) []string { + replacer := strings.NewReplacer(",", ",", "|", ",", "、", ",", ";", ",", ";", ",") + normalized := replacer.Replace(raw) + segments := strings.Split(normalized, ",") + if len(segments) == 1 { + return uniqueNonEmptyStrings(strings.Fields(normalized)) + } + tokens := make([]string, 0, len(segments)) + for _, segment := range segments { + trimmed := strings.TrimSpace(segment) + if trimmed != "" { + tokens = append(tokens, trimmed) + } + } + if len(tokens) == 0 { + return uniqueNonEmptyStrings(strings.Fields(normalized)) + } + return uniqueNonEmptyStrings(tokens) +} + +// normalizeChoiceToken 对选项文本做归一化比较,避免大小写与多空格影响匹配。 +func normalizeChoiceToken(value string) string { + return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(value)), " ")) +} + +// uniqueNonEmptyStrings 去重并保序保留非空字符串。 +func uniqueNonEmptyStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + key := normalizeChoiceToken(trimmed) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + out = append(out, trimmed) + } + return out +} + // runBindingKey 生成稳定的 session/run 复合键,避免同会话多 run 相互覆盖。 func runBindingKey(sessionID string, runID string) string { return strings.TrimSpace(sessionID) + "|" + strings.TrimSpace(runID) @@ -762,6 +1136,203 @@ func extractPermissionRequest(envelope map[string]any) (requestID, toolName, ope return } +// extractUserQuestionRequest 从 user_question_requested 事件中抽取关键信息。 +func extractUserQuestionRequest(envelope map[string]any) userQuestionEntry { + payload, _ := envelope["payload"].(map[string]any) + if payload == nil { + return userQuestionEntry{} + } + entry := userQuestionEntry{ + RequestID: strings.TrimSpace(readString(payload, "request_id")), + QuestionID: strings.TrimSpace(readString(payload, "question_id")), + Title: strings.TrimSpace(readString(payload, "title")), + Description: strings.TrimSpace(readString(payload, "description")), + Kind: strings.TrimSpace(strings.ToLower(readString(payload, "kind"))), + AllowSkip: readBool(payload, "allow_skip"), + MaxChoices: readInt(payload, "max_choices"), + } + rawOptions, _ := payload["options"].([]any) + if len(rawOptions) > 0 { + options := make([]UserQuestionCardOption, 0, len(rawOptions)) + for _, raw := range rawOptions { + switch typed := raw.(type) { + case string: + label := strings.TrimSpace(typed) + if label != "" { + options = append(options, UserQuestionCardOption{Label: label}) + } + case map[string]any: + label := strings.TrimSpace(readString(typed, "label")) + if label == "" { + continue + } + options = append(options, UserQuestionCardOption{ + Label: label, + Description: strings.TrimSpace(readString(typed, "description")), + }) + } + } + entry.Options = options + } + return entry +} + +type userQuestionResolved struct { + RequestID string + Status string + Values []string + Message string +} + +// extractUserQuestionResolved 从 user_question_* resolved 事件中抽取回传结果。 +func extractUserQuestionResolved(envelope map[string]any) userQuestionResolved { + payload, _ := envelope["payload"].(map[string]any) + if payload == nil { + return userQuestionResolved{} + } + resolved := userQuestionResolved{ + RequestID: strings.TrimSpace(readString(payload, "request_id")), + Status: strings.TrimSpace(strings.ToLower(readString(payload, "status"))), + Message: strings.TrimSpace(readString(payload, "message")), + } + rawValues, _ := payload["values"].([]any) + values := make([]string, 0, len(rawValues)) + for _, raw := range rawValues { + value, _ := raw.(string) + value = strings.TrimSpace(value) + if value != "" { + values = append(values, value) + } + } + resolved.Values = values + return resolved +} + +// shouldSendAskUserCard 根据问题形态判断是否展示飞书交互卡片。 +func shouldSendAskUserCard(question userQuestionEntry) bool { + kind := strings.TrimSpace(strings.ToLower(question.Kind)) + if kind == "single_choice" && len(question.Options) > 0 { + return true + } + return question.AllowSkip +} + +// isUserQuestionResolvedEvent 判断 runtime event 是否为 ask_user 终态事件。 +func isUserQuestionResolvedEvent(runtimeType string) bool { + switch strings.TrimSpace(strings.ToLower(runtimeType)) { + case "user_question_answered", "user_question_skipped", "user_question_timeout": + return true + default: + return false + } +} + +// userQuestionStatusFromRuntimeType 将 runtime 终态事件类型映射为 status 字段。 +func userQuestionStatusFromRuntimeType(runtimeType string) string { + switch strings.TrimSpace(strings.ToLower(runtimeType)) { + case "user_question_skipped": + return "skipped" + case "user_question_timeout": + return "timeout" + default: + return "answered" + } +} + +// buildAskUserTextPrompt 构造文本降级指令,覆盖 text/multi_choice 及无按钮场景。 +func buildAskUserTextPrompt(question userQuestionEntry) string { + title := strings.TrimSpace(question.Title) + if title == "" { + title = "请回答以下问题" + } + lines := []string{title} + if description := strings.TrimSpace(question.Description); description != "" { + lines = append(lines, description) + } + if len(question.Options) > 0 { + optionLabels := make([]string, 0, len(question.Options)) + for _, option := range question.Options { + label := strings.TrimSpace(option.Label) + if label != "" { + optionLabels = append(optionLabels, label) + } + } + if len(optionLabels) > 0 { + lines = append(lines, "可选项:"+strings.Join(optionLabels, " / ")) + } + } + lines = append(lines, fmt.Sprintf("请回复:回答 %s <内容>", strings.TrimSpace(question.RequestID))) + if question.AllowSkip { + lines = append(lines, fmt.Sprintf("如需跳过:跳过 %s", strings.TrimSpace(question.RequestID))) + } + return strings.Join(lines, "\n") +} + +// buildUserQuestionResolvedSummary 生成 ask_user 终态摘要文案,写入状态卡与已处理卡片。 +func buildUserQuestionResolvedSummary(question userQuestionEntry, status string, values []string, message string) string { + switch strings.TrimSpace(strings.ToLower(status)) { + case "skipped": + return "用户已跳过该问题" + case "timeout": + return "问题等待超时" + default: + if trimmed := strings.TrimSpace(message); trimmed != "" { + return "用户回答:" + trimmed + } + if len(values) > 0 { + return "用户回答:" + strings.Join(values, ", ") + } + if strings.TrimSpace(question.Title) != "" { + return "用户已回答:" + strings.TrimSpace(question.Title) + } + return "用户已回答问题" + } +} + +// buildCardActionDedupeKey 生成卡片动作幂等键,优先使用 event_id 避免重复回调重放。 +func buildCardActionDedupeKey(event FeishuCardActionEvent, actionType string) string { + if eventID := strings.TrimSpace(event.EventID); eventID != "" { + return "card:event:" + eventID + } + requestID := strings.TrimSpace(event.RequestID) + if actionType == "permission" { + return "card:permission:" + requestID + ":" + strings.TrimSpace(strings.ToLower(event.Decision)) + } + return "card:user_question:" + requestID + ":" + strings.TrimSpace(strings.ToLower(event.Status)) +} + +// readBool 从松散 map 中读取 bool 字段并提供 false 默认值。 +func readBool(m map[string]any, key string) bool { + if m == nil { + return false + } + value, _ := m[key].(bool) + return value +} + +// readInt 从松散 map 中读取 int 字段,不可解析时返回 0。 +func readInt(m map[string]any, key string) int { + if m == nil { + return 0 + } + switch typed := m[key].(type) { + case int: + return typed + case int32: + return int(typed) + case int64: + return int(typed) + case float64: + return int(typed) + case json.Number: + value, err := typed.Int64() + if err == nil { + return int(value) + } + } + return 0 +} + // extractHookNotificationSummary 提取 async_rewake 等通知摘要并写入卡片,便于下轮继续追踪。 func extractHookNotificationSummary(envelope map[string]any) string { payload, _ := envelope["payload"].(map[string]any) @@ -914,6 +1485,7 @@ func deriveRunStatus(runtimeType string, envelope map[string]any, current string case "tool_call_thinking", "agent_chunk": return "thinking" case "permission_requested", "permission_resolved", "tool_start", "tool_result", "tool_chunk", "tool_diff", + "user_question_requested", "user_question_answered", "user_question_skipped", "user_question_timeout", "verification_started", "verification_finished", "verification_completed", "verification_failed", "acceptance_decided", "hook_notification": return "running" diff --git a/internal/feishuadapter/adapter_test.go b/internal/feishuadapter/adapter_test.go index 17d7857c..8b4f3d25 100644 --- a/internal/feishuadapter/adapter_test.go +++ b/internal/feishuadapter/adapter_test.go @@ -69,6 +69,19 @@ func (f *fakeGatewayClient) ResolvePermission(_ context.Context, requestID strin f.resolveCount++ return f.resolveErr } +func (f *fakeGatewayClient) ResolveUserQuestion( + _ context.Context, + requestID string, + status string, + values []string, + message string, +) error { + f.record("resolve_user_question:" + requestID + ":" + status + ":" + strings.Join(values, ",") + ":" + strings.TrimSpace(message)) + f.mu.Lock() + defer f.mu.Unlock() + f.resolveCount++ + return f.resolveErr +} func (f *fakeGatewayClient) Ping(context.Context) error { f.record("ping") f.mu.Lock() @@ -94,13 +107,15 @@ func (f *fakeGatewayClient) snapshotCalls() []string { } type sentMessage struct { - chatID string - kind string - text string - card PermissionCardPayload - runCard StatusCardPayload - cardID string - resolvedCard *ResolvedPermissionCardPayload + chatID string + kind string + text string + card PermissionCardPayload + userQuestionCard UserQuestionCardPayload + runCard StatusCardPayload + cardID string + resolvedCard *ResolvedPermissionCardPayload + resolvedUserQuestion *ResolvedUserQuestionCardPayload } type fakeMessenger struct { @@ -135,6 +150,22 @@ func (m *fakeMessenger) UpdatePermissionCard(_ context.Context, cardID string, p return nil } +func (m *fakeMessenger) SendUserQuestionCard(_ context.Context, chatID string, payload UserQuestionCardPayload) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.nextID++ + cardID := fmt.Sprintf("ask-card-%d", m.nextID) + m.messages = append(m.messages, sentMessage{chatID: chatID, kind: "ask_card", userQuestionCard: payload, cardID: cardID}) + return cardID, nil +} + +func (m *fakeMessenger) UpdateUserQuestionCard(_ context.Context, cardID string, payload ResolvedUserQuestionCardPayload) error { + m.mu.Lock() + defer m.mu.Unlock() + m.messages = append(m.messages, sentMessage{chatID: cardID, kind: "update_ask_card", resolvedUserQuestion: &payload}) + return nil +} + func (m *fakeMessenger) SendStatusCard(_ context.Context, chatID string, payload StatusCardPayload) (string, error) { m.mu.Lock() defer m.mu.Unlock() @@ -487,6 +518,80 @@ func TestGatewayEventsMappedToMessagesAndPermissionCard(t *testing.T) { } } +func TestGatewayUserQuestionRequestedSingleChoiceSendsCard(t *testing.T) { + adapter := newTestAdapter(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go adapter.consumeGatewayEvents(ctx) + + sessionID := BuildSessionID("chat-ask-card") + runID := BuildRunID("msg-ask-card") + adapter.trackSession(sessionID, runID, "chat-ask-card", "ask task") + + pushGatewayEvent(t, adapterTestGateway(adapter), sessionID, runID, "run_progress", map[string]any{ + "runtime_event_type": "user_question_requested", + "payload": map[string]any{ + "request_id": "ask-card-1", + "question_id": "q1", + "title": "选择部署环境", + "kind": "single_choice", + "allow_skip": true, + "options": []any{ + map[string]any{"label": "测试环境"}, + map[string]any{"label": "生产环境"}, + }, + }, + }) + time.Sleep(30 * time.Millisecond) + + msgs := adapterTestMessenger(adapter).snapshot() + found := false + for _, message := range msgs { + if message.kind == "ask_card" && message.userQuestionCard.RequestID == "ask-card-1" { + found = true + break + } + } + if !found { + t.Fatalf("expected ask_user card message, got %#v", msgs) + } +} + +func TestGatewayUserQuestionRequestedTextFallsBackToInstructionText(t *testing.T) { + adapter := newTestAdapter(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go adapter.consumeGatewayEvents(ctx) + + sessionID := BuildSessionID("chat-ask-text") + runID := BuildRunID("msg-ask-text") + adapter.trackSession(sessionID, runID, "chat-ask-text", "ask text task") + + pushGatewayEvent(t, adapterTestGateway(adapter), sessionID, runID, "run_progress", map[string]any{ + "runtime_event_type": "user_question_requested", + "payload": map[string]any{ + "request_id": "ask-text-1", + "question_id": "q-text-1", + "title": "请输入备注", + "kind": "text", + "allow_skip": false, + }, + }) + time.Sleep(30 * time.Millisecond) + + msgs := adapterTestMessenger(adapter).snapshot() + foundInstruction := false + for _, message := range msgs { + if message.kind == "text" && strings.Contains(message.text, "回答 ask-text-1") { + foundInstruction = true + break + } + } + if !foundInstruction { + t.Fatalf("expected ask_user instruction text, got %#v", msgs) + } +} + func TestBindThenRunCreatesStatusCard(t *testing.T) { adapter := newTestAdapter(t) if err := adapter.bindThenRun(context.Background(), "session-card", "run-card", "chat-card", "编写发布说明"); err != nil { @@ -572,6 +677,50 @@ func TestGatewayEventsUpdateStatusCard(t *testing.T) { } } +func TestHandleCardActionUserQuestionResolvesAndUpdatesCard(t *testing.T) { + adapter := newTestAdapter(t) + sessionID := BuildSessionID("chat-ask-resolve") + runID := BuildRunID("msg-ask-resolve") + adapter.trackSession(sessionID, runID, "chat-ask-resolve", "ask resolve task") + adapter.markUserQuestionPending(sessionID, runID, userQuestionEntry{ + RequestID: "ask-resolve-1", + QuestionID: "q-resolve-1", + Title: "选择分支", + Kind: "single_choice", + AllowSkip: true, + Options: []UserQuestionCardOption{{Label: "main"}}, + Description: "请选择目标分支", + }) + adapter.mu.Lock() + adapter.userQuestionCards["ask-resolve-1"] = "ask-card-1" + adapter.mu.Unlock() + + if err := adapter.HandleCardAction(context.Background(), FeishuCardActionEvent{ + ActionType: "user_question", + RequestID: "ask-resolve-1", + Status: "answered", + Values: []string{"main"}, + }); err != nil { + t.Fatalf("handle card action: %v", err) + } + + if adapterTestGateway(adapter).resolveCount != 1 { + t.Fatalf("resolve count = %d, want 1", adapterTestGateway(adapter).resolveCount) + } + msgs := adapterTestMessenger(adapter).snapshot() + foundUpdate := false + for _, message := range msgs { + if message.kind == "update_ask_card" && message.resolvedUserQuestion != nil && + message.resolvedUserQuestion.RequestID == "ask-resolve-1" { + foundUpdate = true + break + } + } + if !foundUpdate { + t.Fatalf("expected ask card update, got %#v", msgs) + } +} + func TestRunTerminalEventUntracksActiveRun(t *testing.T) { adapter := newTestAdapter(t) ctx, cancel := context.WithCancel(context.Background()) @@ -690,6 +839,34 @@ func TestCardCallbackResolveFailureReturns500(t *testing.T) { } } +func TestCardCallbackUserQuestionAnswerAccepted(t *testing.T) { + adapter := newTestAdapter(t) + sessionID := BuildSessionID("chat-callback-ask") + runID := BuildRunID("msg-callback-ask") + adapter.trackSession(sessionID, runID, "chat-callback-ask", "ask callback task") + adapter.markUserQuestionPending(sessionID, runID, userQuestionEntry{ + RequestID: "ask-callback-1", + QuestionID: "q-callback-1", + Title: "选择模式", + Kind: "single_choice", + Options: []UserQuestionCardOption{{Label: "快速"}}, + }) + + body := `{"action":{"value":{"action_type":"user_question","request_id":"ask-callback-1","status":"answered","value":"快速"}},"token":"verify"}` + request := signedRequest(t, adapter.cfg.SigningSecret, body) + recorder := httptest.NewRecorder() + adapter.handleCardCallback(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", recorder.Code) + } + if adapterTestGateway(adapter).resolveCount != 1 { + t.Fatalf("resolve count = %d, want 1", adapterTestGateway(adapter).resolveCount) + } + if !strings.Contains(recorder.Body.String(), "回答已提交") { + t.Fatalf("response = %s, want ask toast", recorder.Body.String()) + } +} + func TestCardCallbackUrlVerificationAccepted(t *testing.T) { adapter := newTestAdapter(t) body := `{"type":"url_verification","challenge":"card-challenge","token":"verify","header":{"token":"verify"}}` @@ -740,6 +917,23 @@ func TestCardCallbackProbeWithoutActionReturnsOK(t *testing.T) { } } +func TestCardCallbackInvalidActionPayloadReturnsInfoWithoutResolve(t *testing.T) { + adapter := newTestAdapter(t) + body := `{"action":{"value":{"action_type":"permission","request_id":"perm-x","decision":"allow_all"}},"token":"verify","header":{"token":"verify"}}` + request := signedRequest(t, adapter.cfg.SigningSecret, body) + recorder := httptest.NewRecorder() + adapter.handleCardCallback(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", recorder.Code) + } + if !strings.Contains(recorder.Body.String(), "callback ready") { + t.Fatalf("response = %s, want callback ready", recorder.Body.String()) + } + if adapterTestGateway(adapter).resolveCount != 0 { + t.Fatalf("resolve count = %d, want 0", adapterTestGateway(adapter).resolveCount) + } +} + func TestReconnectRebindActiveSessions(t *testing.T) { adapter := newTestAdapter(t) gw := adapterTestGateway(adapter) @@ -974,6 +1168,98 @@ func TestTryHandleTextPermissionRejectFailureRepliesRetryable(t *testing.T) { } } +func TestTryHandleTextPermissionHandlesAskUserAnswerAndSkip(t *testing.T) { + adapter := newTestAdapter(t) + sessionID := BuildSessionID("chat-ask-text-cmd") + runID := BuildRunID("msg-ask-text-cmd") + adapter.trackSession(sessionID, runID, "chat-ask-text-cmd", "ask cmd task") + adapter.markUserQuestionPending(sessionID, runID, userQuestionEntry{ + RequestID: "ask-cmd-1", + QuestionID: "q-cmd-1", + Title: "选择发布环境", + Kind: "single_choice", + Options: []UserQuestionCardOption{ + {Label: "测试环境"}, + {Label: "生产环境"}, + }, + AllowSkip: true, + }) + + handled, err := adapter.tryHandleTextPermission(context.Background(), "chat-ask-text-cmd", "回答 ask-cmd-1 测试环境") + if err != nil || !handled { + t.Fatalf("answer command = handled:%v err:%v", handled, err) + } + handled, err = adapter.tryHandleTextPermission(context.Background(), "chat-ask-text-cmd", "跳过 ask-cmd-1") + if err != nil || !handled { + t.Fatalf("skip command = handled:%v err:%v", handled, err) + } + if adapterTestGateway(adapter).resolveCount < 2 { + t.Fatalf("resolve count = %d, want >=2", adapterTestGateway(adapter).resolveCount) + } +} + +func TestTryHandleTextPermissionRejectsInvalidChoiceAnswer(t *testing.T) { + adapter := newTestAdapter(t) + sessionID := BuildSessionID("chat-ask-invalid-choice") + runID := BuildRunID("msg-ask-invalid-choice") + adapter.trackSession(sessionID, runID, "chat-ask-invalid-choice", "ask invalid choice task") + adapter.markUserQuestionPending(sessionID, runID, userQuestionEntry{ + RequestID: "ask-invalid-choice-1", + QuestionID: "q-invalid-choice-1", + Title: "选择发布环境", + Kind: "single_choice", + Options: []UserQuestionCardOption{ + {Label: "测试环境"}, + {Label: "生产环境"}, + }, + AllowSkip: true, + }) + + handled, err := adapter.tryHandleTextPermission(context.Background(), "chat-ask-invalid-choice", "回答 ask-invalid-choice-1 staging") + if err != nil || !handled { + t.Fatalf("invalid single_choice answer = handled:%v err:%v", handled, err) + } + if adapterTestGateway(adapter).resolveCount != 0 { + t.Fatalf("resolve count = %d, want 0 for invalid answer", adapterTestGateway(adapter).resolveCount) + } + msgs := adapterTestMessenger(adapter).snapshot() + if len(msgs) == 0 || msgs[len(msgs)-1].text != "回答格式无效,请使用:回答 <内容>" { + t.Fatalf("unexpected invalid answer reply: %#v", msgs) + } +} + +func TestTryHandleTextPermissionRejectsMultiChoiceExceedingMaxChoices(t *testing.T) { + adapter := newTestAdapter(t) + sessionID := BuildSessionID("chat-ask-max-choice") + runID := BuildRunID("msg-ask-max-choice") + adapter.trackSession(sessionID, runID, "chat-ask-max-choice", "ask max choice task") + adapter.markUserQuestionPending(sessionID, runID, userQuestionEntry{ + RequestID: "ask-max-choice-1", + QuestionID: "q-max-choice-1", + Title: "选择要发布的区域", + Kind: "multi_choice", + Options: []UserQuestionCardOption{ + {Label: "华北"}, + {Label: "华东"}, + {Label: "华南"}, + }, + MaxChoices: 2, + AllowSkip: true, + }) + + handled, err := adapter.tryHandleTextPermission(context.Background(), "chat-ask-max-choice", "回答 ask-max-choice-1 华北,华东,华南") + if err != nil || !handled { + t.Fatalf("max_choices exceed answer = handled:%v err:%v", handled, err) + } + if adapterTestGateway(adapter).resolveCount != 0 { + t.Fatalf("resolve count = %d, want 0 when max_choices exceeded", adapterTestGateway(adapter).resolveCount) + } + msgs := adapterTestMessenger(adapter).snapshot() + if len(msgs) == 0 || msgs[len(msgs)-1].text != "回答格式无效,请使用:回答 <内容>" { + t.Fatalf("unexpected max_choices reply: %#v", msgs) + } +} + func TestTryHandleTextPermissionHandlesEmptyAndUnknownCommands(t *testing.T) { adapter := newTestAdapter(t) for _, testCase := range []struct { @@ -1267,6 +1553,115 @@ func TestHelperFunctionsCoverFallbackBranches(t *testing.T) { safeLogAdapter.safeLog("ignored") } +func TestAskUserHelperFunctionsCoverFallbackBranches(t *testing.T) { + resolved := extractUserQuestionResolved(map[string]any{ + "payload": map[string]any{ + "request_id": " ask-1 ", + "status": " Answered ", + "message": " 已确认 ", + "values": []any{" 选项A ", "", 123, "选项B"}, + }, + }) + if resolved.RequestID != "ask-1" || resolved.Status != "answered" || resolved.Message != "已确认" { + t.Fatalf("unexpected resolved payload: %#v", resolved) + } + if len(resolved.Values) != 2 || resolved.Values[0] != "选项A" || resolved.Values[1] != "选项B" { + t.Fatalf("resolved values = %#v, want trimmed string values", resolved.Values) + } + if fallback := extractUserQuestionResolved(nil); fallback.RequestID != "" || fallback.Status != "" || fallback.Message != "" || + len(fallback.Values) != 0 { + t.Fatalf("nil payload fallback = %#v", fallback) + } + + if !shouldSendAskUserCard(userQuestionEntry{Kind: "single_choice", Options: []UserQuestionCardOption{{Label: "A"}}}) { + t.Fatal("expected single choice question to send card") + } + if shouldSendAskUserCard(userQuestionEntry{Kind: "text"}) { + t.Fatal("expected text question without skip to fall back to plain text") + } + if !shouldSendAskUserCard(userQuestionEntry{Kind: "text", AllowSkip: true}) { + t.Fatal("expected skip-enabled text question to send card") + } + + if !isUserQuestionResolvedEvent(" user_question_timeout ") { + t.Fatal("expected timeout runtime type to be resolved event") + } + if isUserQuestionResolvedEvent("user_question_requested") { + t.Fatal("did not expect requested runtime type to be resolved event") + } + if status := userQuestionStatusFromRuntimeType(" user_question_skipped "); status != "skipped" { + t.Fatalf("status = %q, want skipped", status) + } + if status := userQuestionStatusFromRuntimeType("user_question_timeout"); status != "timeout" { + t.Fatalf("status = %q, want timeout", status) + } + if status := userQuestionStatusFromRuntimeType("user_question_answered"); status != "answered" { + t.Fatalf("status = %q, want answered", status) + } + + prompt := buildAskUserTextPrompt(userQuestionEntry{ + RequestID: "ask-2", + Title: "选择部署环境", + Description: "请确认本次发布目标", + Options: []UserQuestionCardOption{ + {Label: "测试"}, + {Label: "生产"}, + }, + AllowSkip: true, + }) + if !strings.Contains(prompt, "选择部署环境") || !strings.Contains(prompt, "可选项:测试 / 生产") { + t.Fatalf("prompt = %q, want title and option labels", prompt) + } + if !strings.Contains(prompt, "请回复:回答 ask-2 <内容>") || !strings.Contains(prompt, "如需跳过:跳过 ask-2") { + t.Fatalf("prompt = %q, want answer and skip instructions", prompt) + } + if fallbackPrompt := buildAskUserTextPrompt(userQuestionEntry{}); !strings.Contains(fallbackPrompt, "请回答以下问题") { + t.Fatalf("fallback prompt = %q, want default title", fallbackPrompt) + } + + if summary := buildUserQuestionResolvedSummary(userQuestionEntry{}, "skipped", nil, ""); summary != "用户已跳过该问题" { + t.Fatalf("skip summary = %q", summary) + } + if summary := buildUserQuestionResolvedSummary(userQuestionEntry{}, "timeout", nil, ""); summary != "问题等待超时" { + t.Fatalf("timeout summary = %q", summary) + } + if summary := buildUserQuestionResolvedSummary(userQuestionEntry{}, "answered", nil, " 已提交 "); summary != "用户回答:已提交" { + t.Fatalf("message summary = %q", summary) + } + if summary := buildUserQuestionResolvedSummary(userQuestionEntry{}, "answered", []string{"A", "B"}, ""); summary != "用户回答:A, B" { + t.Fatalf("values summary = %q", summary) + } + if summary := buildUserQuestionResolvedSummary(userQuestionEntry{Title: "选择模式"}, "answered", nil, ""); summary != "用户已回答:选择模式" { + t.Fatalf("title summary = %q", summary) + } + if summary := buildUserQuestionResolvedSummary(userQuestionEntry{}, "answered", nil, ""); summary != "用户已回答问题" { + t.Fatalf("fallback summary = %q", summary) + } + + if value := readInt(nil, "count"); value != 0 { + t.Fatalf("readInt nil map = %d, want 0", value) + } + intCases := []struct { + name string + raw any + want int + }{ + {name: "int", raw: int(3), want: 3}, + {name: "int32", raw: int32(4), want: 4}, + {name: "int64", raw: int64(5), want: 5}, + {name: "float64", raw: float64(6), want: 6}, + {name: "json number", raw: json.Number("7"), want: 7}, + {name: "invalid", raw: json.Number("bad"), want: 0}, + } + for _, testCase := range intCases { + t.Run(testCase.name, func(t *testing.T) { + if value := readInt(map[string]any{"count": testCase.raw}, "count"); value != testCase.want { + t.Fatalf("readInt(%s) = %d, want %d", testCase.name, value, testCase.want) + } + }) + } +} + func TestIsMentionCurrentBotMatchesConfiguredBotIDs(t *testing.T) { cfg := Config{AppID: "cli_app", BotUserID: "ou_bot", BotOpenID: "ou_open_bot"} event := FeishuMessageEvent{ diff --git a/internal/feishuadapter/gateway_client.go b/internal/feishuadapter/gateway_client.go index 01b5145f..2e1e0e67 100644 --- a/internal/feishuadapter/gateway_client.go +++ b/internal/feishuadapter/gateway_client.go @@ -69,6 +69,23 @@ func (c *gatewayRPCClient) ResolvePermission(ctx context.Context, requestID stri }, &result) } +// ResolveUserQuestion 提交一次 ask_user 回答。 +func (c *gatewayRPCClient) ResolveUserQuestion( + ctx context.Context, + requestID string, + status string, + values []string, + message string, +) error { + var result map[string]any + return c.client.Call(ctx, protocol.MethodGatewayUserQuestionAnswer, protocol.UserQuestionAnswerParams{ + RequestID: requestID, + Status: status, + Values: values, + Message: message, + }, &result) +} + // Ping 调用网关保活接口,触发自动重连与链路健康检查。 func (c *gatewayRPCClient) Ping(ctx context.Context) error { var result map[string]any diff --git a/internal/feishuadapter/gateway_client_test.go b/internal/feishuadapter/gateway_client_test.go index 1a80dd9b..0f07d954 100644 --- a/internal/feishuadapter/gateway_client_test.go +++ b/internal/feishuadapter/gateway_client_test.go @@ -127,6 +127,9 @@ func TestGatewayRPCClientDelegatesRPCMethods(t *testing.T) { if err := client.ResolvePermission(ctx, "perm-1", "allow_once"); err != nil { t.Fatalf("resolve permission: %v", err) } + if err := client.ResolveUserQuestion(ctx, "ask-1", "answered", []string{"A"}, "A"); err != nil { + t.Fatalf("resolve user question: %v", err) + } if err := client.Ping(ctx); err != nil { t.Fatalf("ping: %v", err) } @@ -152,6 +155,7 @@ func TestGatewayRPCClientDelegatesRPCMethods(t *testing.T) { protocol.MethodGatewayBindStream, protocol.MethodGatewayRun, protocol.MethodGatewayResolvePermission, + protocol.MethodGatewayUserQuestionAnswer, protocol.MethodGatewayPing, } if len(methods) < len(wantMethods) { @@ -228,3 +232,33 @@ func TestParseGatewayRuntimeEventHandlesEdgeCases(t *testing.T) { t.Fatalf("readString = %q, want empty for non-string", value) } } + +func TestParseGatewayRuntimeEventExtractsEnvelopeAndCloneRawMessageCopiesBytes(t *testing.T) { + raw := json.RawMessage(`{"session_id":"s-1","run_id":"r-1","payload":{"event_type":"user_question_requested","payload":{"request_id":"ask-1"}}}`) + eventType, sessionID, runID, envelope, err := parseGatewayRuntimeEvent(raw) + if err != nil { + t.Fatalf("parse event: %v", err) + } + if eventType != "user_question_requested" || sessionID != "s-1" || runID != "r-1" { + t.Fatalf("unexpected event header: type=%q session=%q run=%q", eventType, sessionID, runID) + } + if got := readString(envelope, "request_id"); got != "ask-1" { + t.Fatalf("envelope request_id = %q, want ask-1", got) + } + + cloned := cloneRawMessage(raw) + if string(cloned) != string(raw) { + t.Fatalf("cloneRawMessage() = %s, want %s", cloned, raw) + } + cloned[2] = 'X' + if string(raw) != `{"session_id":"s-1","run_id":"r-1","payload":{"event_type":"user_question_requested","payload":{"request_id":"ask-1"}}}` { + t.Fatalf("expected clone mutation not to affect source, got %s", raw) + } + + if got := readString(map[string]any{"value": "answer"}, "value"); got != "answer" { + t.Fatalf("readString(string) = %q, want answer", got) + } + if got := readString(nil, "value"); got != "" { + t.Fatalf("readString(nil) = %q, want empty", got) + } +} diff --git a/internal/feishuadapter/ingress.go b/internal/feishuadapter/ingress.go index 52032092..4e498bbe 100644 --- a/internal/feishuadapter/ingress.go +++ b/internal/feishuadapter/ingress.go @@ -37,7 +37,11 @@ type FeishuMention struct { // FeishuCardActionEvent 表示标准化后的审批动作事件。 type FeishuCardActionEvent struct { - EventID string - RequestID string - Decision string + EventID string + ActionType string + RequestID string + Decision string + Status string + Values []string + Message string } diff --git a/internal/feishuadapter/messenger.go b/internal/feishuadapter/messenger.go index 6820a360..dae9c940 100644 --- a/internal/feishuadapter/messenger.go +++ b/internal/feishuadapter/messenger.go @@ -101,6 +101,44 @@ func (m *feishuMessenger) UpdatePermissionCard(ctx context.Context, cardID strin return m.doJSONRequest(req) } +// SendUserQuestionCard 向指定 chat_id 发送 ask_user 交互卡片,返回 message_id 供后续更新。 +func (m *feishuMessenger) SendUserQuestionCard(ctx context.Context, chatID string, payload UserQuestionCardPayload) (string, error) { + card := buildUserQuestionCard(payload) + content, err := json.Marshal(card) + if err != nil { + return "", err + } + return m.sendMessage(ctx, chatID, "interactive", string(content)) +} + +// UpdateUserQuestionCard 根据 card_id 覆盖更新 ask_user 卡片为已处理状态。 +func (m *feishuMessenger) UpdateUserQuestionCard(ctx context.Context, cardID string, payload ResolvedUserQuestionCardPayload) error { + token, err := m.tenantAccessToken(ctx) + if err != nil { + return err + } + card := buildResolvedUserQuestionCard(payload) + content, err := json.Marshal(card) + if err != nil { + return err + } + body := map[string]string{ + "content": string(content), + } + data, err := json.Marshal(body) + if err != nil { + return err + } + url := strings.TrimRight(m.baseURL, "/") + "/open-apis/im/v1/messages/" + cardID + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + return m.doJSONRequest(req) +} + // SendStatusCard 发送 run 维度的轻量级状态卡片,并返回可后续更新的 card_id。 func (m *feishuMessenger) SendStatusCard(ctx context.Context, chatID string, payload StatusCardPayload) (string, error) { content, err := json.Marshal(buildStatusCard(payload)) @@ -322,8 +360,8 @@ func statusNoteElement(taskName string) map[string]any { func statusBarElement(icon string, label string, value string) map[string]any { return map[string]any{ - "tag": "column_set", - "flex_mode": "bisect", + "tag": "column_set", + "flex_mode": "bisect", "background_style": "default", "columns": []map[string]any{ { @@ -447,8 +485,9 @@ func buildPermissionCard(payload PermissionCardPayload) map[string]any { "text": map[string]any{"tag": "plain_text", "content": "允许一次"}, "type": "primary", "value": map[string]string{ - "decision": "allow_once", - "request_id": payload.RequestID, + "action_type": "permission", + "decision": "allow_once", + "request_id": payload.RequestID, }, }, { @@ -456,8 +495,9 @@ func buildPermissionCard(payload PermissionCardPayload) map[string]any { "text": map[string]any{"tag": "plain_text", "content": "拒绝"}, "type": "default", "value": map[string]string{ - "decision": "reject", - "request_id": payload.RequestID, + "action_type": "permission", + "decision": "reject", + "request_id": payload.RequestID, }, }, }, @@ -521,6 +561,125 @@ func buildResolvedPermissionCard(payload ResolvedPermissionCardPayload) map[stri } } +// buildUserQuestionCard 构造 ask_user 交互卡片,支持单选按钮与跳过按钮。 +func buildUserQuestionCard(payload UserQuestionCardPayload) map[string]any { + title := strings.TrimSpace(payload.Title) + if title == "" { + title = "请回答问题" + } + description := strings.TrimSpace(payload.Description) + kind := strings.TrimSpace(strings.ToLower(payload.Kind)) + + bodyLines := make([]string, 0, 4) + if description != "" { + bodyLines = append(bodyLines, description) + } + if kind == "multi_choice" { + bodyLines = append(bodyLines, "请通过消息回复:回答 "+strings.TrimSpace(payload.RequestID)+" <选项1,选项2>") + } else if kind == "text" { + bodyLines = append(bodyLines, "请通过消息回复:回答 "+strings.TrimSpace(payload.RequestID)+" <内容>") + } + if len(bodyLines) == 0 { + bodyLines = append(bodyLines, "请完成以下问题回答。") + } + + elements := []map[string]any{ + { + "tag": "div", + "text": map[string]any{"tag": "lark_md", "content": strings.Join(bodyLines, "\n\n")}, + }, + } + + actions := make([]map[string]any, 0, len(payload.Options)+1) + if kind == "single_choice" { + for _, option := range payload.Options { + label := strings.TrimSpace(option.Label) + if label == "" { + continue + } + actions = append(actions, map[string]any{ + "tag": "button", + "text": map[string]any{"tag": "plain_text", "content": label}, + "type": "default", + "value": map[string]string{ + "action_type": "user_question", + "request_id": strings.TrimSpace(payload.RequestID), + "status": "answered", + "value": label, + }, + }) + } + } + if payload.AllowSkip { + actions = append(actions, map[string]any{ + "tag": "button", + "text": map[string]any{"tag": "plain_text", "content": "跳过"}, + "type": "default", + "value": map[string]string{ + "action_type": "user_question", + "request_id": strings.TrimSpace(payload.RequestID), + "status": "skipped", + }, + }) + } + if len(actions) > 0 { + elements = append(elements, map[string]any{ + "tag": "action", + "actions": actions, + }) + } + + return map[string]any{ + "config": map[string]any{"wide_screen_mode": true}, + "header": map[string]any{ + "title": map[string]string{"tag": "plain_text", "content": title}, + "template": "wathet", + }, + "elements": elements, + } +} + +// buildResolvedUserQuestionCard 构造 ask_user 已处理卡片,去掉交互按钮并显示终态。 +func buildResolvedUserQuestionCard(payload ResolvedUserQuestionCardPayload) map[string]any { + title := strings.TrimSpace(payload.Title) + if title == "" { + title = "用户问题" + } + status := strings.TrimSpace(strings.ToLower(payload.Status)) + statusIcon := "✅" + statusText := "已回答" + headerColor := "green" + switch status { + case "skipped": + statusIcon = "⏭️" + statusText = "已跳过" + headerColor = "yellow" + case "timeout": + statusIcon = "⏰" + statusText = "已超时" + headerColor = "red" + } + + body := statusIcon + " **" + statusText + "**" + if summary := strings.TrimSpace(payload.Summary); summary != "" { + body += "\n\n" + summary + } + + return map[string]any{ + "config": map[string]any{"wide_screen_mode": true}, + "header": map[string]any{ + "title": map[string]string{"tag": "plain_text", "content": title}, + "template": headerColor, + }, + "elements": []map[string]any{ + { + "tag": "div", + "text": map[string]any{"tag": "lark_md", "content": body}, + }, + }, + } +} + func fallbackStatusField(value string, fallback string) string { trimmed := strings.TrimSpace(value) if trimmed == "" { diff --git a/internal/feishuadapter/messenger_test.go b/internal/feishuadapter/messenger_test.go index ea27aecc..a3c0c101 100644 --- a/internal/feishuadapter/messenger_test.go +++ b/internal/feishuadapter/messenger_test.go @@ -320,3 +320,151 @@ func TestBuildStatusCardAndHelpers(t *testing.T) { t.Fatalf("fallback field = %q", value) } } + +func TestSendAndUpdateUserQuestionCard(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":"ask-mid"}}`}, + {status: 200, body: `{"code":0,"msg":"ok","data":{"message_id":"updated"}}`}, + }, + } + messenger := NewFeishuMessenger("app", "secret", client) + cardID, err := messenger.SendUserQuestionCard(context.Background(), "chat-id", UserQuestionCardPayload{ + RequestID: "ask-1", + QuestionID: "q1", + Title: "请选择环境", + Kind: "single_choice", + Options: []UserQuestionCardOption{ + {Label: "测试"}, + {Label: "生产"}, + }, + AllowSkip: true, + }) + if err != nil { + t.Fatalf("send user question card: %v", err) + } + if cardID != "ask-mid" { + t.Fatalf("cardID = %q, want ask-mid", cardID) + } + if err := messenger.UpdateUserQuestionCard(context.Background(), "ask-mid", ResolvedUserQuestionCardPayload{ + RequestID: "ask-1", + Title: "请选择环境", + Status: "answered", + Summary: "用户回答:测试", + }); err != nil { + t.Fatalf("update user question card: %v", err) + } + if len(client.requests) != 3 { + t.Fatalf("request count = %d, want 3", len(client.requests)) + } + if client.requests[2].Method != http.MethodPatch { + t.Fatalf("update method = %s, want PATCH", client.requests[2].Method) + } +} + +func TestBuildUserQuestionCardAndResolvedCard(t *testing.T) { + card := buildUserQuestionCard(UserQuestionCardPayload{ + RequestID: "ask-2", + Title: "选择发布模式", + Kind: "single_choice", + Options: []UserQuestionCardOption{ + {Label: "蓝绿"}, + {Label: "滚动"}, + }, + AllowSkip: true, + }) + raw, _ := json.Marshal(card) + content := string(raw) + if !strings.Contains(content, "action_type") || !strings.Contains(content, "user_question") { + t.Fatalf("card content = %s, want user_question action_type", content) + } + if !strings.Contains(content, "ask-2") { + t.Fatalf("card content = %s, want request id", content) + } + + resolved := buildResolvedUserQuestionCard(ResolvedUserQuestionCardPayload{ + Title: "选择发布模式", + Status: "skipped", + Summary: "用户已跳过", + }) + header, _ := resolved["header"].(map[string]any) + if header["template"] != "yellow" { + t.Fatalf("resolved header template = %v, want yellow", header["template"]) + } +} + +func TestUpdatePermissionCardAndResolvedCardHelpers(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.UpdatePermissionCard(context.Background(), "perm-card", ResolvedPermissionCardPayload{ + RequestID: "perm-1", + ToolName: "bash", + Operation: "run", + Target: "deploy.sh", + Message: "需要执行部署命令", + Approved: false, + }) + if err != nil { + t.Fatalf("update 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) + } + content := string(client.bodies[1]) + if !strings.Contains(content, "已拒绝") || !strings.Contains(content, "deploy.sh") { + t.Fatalf("update body = %s, want rejected summary and target", content) + } + + approved := buildResolvedPermissionCard(ResolvedPermissionCardPayload{ + ToolName: "bash", + Operation: "run", + Approved: true, + }) + approvedHeader, _ := approved["header"].(map[string]any) + if approvedHeader["template"] != "green" { + t.Fatalf("approved header template = %v, want green", approvedHeader["template"]) + } + rejected := buildResolvedPermissionCard(ResolvedPermissionCardPayload{ + ToolName: "bash", + Approved: false, + }) + rejectedHeader, _ := rejected["header"].(map[string]any) + if rejectedHeader["template"] != "red" { + t.Fatalf("rejected header template = %v, want red", rejectedHeader["template"]) + } + + approvalSummary := buildApprovalRecordsElement([]ApprovalRecord{ + {ToolName: "bash", Decision: "allow_once"}, + {ToolName: "git", Decision: "reject"}, + }, 2) + rawSummary, _ := json.Marshal(approvalSummary) + summaryText := string(rawSummary) + if !strings.Contains(summaryText, "1 通过") || !strings.Contains(summaryText, "1 拒绝") || !strings.Contains(summaryText, "2 等待") { + t.Fatalf("approval summary = %s, want approval counts", summaryText) + } + if !strings.Contains(summaryText, "bash") || !strings.Contains(summaryText, "git") { + t.Fatalf("approval summary = %s, want tool details", summaryText) + } + + timeout := buildResolvedUserQuestionCard(ResolvedUserQuestionCardPayload{ + Status: "timeout", + Summary: "问题等待超时", + }) + timeoutHeader, _ := timeout["header"].(map[string]any) + if timeoutHeader["template"] != "red" { + t.Fatalf("timeout header template = %v, want red", timeoutHeader["template"]) + } + title, _ := timeoutHeader["title"].(map[string]string) + if title["content"] != "用户问题" { + t.Fatalf("timeout title = %#v, want default title", timeoutHeader["title"]) + } +} diff --git a/internal/feishuadapter/sdk_ingress.go b/internal/feishuadapter/sdk_ingress.go index 6e4b188a..b04154a5 100644 --- a/internal/feishuadapter/sdk_ingress.go +++ b/internal/feishuadapter/sdk_ingress.go @@ -116,16 +116,40 @@ func mapSDKCardActionEvent(event *larkevent.EventReq) (FeishuCardActionEvent, bo if err := json.Unmarshal(event.Body, &payload); err != nil { return FeishuCardActionEvent{}, false } + actionType := strings.TrimSpace(strings.ToLower(payload.Event.Action.Value["action_type"])) requestID := strings.TrimSpace(payload.Event.Action.Value["request_id"]) decision := strings.TrimSpace(strings.ToLower(payload.Event.Action.Value["decision"])) - if requestID == "" || (decision != "allow_once" && decision != "reject") { + status := strings.TrimSpace(strings.ToLower(payload.Event.Action.Value["status"])) + value := strings.TrimSpace(payload.Event.Action.Value["value"]) + message := strings.TrimSpace(payload.Event.Action.Value["message"]) + if actionType == "" { + if decision != "" { + actionType = "permission" + } else { + actionType = "user_question" + } + } + if requestID == "" { return FeishuCardActionEvent{}, false } - return FeishuCardActionEvent{ - EventID: strings.TrimSpace(payload.Header.EventID), - RequestID: requestID, - Decision: decision, - }, true + if actionType == "permission" && decision != "allow_once" && decision != "reject" { + return FeishuCardActionEvent{}, false + } + if actionType == "user_question" && status != "answered" && status != "skipped" { + return FeishuCardActionEvent{}, false + } + cardEvent := FeishuCardActionEvent{ + EventID: strings.TrimSpace(payload.Header.EventID), + ActionType: actionType, + RequestID: requestID, + Decision: decision, + Status: status, + Message: message, + } + if value != "" { + cardEvent.Values = []string{value} + } + return cardEvent, true } // extractSDKMessageText 从 SDK 消息 content JSON 里提取 text。 diff --git a/internal/feishuadapter/sdk_ingress_test.go b/internal/feishuadapter/sdk_ingress_test.go index 0a4df9cc..42bae7e2 100644 --- a/internal/feishuadapter/sdk_ingress_test.go +++ b/internal/feishuadapter/sdk_ingress_test.go @@ -327,7 +327,41 @@ func TestMapSDKCardActionEventSuccessAndExtractSDKMessageTextFallback(t *testing if event.EventID != "evt-card" || event.RequestID != "perm-1" || event.Decision != "allow_once" { t.Fatalf("unexpected card action: %#v", event) } + if event.ActionType != "permission" { + t.Fatalf("action type = %q, want permission", event.ActionType) + } if text := extractSDKMessageText("plain text"); text != "plain text" { t.Fatalf("fallback sdk text = %q", text) } } + +func TestMapSDKCardActionEventSupportsUserQuestionAction(t *testing.T) { + event, ok := mapSDKCardActionEvent(&larkevent.EventReq{Body: []byte(`{ + "header":{"event_id":"evt-ask-card"}, + "event":{"action":{"value":{"action_type":"user_question","request_id":"ask-1","status":"answered","value":"A"}}} + }`)}) + if !ok { + t.Fatal("expected ask_user card action to parse") + } + if event.ActionType != "user_question" || event.RequestID != "ask-1" || event.Status != "answered" { + t.Fatalf("unexpected ask_user card action: %#v", event) + } + if len(event.Values) != 1 || event.Values[0] != "A" { + t.Fatalf("unexpected ask_user values: %#v", event.Values) + } +} + +func TestMapSDKCardActionEventRejectsInvalidActionPayload(t *testing.T) { + if _, ok := mapSDKCardActionEvent(&larkevent.EventReq{Body: []byte(`{ + "header":{"event_id":"evt-bad-card"}, + "event":{"action":{"value":{"action_type":"permission","request_id":"perm-1","decision":"allow_all"}}} + }`)}); ok { + t.Fatal("expected invalid permission decision to be rejected") + } + if _, ok := mapSDKCardActionEvent(&larkevent.EventReq{Body: []byte(`{ + "header":{"event_id":"evt-bad-ask"}, + "event":{"action":{"value":{"action_type":"user_question","request_id":"ask-1","status":"pending"}}} + }`)}); ok { + t.Fatal("expected invalid user_question status to be rejected") + } +} diff --git a/internal/feishuadapter/types.go b/internal/feishuadapter/types.go index d4894e81..2ae65ca1 100644 --- a/internal/feishuadapter/types.go +++ b/internal/feishuadapter/types.go @@ -103,6 +103,7 @@ type GatewayClient interface { BindStream(ctx context.Context, sessionID string, runID string) error Run(ctx context.Context, sessionID string, runID string, inputText string) error ResolvePermission(ctx context.Context, requestID string, decision string) error + ResolveUserQuestion(ctx context.Context, requestID string, status string, values []string, message string) error Ping(ctx context.Context) error Notifications() <-chan GatewayNotification Close() error @@ -113,6 +114,8 @@ type Messenger interface { SendText(ctx context.Context, chatID string, text string) error SendPermissionCard(ctx context.Context, chatID string, payload PermissionCardPayload) (string, error) UpdatePermissionCard(ctx context.Context, cardID string, payload ResolvedPermissionCardPayload) error + SendUserQuestionCard(ctx context.Context, chatID string, payload UserQuestionCardPayload) (string, error) + UpdateUserQuestionCard(ctx context.Context, cardID string, payload ResolvedUserQuestionCardPayload) error SendStatusCard(ctx context.Context, chatID string, payload StatusCardPayload) (string, error) UpdateCard(ctx context.Context, cardID string, payload StatusCardPayload) error } @@ -136,6 +139,31 @@ type ResolvedPermissionCardPayload struct { Approved bool } +// UserQuestionCardOption 表示 ask_user 单选项按钮。 +type UserQuestionCardOption struct { + Label string + Description string +} + +// UserQuestionCardPayload 表示 ask_user 待回答卡片字段。 +type UserQuestionCardPayload struct { + RequestID string + QuestionID string + Title string + Description string + Kind string + Options []UserQuestionCardOption + AllowSkip bool +} + +// ResolvedUserQuestionCardPayload 表示 ask_user 已处理卡片字段。 +type ResolvedUserQuestionCardPayload struct { + RequestID string + Title string + Status string + Summary string +} + // ApprovalRecord 记录单次工具审批请求及其结论。 type ApprovalRecord struct { ToolName string diff --git a/internal/feishuadapter/webhook_ingress.go b/internal/feishuadapter/webhook_ingress.go index e4e51077..32689f94 100644 --- a/internal/feishuadapter/webhook_ingress.go +++ b/internal/feishuadapter/webhook_ingress.go @@ -146,21 +146,52 @@ func (w *WebhookIngress) handleCardCallback(handler IngressHandler) http.Handler http.Error(writer, "invalid verify token", http.StatusUnauthorized) return } + actionType := strings.TrimSpace(strings.ToLower(callback.Action.Value["action_type"])) requestID := strings.TrimSpace(callback.Action.Value["request_id"]) decision := strings.TrimSpace(strings.ToLower(callback.Action.Value["decision"])) - if requestID == "" || (decision != "allow_once" && decision != "reject") { + status := strings.TrimSpace(strings.ToLower(callback.Action.Value["status"])) + value := strings.TrimSpace(callback.Action.Value["value"]) + message := strings.TrimSpace(callback.Action.Value["message"]) + if actionType == "" { + if decision != "" { + actionType = "permission" + } else { + actionType = "user_question" + } + } + valid := requestID != "" + if valid && actionType == "permission" { + valid = decision == "allow_once" || decision == "reject" + } + if valid && actionType == "user_question" { + valid = status == "answered" || status == "skipped" + } + if !valid { writeJSON(writer, http.StatusOK, map[string]any{"toast": map[string]string{"type": "info", "content": "callback ready"}}) return } - if err := handler.HandleCardAction(request.Context(), FeishuCardActionEvent{ - EventID: strings.TrimSpace(callback.Header.EventID), - RequestID: requestID, - Decision: decision, - }); err != nil { + event := FeishuCardActionEvent{ + EventID: strings.TrimSpace(callback.Header.EventID), + ActionType: actionType, + RequestID: requestID, + Decision: decision, + Status: status, + Message: message, + } + if value != "" { + event.Values = []string{value} + } + if err := handler.HandleCardAction(request.Context(), event); err != nil { http.Error(writer, "card action failed", http.StatusInternalServerError) return } - writeJSON(writer, http.StatusOK, map[string]any{"toast": map[string]string{"type": "success", "content": "审批已提交"}}) + toast := "操作已提交" + if actionType == "permission" { + toast = "审批已提交" + } else if actionType == "user_question" { + toast = "回答已提交" + } + writeJSON(writer, http.StatusOK, map[string]any{"toast": map[string]string{"type": "success", "content": toast}}) } } diff --git a/internal/gateway/contracts.go b/internal/gateway/contracts.go index a1516f94..69f6692e 100644 --- a/internal/gateway/contracts.go +++ b/internal/gateway/contracts.go @@ -729,17 +729,32 @@ type TodoSnapshot struct { Summary TodoSummary `json:"summary,omitempty"` } +// PendingUserQuestionSnapshot 描述当前会话待回答 ask_user 问题快照。 +type PendingUserQuestionSnapshot struct { + RequestID string `json:"request_id"` + QuestionID string `json:"question_id"` + Title string `json:"title"` + Description string `json:"description"` + Kind string `json:"kind"` + Options []any `json:"options,omitempty"` + Required bool `json:"required"` + AllowSkip bool `json:"allow_skip"` + MaxChoices int `json:"max_choices,omitempty"` + TimeoutSec int `json:"timeout_sec,omitempty"` +} + // RuntimeSnapshot 描述 runtime 对外暴露的统一运行状态快照。 type RuntimeSnapshot struct { - RunID string `json:"run_id,omitempty"` - SessionID string `json:"session_id"` - Phase string `json:"phase,omitempty"` - TaskKind string `json:"task_kind,omitempty"` - UpdatedAt time.Time `json:"updated_at"` - Todos TodoSnapshot `json:"todos"` - Facts map[string]any `json:"facts,omitempty"` - Decision map[string]any `json:"decision,omitempty"` - SubAgents map[string]any `json:"subagents,omitempty"` + RunID string `json:"run_id,omitempty"` + SessionID string `json:"session_id"` + Phase string `json:"phase,omitempty"` + TaskKind string `json:"task_kind,omitempty"` + UpdatedAt time.Time `json:"updated_at"` + Todos TodoSnapshot `json:"todos"` + Facts map[string]any `json:"facts,omitempty"` + Decision map[string]any `json:"decision,omitempty"` + SubAgents map[string]any `json:"subagents,omitempty"` + PendingUserQuestion *PendingUserQuestionSnapshot `json:"pending_user_question,omitempty"` } // SkillSource 描述技能来源元数据。 diff --git a/internal/gateway/security.go b/internal/gateway/security.go index d2112345..2cb1406d 100644 --- a/internal/gateway/security.go +++ b/internal/gateway/security.go @@ -72,6 +72,7 @@ func fullControlPlaneMethods() map[string]struct{} { "checkpoint.undoRestore", "checkpoint.diff", "gateway.resolvePermission", + "gateway.userQuestionAnswer", "gateway.user_question_answer", "gateway.deleteSession", "gateway.renameSession", diff --git a/internal/gateway/security_test.go b/internal/gateway/security_test.go index aa68e73a..183c7c81 100644 --- a/internal/gateway/security_test.go +++ b/internal/gateway/security_test.go @@ -39,6 +39,8 @@ func TestStrictACLAllowlist(t *testing.T) { {source: RequestSourceHTTP, method: "checkpoint.restore", want: true}, {source: RequestSourceHTTP, method: "checkpoint.undoRestore", want: true}, {source: RequestSourceHTTP, method: "checkpoint.diff", want: true}, + {source: RequestSourceHTTP, method: "gateway.userQuestionAnswer", want: true}, + {source: RequestSourceHTTP, method: "gateway.user_question_answer", want: true}, {source: RequestSourceUnknown, method: "gateway.ping", want: false}, } for _, tc := range cases { diff --git a/internal/runtime/ask_user_snapshot.go b/internal/runtime/ask_user_snapshot.go new file mode 100644 index 00000000..30815e4d --- /dev/null +++ b/internal/runtime/ask_user_snapshot.go @@ -0,0 +1,126 @@ +package runtime + +import "strings" + +// setPendingUserQuestion 在 runState 中记录当前待回答 ask_user 问题,用于快照恢复。 +func (s *Service) setPendingUserQuestion(state *runState, payload UserQuestionRequestedPayload) { + if s == nil || state == nil { + return + } + state.mu.Lock() + defer state.mu.Unlock() + state.pendingUserQuestion = clonePendingUserQuestion(&payload) +} + +// clearPendingUserQuestionIfMatches 在问题被回答/跳过/超时后清理待回答快照。 +func (s *Service) clearPendingUserQuestionIfMatches(state *runState, requestID string) { + if s == nil || state == nil { + return + } + state.mu.Lock() + defer state.mu.Unlock() + if state.pendingUserQuestion == nil { + return + } + if strings.TrimSpace(requestID) == "" || + strings.EqualFold(strings.TrimSpace(state.pendingUserQuestion.RequestID), strings.TrimSpace(requestID)) { + state.pendingUserQuestion = nil + } +} + +// parseAskUserRequestedPayload 把 ask_user 提问事件负载归一化为 runtime 强类型结构。 +func parseAskUserRequestedPayload(payload any) (UserQuestionRequestedPayload, bool) { + switch typed := payload.(type) { + case UserQuestionRequestedPayload: + return typed, true + case *UserQuestionRequestedPayload: + if typed == nil { + return UserQuestionRequestedPayload{}, false + } + return *typed, true + case map[string]any: + result := UserQuestionRequestedPayload{ + RequestID: trimAnyString(typed["request_id"]), + QuestionID: trimAnyString(typed["question_id"]), + Title: trimAnyString(typed["title"]), + Description: trimAnyString(typed["description"]), + Kind: trimAnyString(typed["kind"]), + Required: toAnyBool(typed["required"]), + AllowSkip: toAnyBool(typed["allow_skip"]), + MaxChoices: toAnyInt(typed["max_choices"]), + TimeoutSec: toAnyInt(typed["timeout_sec"]), + } + if rawOptions, ok := typed["options"].([]any); ok { + result.Options = append([]any(nil), rawOptions...) + } + if strings.TrimSpace(result.RequestID) == "" { + return UserQuestionRequestedPayload{}, false + } + return result, true + default: + return UserQuestionRequestedPayload{}, false + } +} + +// parseAskUserResolvedPayload 把 ask_user 回答类事件负载归一化为 runtime 强类型结构。 +func parseAskUserResolvedPayload(payload any) (UserQuestionResolvedPayload, bool) { + switch typed := payload.(type) { + case UserQuestionResolvedPayload: + return typed, true + case *UserQuestionResolvedPayload: + if typed == nil { + return UserQuestionResolvedPayload{}, false + } + return *typed, true + case map[string]any: + result := UserQuestionResolvedPayload{ + RequestID: trimAnyString(typed["request_id"]), + QuestionID: trimAnyString(typed["question_id"]), + Status: trimAnyString(typed["status"]), + Message: trimAnyString(typed["message"]), + } + if rawValues, ok := typed["values"].([]any); ok { + values := make([]string, 0, len(rawValues)) + for _, value := range rawValues { + trimmed := trimAnyString(value) + if trimmed != "" { + values = append(values, trimmed) + } + } + result.Values = values + } + return result, true + default: + return UserQuestionResolvedPayload{}, false + } +} + +// trimAnyString 将任意输入转换为去首尾空白的字符串。 +func trimAnyString(value any) string { + if typed, ok := value.(string); ok { + return strings.TrimSpace(typed) + } + return "" +} + +// toAnyBool 将松散 JSON 布尔字段转换为 bool。 +func toAnyBool(value any) bool { + typed, _ := value.(bool) + return typed +} + +// toAnyInt 将松散 JSON 数值字段转换为 int。 +func toAnyInt(value any) int { + switch typed := value.(type) { + case int: + return typed + case int32: + return int(typed) + case int64: + return int(typed) + case float64: + return int(typed) + default: + return 0 + } +} diff --git a/internal/runtime/ask_user_snapshot_test.go b/internal/runtime/ask_user_snapshot_test.go new file mode 100644 index 00000000..d4219eb1 --- /dev/null +++ b/internal/runtime/ask_user_snapshot_test.go @@ -0,0 +1,199 @@ +package runtime + +import "testing" + +func TestParseAskUserRequestedPayloadFromMap(t *testing.T) { + t.Parallel() + + payload, ok := parseAskUserRequestedPayload(map[string]any{ + "request_id": "ask-1", + "question_id": "q-1", + "title": "Pick one", + "description": "desc", + "kind": "single_choice", + "options": []any{"A", "B"}, + "required": true, + "allow_skip": false, + "max_choices": float64(1), + "timeout_sec": float64(300), + }) + if !ok { + t.Fatal("expected payload to be parsed") + } + if payload.RequestID != "ask-1" || payload.QuestionID != "q-1" || payload.Kind != "single_choice" { + t.Fatalf("unexpected parsed payload: %+v", payload) + } + if len(payload.Options) != 2 { + t.Fatalf("options len = %d, want 2", len(payload.Options)) + } +} + +func TestParseAskUserResolvedPayloadFromMap(t *testing.T) { + t.Parallel() + + payload, ok := parseAskUserResolvedPayload(map[string]any{ + "request_id": "ask-2", + "question_id": "q-2", + "status": "answered", + "values": []any{"a", "b"}, + "message": " done ", + }) + if !ok { + t.Fatal("expected payload to be parsed") + } + if payload.RequestID != "ask-2" || payload.Status != "answered" { + t.Fatalf("unexpected parsed payload: %+v", payload) + } + if payload.Message != "done" { + t.Fatalf("message = %q, want done", payload.Message) + } + if len(payload.Values) != 2 || payload.Values[0] != "a" || payload.Values[1] != "b" { + t.Fatalf("values = %#v", payload.Values) + } +} + +func TestPendingUserQuestionLifecycleInRunState(t *testing.T) { + t.Parallel() + + service := &Service{} + state := newRunState("run-pending-q", newRuntimeSession("session-pending-q")) + + service.setPendingUserQuestion(&state, UserQuestionRequestedPayload{ + RequestID: "ask-3", + QuestionID: "q-3", + Title: "Title", + Description: "Desc", + Kind: "text", + Options: []any{"x"}, + }) + + snapshot := buildRuntimeSnapshot(&state) + if snapshot.PendingUserQuestion == nil { + t.Fatal("expected snapshot pending user question") + } + if snapshot.PendingUserQuestion.RequestID != "ask-3" { + t.Fatalf("request_id = %q, want ask-3", snapshot.PendingUserQuestion.RequestID) + } + + snapshot.PendingUserQuestion.Options[0] = "mutated" + afterMutation := buildRuntimeSnapshot(&state) + if got := afterMutation.PendingUserQuestion.Options[0]; got != "x" { + t.Fatalf("expected cloned options to be immutable from snapshot mutation, got %v", got) + } + + service.clearPendingUserQuestionIfMatches(&state, "ask-3") + cleared := buildRuntimeSnapshot(&state) + if cleared.PendingUserQuestion != nil { + t.Fatalf("expected pending user question to be cleared, got %+v", cleared.PendingUserQuestion) + } +} + +func TestAskUserSnapshotHelpersHandleEdgeCases(t *testing.T) { + t.Parallel() + + service := &Service{} + state := newRunState("run-edge-q", newRuntimeSession("session-edge-q")) + + service.setPendingUserQuestion(nil, UserQuestionRequestedPayload{RequestID: "ignored"}) + service.setPendingUserQuestion(&state, UserQuestionRequestedPayload{ + RequestID: "ask-edge", + Title: "title", + Options: []any{"keep"}, + }) + + service.clearPendingUserQuestionIfMatches(&state, "other") + if buildRuntimeSnapshot(&state).PendingUserQuestion == nil { + t.Fatal("expected non-matching request id to keep pending question") + } + + service.clearPendingUserQuestionIfMatches(&state, " ASK-EDGE ") + if buildRuntimeSnapshot(&state).PendingUserQuestion != nil { + t.Fatal("expected case-insensitive request id match to clear pending question") + } + + service.setPendingUserQuestion(&state, UserQuestionRequestedPayload{RequestID: "ask-blank"}) + service.clearPendingUserQuestionIfMatches(&state, " ") + if buildRuntimeSnapshot(&state).PendingUserQuestion != nil { + t.Fatal("expected blank request id to clear pending question") + } + + service.clearPendingUserQuestionIfMatches(nil, "ignored") + var nilService *Service + nilService.setPendingUserQuestion(&state, UserQuestionRequestedPayload{RequestID: "nil-service"}) + if buildRuntimeSnapshot(&state).PendingUserQuestion != nil { + t.Fatal("nil service should not mutate run state") + } +} + +func TestParseAskUserPayloadHelpersHandlePointersAndInvalidMaps(t *testing.T) { + t.Parallel() + + requested, ok := parseAskUserRequestedPayload(&UserQuestionRequestedPayload{ + RequestID: "ask-pointer", + Kind: "text", + }) + if !ok || requested.RequestID != "ask-pointer" { + t.Fatalf("pointer payload = %+v, ok=%v", requested, ok) + } + if _, ok := parseAskUserRequestedPayload((*UserQuestionRequestedPayload)(nil)); ok { + t.Fatal("nil requested payload pointer should not parse") + } + if _, ok := parseAskUserRequestedPayload(map[string]any{"request_id": " "}); ok { + t.Fatal("blank request_id should be rejected") + } + if _, ok := parseAskUserRequestedPayload("invalid"); ok { + t.Fatal("non-map requested payload should not parse") + } + + resolved, ok := parseAskUserResolvedPayload(&UserQuestionResolvedPayload{ + RequestID: "ask-resolved", + Status: "answered", + }) + if !ok || resolved.RequestID != "ask-resolved" { + t.Fatalf("resolved pointer payload = %+v, ok=%v", resolved, ok) + } + if _, ok := parseAskUserResolvedPayload((*UserQuestionResolvedPayload)(nil)); ok { + t.Fatal("nil resolved payload pointer should not parse") + } + if _, ok := parseAskUserResolvedPayload(123); ok { + t.Fatal("non-map resolved payload should not parse") + } +} + +func TestAskUserSnapshotScalarConverters(t *testing.T) { + t.Parallel() + + if got := trimAnyString(" hello "); got != "hello" { + t.Fatalf("trimAnyString() = %q, want hello", got) + } + if got := trimAnyString(123); got != "" { + t.Fatalf("trimAnyString(non-string) = %q, want empty", got) + } + if got := toAnyBool(true); !got { + t.Fatal("toAnyBool(true) = false, want true") + } + if got := toAnyBool("true"); got { + t.Fatal("toAnyBool(non-bool) = true, want false") + } + + intCases := map[string]any{ + "int": int(7), + "int32": int32(8), + "int64": int64(9), + "float64": float64(10), + } + want := map[string]int{ + "int": 7, + "int32": 8, + "int64": 9, + "float64": 10, + } + for name, value := range intCases { + if got := toAnyInt(value); got != want[name] { + t.Fatalf("toAnyInt(%s) = %d, want %d", name, got, want[name]) + } + } + if got := toAnyInt("11"); got != 0 { + t.Fatalf("toAnyInt(non-number) = %d, want 0", got) + } +} diff --git a/internal/runtime/permission.go b/internal/runtime/permission.go index c41bdc6f..b61bb253 100644 --- a/internal/runtime/permission.go +++ b/internal/runtime/permission.go @@ -57,6 +57,8 @@ const ( minInlineSubAgentToolTimeout = 30 * time.Second defaultDiagnoseToolTimeout = 60 * time.Second defaultPermissionToolTimeout = 20 * time.Second + defaultAskUserToolTimeout = 5 * time.Minute + maxAskUserToolTimeout = time.Hour ) // permissionExecutionInput 汇总一次工具执行与审批协作所需的上下文。 @@ -111,8 +113,8 @@ func (s *Service) ResolveUserQuestion(ctx context.Context, input UserQuestionRes } result := askuser.Result{ - Status: status, - Values: input.Values, + Status: status, + Values: input.Values, Message: strings.TrimSpace(input.Message), } @@ -147,8 +149,16 @@ func (s *Service) executeToolCallWithPermission(ctx context.Context, input permi callInput.AskUserEventEmitter = func(eventName string, payload any) { eventType := eventTypeFromAskUserEvent(eventName) if eventName == "user_question_requested" { + if question, ok := parseAskUserRequestedPayload(payload); ok { + s.setPendingUserQuestion(input.State, question) + s.emitRuntimeSnapshotUpdated(ctx, input.State, "user_question_requested") + } s.emitRunScopedPriority(eventType, input.State, payload) } else { + if resolved, ok := parseAskUserResolvedPayload(payload); ok { + s.clearPendingUserQuestionIfMatches(input.State, resolved.RequestID) + s.emitRuntimeSnapshotUpdated(ctx, input.State, "user_question_"+strings.TrimSpace(resolved.Status)) + } s.emitRunScopedOptional(eventType, input.State, payload) } } @@ -302,6 +312,17 @@ func resolveToolExecutionTimeout(call providertypes.ToolCall, fallback time.Dura } return base } + if strings.EqualFold(name, tools.ToolNameAskUser) { + requested := parseAskUserTimeoutFromArguments(call.Arguments) + if requested <= 0 { + requested = defaultAskUserToolTimeout + } + requested = clampDuration(requested, defaultAskUserToolTimeout, maxAskUserToolTimeout) + if requested > base { + return requested + } + return base + } if !strings.EqualFold(name, tools.ToolNameSpawnSubAgent) { return base } @@ -320,6 +341,20 @@ func resolveToolExecutionTimeout(call providertypes.ToolCall, fallback time.Dura return base } +// parseAskUserTimeoutFromArguments 解析 ask_user 的 timeout_sec,并返回持续时间。 +func parseAskUserTimeoutFromArguments(raw string) time.Duration { + if strings.TrimSpace(raw) == "" { + return 0 + } + var payload struct { + TimeoutSec int `json:"timeout_sec"` + } + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return 0 + } + return time.Duration(payload.TimeoutSec) * time.Second +} + // parseSpawnSubAgentRuntimeOptions 提取 spawn_subagent 的运行模式与 timeout_sec 参数。 func parseSpawnSubAgentRuntimeOptions(raw string) (string, time.Duration) { if strings.TrimSpace(raw) == "" { diff --git a/internal/runtime/permission_test.go b/internal/runtime/permission_test.go index 3afa3b3e..7a1ae74b 100644 --- a/internal/runtime/permission_test.go +++ b/internal/runtime/permission_test.go @@ -1295,6 +1295,43 @@ func TestResolveToolExecutionTimeoutForDiagnose(t *testing.T) { } } +func TestResolveToolExecutionTimeoutForAskUser(t *testing.T) { + t.Parallel() + + base := 20 * time.Second + got := resolveToolExecutionTimeout(providertypes.ToolCall{ + Name: tools.ToolNameAskUser, + Arguments: `{"question_id":"q1","title":"need input","kind":"text"}`, + }, base) + if got != defaultAskUserToolTimeout { + t.Fatalf("expected ask_user timeout %v, got %v", defaultAskUserToolTimeout, got) + } + + got = resolveToolExecutionTimeout(providertypes.ToolCall{ + Name: tools.ToolNameAskUser, + Arguments: `{"question_id":"q1","title":"need input","kind":"text","timeout_sec":600}`, + }, base) + if got != 10*time.Minute { + t.Fatalf("expected ask_user timeout 10m, got %v", got) + } + + got = resolveToolExecutionTimeout(providertypes.ToolCall{ + Name: tools.ToolNameAskUser, + Arguments: `{"question_id":"q1","title":"need input","kind":"text","timeout_sec":7200}`, + }, base) + if got != maxAskUserToolTimeout { + t.Fatalf("expected ask_user timeout clamped to %v, got %v", maxAskUserToolTimeout, got) + } + + got = resolveToolExecutionTimeout(providertypes.ToolCall{ + Name: tools.ToolNameAskUser, + Arguments: `{"question_id":"q1","title":"need input","kind":"text","timeout_sec":60}`, + }, 15*time.Minute) + if got != 15*time.Minute { + t.Fatalf("expected larger base timeout to win, got %v", got) + } +} + func TestResolveToolExecutionTimeoutFallbackAndHelpers(t *testing.T) { t.Parallel() @@ -1321,6 +1358,16 @@ func TestResolveToolExecutionTimeoutFallbackAndHelpers(t *testing.T) { t.Fatalf("unexpected parsed options mode=%q timeout=%v", mode, timeout) } + if got := parseAskUserTimeoutFromArguments(""); got != 0 { + t.Fatalf("expected empty ask_user args timeout 0, got %v", got) + } + if got := parseAskUserTimeoutFromArguments("{"); got != 0 { + t.Fatalf("expected invalid ask_user args timeout 0, got %v", got) + } + if got := parseAskUserTimeoutFromArguments(`{"timeout_sec":321}`); got != 321*time.Second { + t.Fatalf("expected parsed ask_user timeout 321s, got %v", got) + } + if got := clampDuration(5*time.Second, 10*time.Second, 20*time.Second); got != 10*time.Second { t.Fatalf("expected lower clamp, got %v", got) } diff --git a/internal/runtime/runtime_snapshot.go b/internal/runtime/runtime_snapshot.go index e10ac6e9..4dae4b90 100644 --- a/internal/runtime/runtime_snapshot.go +++ b/internal/runtime/runtime_snapshot.go @@ -21,6 +21,8 @@ type RuntimeSnapshot struct { Facts FactsSnapshot `json:"facts"` Decision DecisionSnapshot `json:"decision,omitempty"` SubAgents SubAgentSnapshot `json:"subagents,omitempty"` + // PendingUserQuestion 表示当前 run 是否存在待回答 ask_user 问题。 + PendingUserQuestion *UserQuestionRequestedPayload `json:"pending_user_question,omitempty"` } // FactsSnapshot 是 runtime facts 的传输快照。 @@ -93,6 +95,7 @@ func buildRuntimeSnapshot(state *runState) RuntimeSnapshot { UserVisibleSummary: strings.TrimSpace(state.lastDeciderDecision.UserVisibleSummary), InternalSummary: strings.TrimSpace(state.lastDeciderDecision.InternalSummary), } + pendingUserQuestion := clonePendingUserQuestion(state.pendingUserQuestion) return RuntimeSnapshot{ RunID: strings.TrimSpace(state.runID), @@ -110,6 +113,7 @@ func buildRuntimeSnapshot(state *runState) RuntimeSnapshot { CompletedCount: len(factsSnapshot.SubAgents.Completed), FailedCount: len(factsSnapshot.SubAgents.Failed), }, + PendingUserQuestion: pendingUserQuestion, } } @@ -210,7 +214,18 @@ func (s *Service) GetRuntimeSnapshot(ctx context.Context, sessionID string) (Run Facts: FactsSnapshot{ RuntimeFacts: runtimefacts.RuntimeFacts{}, }, + PendingUserQuestion: nil, } s.cacheRuntimeSnapshot(snapshot) return snapshot, nil } + +// clonePendingUserQuestion 复制待回答 ask_user 快照,避免共享可变引用到外部读取方。 +func clonePendingUserQuestion(question *UserQuestionRequestedPayload) *UserQuestionRequestedPayload { + if question == nil { + return nil + } + cloned := *question + cloned.Options = append([]any(nil), question.Options...) + return &cloned +} diff --git a/internal/runtime/state.go b/internal/runtime/state.go index 2308a6c9..6606f552 100644 --- a/internal/runtime/state.go +++ b/internal/runtime/state.go @@ -58,6 +58,7 @@ type runState struct { hookNotificationOmitted int reportedMissingSkills map[string]struct{} thinkingOverride *ThinkingOverride + pendingUserQuestion *UserQuestionRequestedPayload disableTools bool } diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index 98afc22a..c3e1434a 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -11,6 +11,7 @@ import ( "io" "os" "path/filepath" + "strconv" "strings" "time" "unicode" @@ -688,6 +689,21 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te } input := strings.TrimSpace(rawInput) images := a.collectPendingImageInputs() + if a.pendingUserQuestion != nil && + strings.HasPrefix(input, slashPrefix) && + !strings.EqualFold(input, "/skip") && + isCompleteSlashCommand(strings.ToLower(input)) { + a.input.Reset() + a.state.InputText = "" + a.clearPendingTextPastes() + a.applyComponentLayout(true) + a.refreshCommandMenu() + a.resetPasteHeuristics() + if cmd := a.runSlashCommandSelection(strings.ToLower(input)); cmd != nil { + cmds = append(cmds, cmd) + } + return a, batchUpdateCmds() + } if cmd, handled := a.submitPendingUserQuestionInput(input); handled { a.input.Reset() a.state.InputText = "" @@ -945,17 +961,34 @@ func (a *App) submitPendingUserQuestionInput(input string) (tea.Cmd, bool) { message := "" switch kind { case "single_choice": - values = parseUserQuestionValues(rawInput) + options := parseUserQuestionOptionLabels(request.Options) + var invalidValues []string + values, invalidValues = normalizeUserQuestionChoiceValues(parseUserQuestionValues(rawInput), options) if len(values) != 1 { a.state.StatusText = "single_choice requires exactly one value" return nil, true } + if len(invalidValues) > 0 { + a.state.StatusText = fmt.Sprintf("single_choice must match options: %s", strings.Join(invalidValues, ", ")) + return nil, true + } case "multi_choice": - values = parseUserQuestionValues(rawInput) + options := parseUserQuestionOptionLabels(request.Options) + var invalidValues []string + values, invalidValues = normalizeUserQuestionChoiceValues(parseUserQuestionValues(rawInput), options) if len(values) == 0 { a.state.StatusText = statusUserQuestionRequired return nil, true } + maxChoices := request.MaxChoices + if maxChoices > 0 && len(values) > maxChoices { + a.state.StatusText = fmt.Sprintf("multi_choice accepts up to %d values", maxChoices) + return nil, true + } + if len(invalidValues) > 0 { + a.state.StatusText = fmt.Sprintf("multi_choice values must match options: %s", strings.Join(invalidValues, ", ")) + return nil, true + } default: message = rawInput } @@ -980,6 +1013,101 @@ func parseUserQuestionValues(raw string) []string { return values } +// parseUserQuestionOptionLabels 从 ask_user 事件 options 里提取可提交的标签列表。 +func parseUserQuestionOptionLabels(rawOptions []any) []string { + if len(rawOptions) == 0 { + return nil + } + labels := make([]string, 0, len(rawOptions)) + for _, option := range rawOptions { + switch typed := option.(type) { + case string: + label := strings.TrimSpace(typed) + if label != "" { + labels = append(labels, label) + } + case map[string]any: + label := strings.TrimSpace(anyToString(typed["label"])) + if label != "" { + labels = append(labels, label) + } + default: + // 未知结构直接忽略,避免阻断已有链路。 + } + } + return labels +} + +// normalizeUserQuestionChoiceValues 把输入值归一为选项 label,并返回不合法的原始值。 +func normalizeUserQuestionChoiceValues(values []string, options []string) ([]string, []string) { + if len(values) == 0 { + return nil, nil + } + normalized := make([]string, 0, len(values)) + invalid := make([]string, 0) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + canonical, ok := normalizeUserQuestionChoiceValue(value, options) + if !ok { + invalid = append(invalid, strings.TrimSpace(value)) + continue + } + key := strings.ToLower(strings.TrimSpace(canonical)) + if key == "" { + continue + } + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, canonical) + } + return normalized, invalid +} + +// normalizeUserQuestionChoiceValue 支持按序号或标签匹配 ask_user 选项。 +func normalizeUserQuestionChoiceValue(raw string, options []string) (string, bool) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", false + } + if len(options) == 0 { + return trimmed, true + } + if numeric, ok := parsePositiveInt(trimmed); ok { + if numeric >= 1 && numeric <= len(options) { + return options[numeric-1], true + } + } + for _, option := range options { + if strings.EqualFold(strings.TrimSpace(option), trimmed) { + return option, true + } + } + return "", false +} + +// parsePositiveInt 尝试把字符串解析为正整数。 +func parsePositiveInt(raw string) (int, bool) { + value, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || value <= 0 { + return 0, false + } + return value, true +} + +// anyToString 将任意值转换为字符串,主要用于解析 map 结构的选项标签。 +func anyToString(value any) string { + switch typed := value.(type) { + case string: + return typed + case fmt.Stringer: + return typed.String() + default: + return "" + } +} + // toggleFullAccessMode 处理 Full Access 模式的启停切换;启用前必须经过风险确认? func (a *App) toggleFullAccessMode() { if a.fullAccessModeEnabled { diff --git a/internal/tui/core/app/update_user_question_test.go b/internal/tui/core/app/update_user_question_test.go new file mode 100644 index 00000000..0c38d192 --- /dev/null +++ b/internal/tui/core/app/update_user_question_test.go @@ -0,0 +1,257 @@ +package tui + +import ( + "errors" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + agentruntime "neo-code/internal/tui/services" +) + +func TestUserQuestionChoiceParsingHelpers(t *testing.T) { + options := parseUserQuestionOptionLabels([]any{ + map[string]any{"label": "alpha"}, + "beta", + map[string]any{"label": " "}, + 3, + }) + if len(options) != 2 || options[0] != "alpha" || options[1] != "beta" { + t.Fatalf("options = %#v, want [\"alpha\",\"beta\"]", options) + } + + values, invalid := normalizeUserQuestionChoiceValues([]string{"1", "beta", "1", "gamma"}, options) + if len(values) != 2 || values[0] != "alpha" || values[1] != "beta" { + t.Fatalf("values = %#v, want [\"alpha\",\"beta\"]", values) + } + if len(invalid) != 1 || invalid[0] != "gamma" { + t.Fatalf("invalid = %#v, want [\"gamma\"]", invalid) + } +} + +func TestSubmitPendingUserQuestionInputText(t *testing.T) { + runtime := &permissionTestRuntime{} + app := newPermissionTestApp(runtime) + app.pendingUserQuestion = &userQuestionPromptState{ + Request: agentruntime.UserQuestionRequestedPayload{ + RequestID: "ask-1", + Kind: "text", + }, + } + + cmd, handled := app.submitPendingUserQuestionInput(" hello world ") + if !handled || cmd == nil { + t.Fatalf("expected text input to submit ask_user answer, handled=%v cmd=%v", handled, cmd) + } + if !app.pendingUserQuestion.Submitting { + t.Fatalf("expected pending user question entering submitting state") + } + if app.state.StatusText != statusUserQuestionSubmitting { + t.Fatalf("status text = %q, want %q", app.state.StatusText, statusUserQuestionSubmitting) + } + + msg := cmd() + done, ok := msg.(userQuestionResolutionFinishedMsg) + if !ok { + t.Fatalf("expected userQuestionResolutionFinishedMsg, got %T", msg) + } + if done.RequestID != "ask-1" || done.Status != "answered" || done.Err != nil { + t.Fatalf("unexpected resolve message: %+v", done) + } + if runtime.lastQuestion.RequestID != "ask-1" || runtime.lastQuestion.Status != "answered" { + t.Fatalf("unexpected runtime ask_user resolve input: %+v", runtime.lastQuestion) + } + if runtime.lastQuestion.Message != "hello world" { + t.Fatalf("message = %q, want %q", runtime.lastQuestion.Message, "hello world") + } + if len(runtime.lastQuestion.Values) != 0 { + t.Fatalf("values should be empty for text question, got %#v", runtime.lastQuestion.Values) + } +} + +func TestSubmitPendingUserQuestionInputSkipValidation(t *testing.T) { + runtime := &permissionTestRuntime{} + app := newPermissionTestApp(runtime) + app.pendingUserQuestion = &userQuestionPromptState{ + Request: agentruntime.UserQuestionRequestedPayload{ + RequestID: "ask-2", + Kind: "text", + AllowSkip: false, + }, + } + + cmd, handled := app.submitPendingUserQuestionInput("/skip") + if !handled || cmd != nil { + t.Fatalf("expected skip disallowed branch to consume input without cmd, handled=%v cmd=%v", handled, cmd) + } + if app.state.StatusText != "This question does not allow skip" { + t.Fatalf("status text = %q", app.state.StatusText) + } + + app.pendingUserQuestion.Request.AllowSkip = true + cmd, handled = app.submitPendingUserQuestionInput("/skip") + if !handled || cmd == nil { + t.Fatalf("expected skip allowed branch to submit answer, handled=%v cmd=%v", handled, cmd) + } + msg := cmd() + done, ok := msg.(userQuestionResolutionFinishedMsg) + if !ok { + t.Fatalf("expected userQuestionResolutionFinishedMsg, got %T", msg) + } + if done.Status != "skipped" { + t.Fatalf("status = %q, want skipped", done.Status) + } + if runtime.lastQuestion.Status != "skipped" { + t.Fatalf("runtime status = %q, want skipped", runtime.lastQuestion.Status) + } +} + +func TestSubmitPendingUserQuestionInputSingleChoiceSupportsIndexAndOptionValidation(t *testing.T) { + runtime := &permissionTestRuntime{} + app := newPermissionTestApp(runtime) + app.pendingUserQuestion = &userQuestionPromptState{ + Request: agentruntime.UserQuestionRequestedPayload{ + RequestID: "ask-3", + Kind: "single_choice", + Options: []any{ + map[string]any{"label": "alpha"}, + map[string]any{"label": "beta"}, + }, + }, + } + + cmd, handled := app.submitPendingUserQuestionInput("2") + if !handled || cmd == nil { + t.Fatalf("expected index-based single_choice answer to submit, handled=%v cmd=%v", handled, cmd) + } + done := cmd().(userQuestionResolutionFinishedMsg) + if done.Status != "answered" { + t.Fatalf("status = %q, want answered", done.Status) + } + if len(runtime.lastQuestion.Values) != 1 || runtime.lastQuestion.Values[0] != "beta" { + t.Fatalf("values = %#v, want [\"beta\"]", runtime.lastQuestion.Values) + } + + app.pendingUserQuestion = &userQuestionPromptState{ + Request: agentruntime.UserQuestionRequestedPayload{ + RequestID: "ask-4", + Kind: "single_choice", + Options: []any{ + map[string]any{"label": "alpha"}, + map[string]any{"label": "beta"}, + }, + }, + } + cmd, handled = app.submitPendingUserQuestionInput("gamma") + if !handled || cmd != nil { + t.Fatalf("expected invalid single_choice option to be rejected, handled=%v cmd=%v", handled, cmd) + } + if app.state.StatusText == "" { + t.Fatalf("expected validation message for invalid single_choice option") + } +} + +func TestSubmitPendingUserQuestionInputMultiChoiceEnforcesMaxChoices(t *testing.T) { + runtime := &permissionTestRuntime{} + app := newPermissionTestApp(runtime) + app.pendingUserQuestion = &userQuestionPromptState{ + Request: agentruntime.UserQuestionRequestedPayload{ + RequestID: "ask-5", + Kind: "multi_choice", + MaxChoices: 2, + Options: []any{ + map[string]any{"label": "alpha"}, + map[string]any{"label": "beta"}, + map[string]any{"label": "gamma"}, + }, + }, + } + + cmd, handled := app.submitPendingUserQuestionInput("1, beta, 1") + if !handled || cmd == nil { + t.Fatalf("expected valid multi_choice answer to submit, handled=%v cmd=%v", handled, cmd) + } + done := cmd().(userQuestionResolutionFinishedMsg) + if done.Status != "answered" { + t.Fatalf("status = %q, want answered", done.Status) + } + if len(runtime.lastQuestion.Values) != 2 || runtime.lastQuestion.Values[0] != "alpha" || runtime.lastQuestion.Values[1] != "beta" { + t.Fatalf("values = %#v, want [\"alpha\",\"beta\"]", runtime.lastQuestion.Values) + } + + app.pendingUserQuestion = &userQuestionPromptState{ + Request: agentruntime.UserQuestionRequestedPayload{ + RequestID: "ask-6", + Kind: "multi_choice", + MaxChoices: 2, + Options: []any{ + map[string]any{"label": "alpha"}, + map[string]any{"label": "beta"}, + map[string]any{"label": "gamma"}, + }, + }, + } + cmd, handled = app.submitPendingUserQuestionInput("1,2,3") + if !handled || cmd != nil { + t.Fatalf("expected over-limit multi_choice answer to be rejected, handled=%v cmd=%v", handled, cmd) + } + if app.state.StatusText != "multi_choice accepts up to 2 values" { + t.Fatalf("status text = %q", app.state.StatusText) + } +} + +func TestUpdateUserQuestionResolutionFinishedMessageAdditional(t *testing.T) { + app, _ := newTestApp(t) + app.pendingUserQuestion = &userQuestionPromptState{ + Request: agentruntime.UserQuestionRequestedPayload{RequestID: "ask-7"}, + Submitting: true, + } + + model, _ := app.Update(userQuestionResolutionFinishedMsg{ + RequestID: "ask-7", + Status: "answered", + Err: errors.New("network"), + }) + next := model.(App) + if next.pendingUserQuestion == nil || next.pendingUserQuestion.Submitting { + t.Fatalf("expected pending user question to remain while reset submitting after error") + } + if next.state.ExecutionError == "" { + t.Fatalf("expected execution error for failed ask_user submit") + } + + model, _ = next.Update(userQuestionResolutionFinishedMsg{ + RequestID: "ask-7", + Status: "answered", + }) + next = model.(App) + if next.pendingUserQuestion != nil { + t.Fatalf("expected pending user question to clear after success") + } + if next.state.StatusText != statusUserQuestionSubmitted { + t.Fatalf("status text = %q, want %q", next.state.StatusText, statusUserQuestionSubmitted) + } +} + +func TestPendingUserQuestionAllowsImmediateSlashCommands(t *testing.T) { + app, _ := newTestApp(t) + app.pendingUserQuestion = &userQuestionPromptState{ + Request: agentruntime.UserQuestionRequestedPayload{ + RequestID: "ask-help", + Kind: "text", + Required: true, + }, + } + app.input.SetValue("/help") + app.state.InputText = "/help" + + model, _ := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + next := model.(App) + + if next.pendingUserQuestion == nil || next.pendingUserQuestion.Request.RequestID != "ask-help" { + t.Fatalf("expected pending ask_user question to remain while running slash command") + } + if next.state.ActivePicker != pickerHelp { + t.Fatalf("expected /help to open help picker, active=%v", next.state.ActivePicker) + } +} diff --git a/internal/tui/core/app/user_question_prompt.go b/internal/tui/core/app/user_question_prompt.go index b403822b..9f17a77d 100644 --- a/internal/tui/core/app/user_question_prompt.go +++ b/internal/tui/core/app/user_question_prompt.go @@ -60,19 +60,53 @@ func formatUserQuestionPromptLines(state userQuestionPromptState) []string { fmt.Sprintf("Kind: %s", kind), fmt.Sprintf("Description: %s", description), } + lines = append(lines, fmt.Sprintf("Required: %s", map[bool]string{true: "yes", false: "no"}[request.Required])) + lines = append(lines, fmt.Sprintf("Allow skip: %s", map[bool]string{true: "yes", false: "no"}[request.AllowSkip])) if len(request.Options) > 0 { lines = append(lines, "Options:") for index, option := range request.Options { - lines = append(lines, fmt.Sprintf(" %d. %v", index+1, option)) + lines = append(lines, fmt.Sprintf(" %d. %s", index+1, formatUserQuestionOptionDisplay(option))) } } - lines = append(lines, "Type answer and press Enter. Use /skip to skip when allowed.") + switch kind { + case "single_choice": + lines = append(lines, "Input one option label or index and press Enter.") + case "multi_choice": + lines = append(lines, "Input comma-separated option labels or indices and press Enter.") + if request.MaxChoices > 0 { + lines = append(lines, fmt.Sprintf("Max choices: %d", request.MaxChoices)) + } + default: + lines = append(lines, "Type answer and press Enter.") + } + lines = append(lines, "Use /skip to skip when allowed.") if state.Submitting { lines = append(lines, "Submitting user question answer...") } return lines } +// formatUserQuestionOptionDisplay 统一格式化 ask_user 选项展示文本,避免 map 原始输出难以阅读。 +func formatUserQuestionOptionDisplay(option any) string { + switch typed := option.(type) { + case string: + label := strings.TrimSpace(typed) + if label != "" { + return label + } + case map[string]any: + label := strings.TrimSpace(anyToString(typed["label"])) + desc := strings.TrimSpace(anyToString(typed["description"])) + if label != "" && desc != "" { + return fmt.Sprintf("%s - %s", label, desc) + } + if label != "" { + return label + } + } + return strings.TrimSpace(fmt.Sprintf("%v", option)) +} + // renderUserQuestionPrompt 渲染 ask_user 输入框内容。 func (a App) renderUserQuestionPrompt() string { if a.pendingUserQuestion == nil { diff --git a/internal/tui/core/app/user_question_prompt_test.go b/internal/tui/core/app/user_question_prompt_test.go index a6507b9f..90541a80 100644 --- a/internal/tui/core/app/user_question_prompt_test.go +++ b/internal/tui/core/app/user_question_prompt_test.go @@ -165,3 +165,52 @@ func TestRuntimeUserQuestionEventHandlers(t *testing.T) { t.Fatalf("expected generic resolved branch, got title=%q status=%q", last.Title, app.state.StatusText) } } + +func TestFormatUserQuestionPromptLines(t *testing.T) { + t.Parallel() + + lines := formatUserQuestionPromptLines(userQuestionPromptState{ + Request: agentruntime.UserQuestionRequestedPayload{ + Title: "Pick options", + Kind: "multi_choice", + Description: "choose", + Required: true, + AllowSkip: true, + MaxChoices: 2, + Options: []any{ + map[string]any{"label": "alpha"}, + map[string]any{"label": "beta"}, + }, + }, + Submitting: true, + }) + + joined := "" + for _, line := range lines { + joined += line + "\n" + } + if !containsLine(lines, "Required: yes") { + t.Fatalf("expected Required hint in lines: %q", joined) + } + if !containsLine(lines, "Allow skip: yes") { + t.Fatalf("expected Allow skip hint in lines: %q", joined) + } + if !containsLine(lines, "Max choices: 2") { + t.Fatalf("expected Max choices hint in lines: %q", joined) + } + if !containsLine(lines, " 1. alpha") { + t.Fatalf("expected formatted option line in lines: %q", joined) + } + if !containsLine(lines, "Submitting user question answer...") { + t.Fatalf("expected submitting hint in lines: %q", joined) + } +} + +func containsLine(lines []string, target string) bool { + for _, line := range lines { + if line == target { + return true + } + } + return false +} diff --git a/web/src/api/gateway.ts b/web/src/api/gateway.ts index 17ecc0a2..59de4be9 100644 --- a/web/src/api/gateway.ts +++ b/web/src/api/gateway.ts @@ -9,6 +9,8 @@ import { type LoadSessionParams, type ListSessionTodosParams, type ListSessionTodosResult, + type GetRuntimeSnapshotParams, + type GetRuntimeSnapshotResult, type ListCheckpointsParams, type ListCheckpointsResult, type RestoreCheckpointParams, @@ -18,6 +20,7 @@ import { type CheckpointDiffParams, type CheckpointDiffResult, type ResolvePermissionParams, + type ResolveUserQuestionParams, type Session, type RunAckResult, type ListSessionsResult, @@ -119,6 +122,13 @@ export class GatewayAPI { return this.ws.call(Method.ListSessionTodos, { session_id: sessionId } satisfies ListSessionTodosParams) } + async getRuntimeSnapshot(sessionId: string) { + return this.ws.call( + Method.GetRuntimeSnapshot, + { session_id: sessionId } satisfies GetRuntimeSnapshotParams, + ) + } + async listCheckpoints(params: ListCheckpointsParams) { return this.ws.call(Method.ListCheckpoints, params) } @@ -140,6 +150,11 @@ export class GatewayAPI { return this.ws.call(Method.ResolvePermission, params) } + /** 提交 ask_user 回答 */ + async resolveUserQuestion(params: ResolveUserQuestionParams) { + return this.ws.call(Method.UserQuestionAnswer, params) + } + /** 执行系统工具 */ async executeSystemTool(sessionId: string, runId: string, toolName: string, args: any, workdir?: string) { return this.ws.call(Method.ExecuteSystemTool, { diff --git a/web/src/api/protocol.ts b/web/src/api/protocol.ts index e7be078c..fbe1011c 100644 --- a/web/src/api/protocol.ts +++ b/web/src/api/protocol.ts @@ -16,11 +16,13 @@ export const Method = { ListSessions: 'gateway.listSessions', LoadSession: 'gateway.loadSession', ListSessionTodos: 'session.todos.list', + GetRuntimeSnapshot: 'runtime.snapshot.get', ListCheckpoints: 'checkpoint.list', RestoreCheckpoint: 'checkpoint.restore', UndoRestore: 'checkpoint.undoRestore', CheckpointDiff: 'checkpoint.diff', ResolvePermission: 'gateway.resolvePermission', + UserQuestionAnswer: 'gateway.userQuestionAnswer', ExecuteSystemTool: 'gateway.executeSystemTool', ActivateSessionSkill: 'gateway.activateSessionSkill', DeactivateSessionSkill: 'gateway.deactivateSessionSkill', @@ -86,6 +88,10 @@ export const EventType = { Error: 'error', PermissionRequested: 'permission_requested', PermissionResolved: 'permission_resolved', + UserQuestionRequested: 'user_question_requested', + UserQuestionAnswered: 'user_question_answered', + UserQuestionSkipped: 'user_question_skipped', + UserQuestionTimeout: 'user_question_timeout', CompactStart: 'compact_start', CompactApplied: 'compact_applied', CompactError: 'compact_error', @@ -232,6 +238,10 @@ export interface ListSessionTodosParams { session_id: string } +export interface GetRuntimeSnapshotParams { + session_id: string +} + export interface ListCheckpointsParams { session_id: string limit?: number @@ -261,6 +271,14 @@ export interface ResolvePermissionParams { decision: string } +/** gateway.userQuestionAnswer 参数 */ +export interface ResolveUserQuestionParams { + request_id: string + status?: string + values?: string[] + message?: string +} + /** 会话摘要 */ export interface SessionSummary { id: string @@ -370,6 +388,32 @@ export interface TodoSnapshot { summary?: TodoSummary } +export interface PendingUserQuestionSnapshot { + request_id: string + question_id: string + title: string + description: string + kind: string + options?: unknown[] + required: boolean + allow_skip: boolean + max_choices?: number + timeout_sec?: number +} + +export interface RuntimeSnapshotPayload { + run_id?: string + session_id: string + phase?: string + task_kind?: string + updated_at: string + todos: TodoSnapshot + facts?: Record + decision?: Record + subagents?: Record + pending_user_question?: PendingUserQuestionSnapshot +} + export interface TodoEventPayload { action: string reason?: string @@ -378,6 +422,7 @@ export interface TodoEventPayload { } export type ListSessionTodosResult = RPCResult +export type GetRuntimeSnapshotResult = RPCResult export interface VerificationStartedPayload { completion_passed: boolean diff --git a/web/src/components/chat/ChatPanel.tsx b/web/src/components/chat/ChatPanel.tsx index e1e02659..845889ff 100644 --- a/web/src/components/chat/ChatPanel.tsx +++ b/web/src/components/chat/ChatPanel.tsx @@ -24,17 +24,23 @@ export default function ChatPanel() { const toggleFileTreePanel = useUIStore((s) => s.toggleFileTreePanel) const currentSessionId = useSessionStore((s) => s.currentSessionId) - const projects = useSessionStore((s) => s.projects) + const projects = useSessionStore((s) => s.projects) - const permissionRequests = useChatStore((s) => s.permissionRequests) - const agentMode = useChatStore((s) => s.agentMode) - const permissionMode = useChatStore((s) => s.permissionMode) - const currentPermission = permissionRequests[0] + const permissionRequests = useChatStore((s) => s.permissionRequests) + const agentMode = useChatStore((s) => s.agentMode) + const permissionMode = useChatStore((s) => s.permissionMode) + const currentPermission = permissionRequests[0] + const pendingUserQuestion = useChatStore((s) => s.pendingUserQuestion) + const clearPendingUserQuestion = useChatStore((s) => s.clearPendingUserQuestion) - const [editingTitle, setEditingTitle] = useState(false) - const [isResolvingPermission, setIsResolvingPermission] = useState(false) - const titleRef = useRef(null) - const autoResolvingPermissionIdsRef = useRef>(new Set()) + const [editingTitle, setEditingTitle] = useState(false) + const [isResolvingPermission, setIsResolvingPermission] = useState(false) + const [isResolvingUserQuestion, setIsResolvingUserQuestion] = useState(false) + const [userQuestionText, setUserQuestionText] = useState('') + const [userQuestionSingleChoice, setUserQuestionSingleChoice] = useState('') + const [userQuestionMultiChoices, setUserQuestionMultiChoices] = useState([]) + const titleRef = useRef(null) + const autoResolvingPermissionIdsRef = useRef>(new Set()) const currentSession = projects.flatMap((p) => p.sessions).find((s) => s.id === currentSessionId) const title = currentSession?.title || '新对话' @@ -54,6 +60,106 @@ export default function ChatPanel() { } } + function parseUserQuestionOptions(raw: unknown[]): { label: string; value: string; description?: string }[] { + const options: { label: string; value: string; description?: string }[] = [] + for (const option of raw) { + if (typeof option === 'string') { + const trimmed = option.trim() + if (trimmed) options.push({ label: trimmed, value: trimmed }) + continue + } + if (!option || typeof option !== 'object') continue + const record = option as Record + const label = typeof record.label === 'string' ? record.label.trim() : '' + const description = typeof record.description === 'string' ? record.description.trim() : '' + if (!label) continue + options.push({ label, value: label, description: description || undefined }) + } + return options + } + + async function handleSubmitUserQuestion(status: 'answered' | 'skipped') { + if (!gatewayAPI || !pendingUserQuestion || isResolvingUserQuestion) return + + const options = parseUserQuestionOptions(Array.isArray(pendingUserQuestion.options) ? pendingUserQuestion.options : []) + let values: string[] = [] + let message = '' + + if (status === 'answered') { + switch (pendingUserQuestion.kind) { + case 'text': { + const trimmed = userQuestionText.trim() + if (!trimmed) { + useUIStore.getState().showToast('Please enter an answer', 'info') + return + } + message = trimmed + values = [trimmed] + break + } + case 'single_choice': { + const selected = userQuestionSingleChoice.trim() + if (!selected) { + useUIStore.getState().showToast('Please select one option', 'info') + return + } + values = [selected] + break + } + case 'multi_choice': { + if (userQuestionMultiChoices.length === 0) { + useUIStore.getState().showToast('Please select at least one option', 'info') + return + } + const maxChoices = Number(pendingUserQuestion.max_choices || 0) + if (maxChoices > 0 && userQuestionMultiChoices.length > maxChoices) { + useUIStore.getState().showToast(`You can select up to ${maxChoices} option(s)`, 'info') + return + } + values = [...userQuestionMultiChoices] + break + } + default: { + if (options.length > 0 && !userQuestionSingleChoice.trim()) { + useUIStore.getState().showToast('Please provide an answer', 'info') + return + } + if (userQuestionSingleChoice.trim()) values = [userQuestionSingleChoice.trim()] + } + } + } + + setIsResolvingUserQuestion(true) + try { + await gatewayAPI.resolveUserQuestion({ + request_id: pendingUserQuestion.request_id, + status, + values: values.length > 0 ? values : undefined, + message: message || undefined, + }) + clearPendingUserQuestion(pendingUserQuestion.request_id) + useUIStore.getState().showToast('User question submitted', 'success') + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to submit user question' + useUIStore.getState().showToast(msg, 'error') + console.error('Resolve user question failed:', err) + } finally { + setIsResolvingUserQuestion(false) + } + } + + useEffect(() => { + if (!pendingUserQuestion) { + setUserQuestionText('') + setUserQuestionSingleChoice('') + setUserQuestionMultiChoices([]) + return + } + setUserQuestionText('') + setUserQuestionSingleChoice('') + setUserQuestionMultiChoices([]) + }, [pendingUserQuestion?.request_id]) + useEffect(() => { const activeRequestIds = new Set(permissionRequests.map((request) => request.request_id)) for (const requestId of Array.from(autoResolvingPermissionIdsRef.current)) { @@ -91,7 +197,10 @@ export default function ChatPanel() { const range = document.createRange() range.selectNodeContents(titleRef.current) const selection = window.getSelection() - if (selection) { selection.removeAllRanges(); selection.addRange(range) } + if (selection) { + selection.removeAllRanges() + selection.addRange(range) + } } }, [editingTitle]) @@ -101,14 +210,15 @@ export default function ChatPanel() { try { await gatewayAPI.renameSession(currentSessionId, newTitle) await useSessionStore.getState().fetchSessions(gatewayAPI) - } catch (err) { console.error('Rename session failed:', err) } + } catch (err) { + console.error('Rename session failed:', err) + } } setEditingTitle(false) } return (
- {/* Header */}
{editingTitle ? ( @@ -147,15 +257,12 @@ export default function ChatPanel() {
- {/* Messages */}
- {/* Todo strip */} - {/* Input or Permission Request */} {currentPermission && !(agentMode === 'build' && permissionMode === 'bypass') ? (
@@ -201,6 +308,90 @@ export default function ChatPanel() {
+ ) : pendingUserQuestion ? ( +
+
+
+ + {pendingUserQuestion.title || 'User question'} +
+ {pendingUserQuestion.description && ( +
+ {pendingUserQuestion.description} +
+ )} + {pendingUserQuestion.kind === 'text' && ( +