From 964d93e704a42821869e2f12728723b556e25269 Mon Sep 17 00:00:00 2001 From: Cai_Tang <106404101+Cai-Tang-www@users.noreply.github.com> Date: Sat, 9 May 2026 02:05:10 +0800 Subject: [PATCH 1/8] feat: unify ask_user flow for web and feishu adapters --- docs/gateway-rpc-api.md | 29 + docs/guides/feishu-adapter.md | 19 +- internal/cli/feishu_adapter_command_test.go | 9 + internal/cli/gateway_runtime_bridge.go | 22 + internal/feishuadapter/adapter.go | 579 +++++++++++++++++- internal/feishuadapter/adapter_test.go | 221 ++++++- internal/feishuadapter/gateway_client.go | 17 + internal/feishuadapter/gateway_client_test.go | 4 + internal/feishuadapter/ingress.go | 10 +- internal/feishuadapter/messenger.go | 171 +++++- internal/feishuadapter/messenger_test.go | 73 +++ internal/feishuadapter/sdk_ingress.go | 30 +- internal/feishuadapter/sdk_ingress_test.go | 19 + internal/feishuadapter/types.go | 28 + internal/feishuadapter/webhook_ingress.go | 38 +- internal/gateway/contracts.go | 33 +- internal/runtime/ask_user_snapshot.go | 126 ++++ internal/runtime/ask_user_snapshot_test.go | 89 +++ internal/runtime/permission.go | 12 +- internal/runtime/runtime_snapshot.go | 15 + internal/runtime/state.go | 1 + web/src/api/gateway.ts | 15 + web/src/api/protocol.ts | 45 ++ web/src/components/chat/ChatPanel.tsx | 223 ++++++- web/src/context/RuntimeProvider.tsx | 22 + web/src/stores/useChatStore.ts | 31 +- web/src/stores/useSessionStore.ts | 13 +- web/src/utils/eventBridge.test.ts | 59 ++ web/src/utils/eventBridge.ts | 45 ++ www/guide/web-ui.md | 18 + 30 files changed, 1939 insertions(+), 77 deletions(-) create mode 100644 internal/runtime/ask_user_snapshot.go create mode 100644 internal/runtime/ask_user_snapshot_test.go 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 b4547dad..70adf98e 100644 --- a/internal/cli/gateway_runtime_bridge.go +++ b/internal/cli/gateway_runtime_bridge.go @@ -1094,6 +1094,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/feishuadapter/adapter.go b/internal/feishuadapter/adapter.go index b54f8472..6206b9b7 100644 --- a/internal/feishuadapter/adapter.go +++ b/internal/feishuadapter/adapter.go @@ -26,6 +26,16 @@ 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 +} + type sessionBinding struct { SessionID string ChatID string @@ -51,12 +61,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 +86,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 +181,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 +202,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 +229,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 +318,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 +392,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 +629,90 @@ 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, + } + 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 +856,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 +869,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 +885,195 @@ 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 + } + selected := normalizeChoiceToken(trimmedAnswer) + for _, option := range question.Options { + label := strings.TrimSpace(option.Label) + if normalizeChoiceToken(label) == selected { + return []string{label}, "", true + } + } + return []string{trimmedAnswer}, "", 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 { + normalized := normalizeChoiceToken(token) + matched := "" + for _, option := range question.Options { + label := strings.TrimSpace(option.Label) + if normalizeChoiceToken(label) == normalized { + matched = label + break + } + } + if matched == "" { + matched = token + } + selected = append(selected, matched) + } + return uniqueNonEmptyStrings(selected), "", true + default: + if trimmedAnswer == "" { + return nil, "", false + } + return []string{trimmedAnswer}, trimmedAnswer, true + } +} + +// 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 +1109,179 @@ 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"), + } + 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 +} + // extractHookNotificationSummary 提取 async_rewake 等通知摘要并写入卡片,便于下轮继续追踪。 func extractHookNotificationSummary(envelope map[string]any) string { payload, _ := envelope["payload"].(map[string]any) @@ -914,6 +1434,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..eb113857 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"}}` @@ -974,6 +1151,36 @@ 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 TestTryHandleTextPermissionHandlesEmptyAndUnknownCommands(t *testing.T) { adapter := newTestAdapter(t) for _, testCase := range []struct { 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..8b3b60bb 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) { 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..b85b5319 100644 --- a/internal/feishuadapter/messenger_test.go +++ b/internal/feishuadapter/messenger_test.go @@ -320,3 +320,76 @@ 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"]) + } +} diff --git a/internal/feishuadapter/sdk_ingress.go b/internal/feishuadapter/sdk_ingress.go index 6e4b188a..0c3eec0a 100644 --- a/internal/feishuadapter/sdk_ingress.go +++ b/internal/feishuadapter/sdk_ingress.go @@ -116,16 +116,34 @@ 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 + 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..63449f52 100644 --- a/internal/feishuadapter/sdk_ingress_test.go +++ b/internal/feishuadapter/sdk_ingress_test.go @@ -327,7 +327,26 @@ 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) + } +} 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..242c2df7 100644 --- a/internal/feishuadapter/webhook_ingress.go +++ b/internal/feishuadapter/webhook_ingress.go @@ -146,21 +146,45 @@ 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" + } + } + if requestID == "" { 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 5629ea7c..d4edb51b 100644 --- a/internal/gateway/contracts.go +++ b/internal/gateway/contracts.go @@ -593,17 +593,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/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..2f1dcc90 --- /dev/null +++ b/internal/runtime/ask_user_snapshot_test.go @@ -0,0 +1,89 @@ +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) + } +} diff --git a/internal/runtime/permission.go b/internal/runtime/permission.go index c41bdc6f..710db027 100644 --- a/internal/runtime/permission.go +++ b/internal/runtime/permission.go @@ -111,8 +111,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 +147,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) } } diff --git a/internal/runtime/runtime_snapshot.go b/internal/runtime/runtime_snapshot.go index 9b888459..493d2ac8 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 a27fcb52..534a706d 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 } // newRunState 基于持久化会话创建一次运行的内存状态镜像。 diff --git a/web/src/api/gateway.ts b/web/src/api/gateway.ts index 63755096..fe4e12c3 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, @@ -113,6 +116,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) } @@ -134,6 +144,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 150a556f..6b82b839 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', @@ -83,6 +85,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', @@ -229,6 +235,10 @@ export interface ListSessionTodosParams { session_id: string } +export interface GetRuntimeSnapshotParams { + session_id: string +} + export interface ListCheckpointsParams { session_id: string limit?: number @@ -256,6 +266,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 @@ -365,6 +383,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 @@ -373,6 +417,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 a7bb08a7..a0bcff53 100644 --- a/web/src/components/chat/ChatPanel.tsx +++ b/web/src/components/chat/ChatPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, type CSSProperties } from 'react' import { useUIStore } from '@/stores/useUIStore' import { useSessionStore } from '@/stores/useSessionStore' import { useChatStore } from '@/stores/useChatStore' @@ -34,10 +34,16 @@ export default function ChatPanel() { const permissionRequests = useChatStore((s) => s.permissionRequests) const currentPermission = permissionRequests[0] + const pendingUserQuestion = useChatStore((s) => s.pendingUserQuestion) + const clearPendingUserQuestion = useChatStore((s) => s.clearPendingUserQuestion) const [editingTitle, setEditingTitle] = useState(false) const [moreMenuOpen, setMoreMenuOpen] = 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 moreMenuRef = useRef(null) @@ -63,6 +69,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(() => { function handleClick(event: MouseEvent) { if (moreMenuRef.current && !moreMenuRef.current.contains(event.target as Node)) { @@ -252,6 +358,88 @@ export default function ChatPanel() { + ) : pendingUserQuestion ? ( +
+
+
+ + {pendingUserQuestion.title || 'User question'} +
+ {pendingUserQuestion.description && ( +
+ {pendingUserQuestion.description} +
+ )} + {pendingUserQuestion.kind === 'text' && ( +