diff --git a/crates/agent-gateway/internal/kbrain/client.go b/crates/agent-gateway/internal/kbrain/client.go index 4e4344631..2e9c1334f 100644 --- a/crates/agent-gateway/internal/kbrain/client.go +++ b/crates/agent-gateway/internal/kbrain/client.go @@ -146,6 +146,11 @@ type ToolCall struct { ID string `json:"id"` Name string `json:"name"` Arguments json.RawMessage `json:"arguments,omitempty"` + // ExitCode, TimedOut and DurationMs describe how the call ran (history only; + // a shell tool's exit status, 0 included). Display only. + ExitCode *int `json:"exit_code,omitempty"` + TimedOut bool `json:"timed_out,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` } type Usage struct { diff --git a/crates/agent-gateway/internal/kbrain/events.go b/crates/agent-gateway/internal/kbrain/events.go index 9f50fe681..b1a9661c4 100644 --- a/crates/agent-gateway/internal/kbrain/events.go +++ b/crates/agent-gateway/internal/kbrain/events.go @@ -12,6 +12,12 @@ import ( // event vocabulary. Provider-specific response/chat-message fields never cross // this boundary. func ToChatEvent(event Event) (*v2.ChatEvent, *v2.ChatControlEvent, error) { + return toChatEvent(event, nil) +} + +// toChatEvent is ToChatEvent with the recorded arguments of a tool call, which +// the question tool's plain-text result is read against. +func toChatEvent(event Event, callArgs func(id string) map[string]any) (*v2.ChatEvent, *v2.ChatControlEvent, error) { payload := map[string]any{} if len(event.Payload) != 0 && string(event.Payload) != "null" { if err := json.Unmarshal(event.Payload, &payload); err != nil { @@ -46,11 +52,25 @@ func ToChatEvent(event Event) (*v2.ChatEvent, *v2.ChatControlEvent, error) { case "tool.result": base.Type = v2.ChatEvent_TOOL_RESULT if result, ok := payload["tool_result"].(map[string]any); ok { - payload["id"], payload["name"] = result["id"], result["name"] - payload["isError"] = result["failed"] == true || result["cancelled"] == true name, _ := result["name"].(string) + id, _ := result["id"].(string) + isError := result["failed"] == true || result["cancelled"] == true + payload["id"], payload["name"] = result["id"], toolResultName(name) + payload["isError"] = isError output, _ := result["output"].(string) - if details := questionResultDetails(name, output); details != nil { + var args map[string]any + if callArgs != nil && id != "" { + args = callArgs(id) + } + details := resultDetails(name, output, args, isError) + var exitCode *int + if code, ok := result["exit_code"].(float64); ok { + value := int(code) + exitCode = &value + } + timedOut := result["timed_out"] == true + duration, _ := result["duration_ms"].(float64) + if details = withRunInfo(name, details, exitCode, timedOut, int64(duration)); details != nil { payload["details"] = details } content := []any{map[string]any{"type": "text", "text": result["output"]}} diff --git a/crates/agent-gateway/internal/kbrain/history.go b/crates/agent-gateway/internal/kbrain/history.go index 165cf0c63..a3e80a25d 100644 --- a/crates/agent-gateway/internal/kbrain/history.go +++ b/crates/agent-gateway/internal/kbrain/history.go @@ -338,6 +338,14 @@ func isBackgroundNotice(message Message) bool { // WebUI can match search hits, which report whole-session offsets. func historyMessagesJSON(messages []Message, firstOffset int32) (string, error) { out := make([]map[string]any, 0, len(messages)) + // A tool result is read against its call: the question tool's text answers + // against the questions asked, and run info sits on the call. + calls := map[string]ToolCall{} + for _, message := range messages { + for _, call := range message.ToolCalls { + calls[call.ID] = call + } + } for i, message := range messages { if message.Role != "user" && message.Role != "assistant" && message.Role != "tool" { continue @@ -366,9 +374,19 @@ func historyMessagesJSON(messages []Message, firstOffset int32) (string, error) entry["liveAgentBackgroundNotice"] = true } if message.Role == "tool" { - entry["role"], entry["toolCallId"], entry["toolName"] = "toolResult", message.ToolCallID, message.Name - entry["isError"] = message.StopReason == "error" || message.StopReason == "cancelled" - if details := questionResultDetails(message.Name, historyText(message)); details != nil { + isError := message.StopReason == "error" || message.StopReason == "cancelled" + entry["role"], entry["toolCallId"], entry["toolName"] = "toolResult", message.ToolCallID, toolResultName(message.Name) + entry["isError"] = isError + call, known := calls[message.ToolCallID] + var args map[string]any + if known && len(call.Arguments) > 0 { + _ = json.Unmarshal(call.Arguments, &args) + } + details := resultDetails(message.Name, historyText(message), args, isError) + if known { + details = withRunInfo(message.Name, details, call.ExitCode, call.TimedOut, call.DurationMs) + } + if details != nil { entry["details"] = details } } @@ -388,7 +406,16 @@ func historyMessagesJSON(messages []Message, firstOffset int32) (string, error) return "", fmt.Errorf("invalid history tool arguments at %d: %w", i, err) } } - content = append(content, map[string]any{"type": "toolCall", "id": call.ID, "name": call.Name, "arguments": args}) + name := call.Name + if name == kbrainQuestionTool { + if record, ok := args.(map[string]any); ok { + args = normalizeQuestionToolArgs(record) + } else { + args = normalizeQuestionToolArgs(map[string]any{}) + } + name = askUserQuestionTool + } + content = append(content, map[string]any{"type": "toolCall", "id": call.ID, "name": name, "arguments": args}) } entry["content"], entry["api"], entry["provider"], entry["model"] = content, protocolVersion, message.Provider, message.Model stop := message.StopReason diff --git a/crates/agent-gateway/internal/kbrain/interactive.go b/crates/agent-gateway/internal/kbrain/interactive.go index a1448ac3a..e1e1e1d83 100644 --- a/crates/agent-gateway/internal/kbrain/interactive.go +++ b/crates/agent-gateway/internal/kbrain/interactive.go @@ -30,6 +30,7 @@ const ( argApprovalRules = "__toolApprovalRememberRules" argApprovalDefault = "__toolApprovalDefaultScope" argApprovalNotice = "__toolApprovalNotice" + argApprovalPreview = "__toolApprovalPreview" argAutoResolved = "__toolPermissionAutoResolved" argQuestionPending = "__askUserQuestionPending" argQuestionDeadline = "__askUserQuestionDeadlineAt" @@ -42,6 +43,8 @@ const ( approvalTextMax = 2000 approvalScopeMax = 300 + // approvalPreviewMax bounds the synced diff, as K-brain bounds its own (32 KiB). + approvalPreviewMax = 32 * 1024 ) var syntheticArgPrefixes = []string{"__toolApproval", "__toolPermission", "__askUserQuestion", "__exitPlanMode"} @@ -279,7 +282,7 @@ func (r *Relay) relayRunEvent(ctx context.Context, event Event, conversationID s } mapped := event mapped.ConversationID = conversationID - chat, control, err := ToChatEvent(mapped) + chat, control, err := toChatEvent(mapped, func(id string) map[string]any { return s.callArguments(conversationID, id) }) if err != nil { return nil, err } @@ -304,6 +307,13 @@ type permissionRequest struct { } `json:"suggestion"` Scope string `json:"scope"` ToolCallID string `json:"tool_call_id"` + // Preview is the change a file tool will make, as diff rows. + Preview *struct { + Path string `json:"path"` + Diff string `json:"diff"` + NewFile bool `json:"new_file"` + Truncated bool `json:"truncated"` + } `json:"preview"` } type questionRequest struct { @@ -329,6 +339,10 @@ func (s *interactiveState) recordCall(conversationID, runID string, call ToolCal delete(args, key) } } + if call.Name == kbrainQuestionTool { + // K-brain's question tool is answered on the AskUserQuestion card. + call.Name, args = askUserQuestionTool, normalizeQuestionToolArgs(args) + } key := interactionKey(conversationID, call.ID) s.mu.Lock() defer s.mu.Unlock() @@ -348,6 +362,21 @@ func (s *interactiveState) recordCall(conversationID, runID string, call ToolCal return s.callEventLocked(conversationID, call.ID) } +// callArguments is a copy of a recorded call's arguments; nil when unknown. +func (s *interactiveState) callArguments(conversationID, toolCallID string) map[string]any { + s.mu.Lock() + defer s.mu.Unlock() + call := s.calls[interactionKey(conversationID, toolCallID)] + if call == nil { + return nil + } + args := make(map[string]any, len(call.arguments)) + for key, value := range call.arguments { + args[key] = value + } + return args +} + func (s *interactiveState) settleCall(conversationID, toolCallID string) { s.mu.Lock() defer s.mu.Unlock() @@ -432,6 +461,14 @@ func (s *interactiveState) requestPermission(conversationID, sessionID, runID st } else if request.Rule != "" { overlay[argApprovalScope] = clampText(request.Rule, approvalTextMax) } + if preview := request.Preview; preview != nil && (preview.Diff != "" || preview.Truncated) { + // WebUI shows K-brain's diff rows instead of diffing the call's arguments. + diff, cut := clampDiffRows(preview.Diff, approvalPreviewMax) + overlay[argApprovalPreview] = map[string]any{ + "path": preview.Path, "before": "", "after": "", "diff": diff, + "newFile": preview.NewFile, "truncated": preview.Truncated || cut, + } + } s.pending[key] = &pendingInteraction{ kind: "permission", conversationID: conversationID, sessionID: sessionID, runID: runID, requestID: request.PermissionID, toolCallID: toolCallID, synthetic: synthetic, @@ -656,10 +693,11 @@ func (r *Relay) answerQuestion(ctx context.Context, conversationID, toolCallID, return queueError("not_found", "This question is no longer pending.") } var answers []struct { - QuestionID string `json:"questionId"` - Prompt string `json:"prompt"` - SelectedLabel string `json:"selectedLabel"` - Custom bool `json:"custom"` + QuestionID string `json:"questionId"` + Prompt string `json:"prompt"` + SelectedLabel string `json:"selectedLabel"` + SelectedLabels []string `json:"selectedLabels"` + Custom bool `json:"custom"` } if err := json.Unmarshal([]byte(requestJSON), &answers); err != nil || len(answers) == 0 { return queueError("invalid_request", "question answers are invalid") @@ -670,7 +708,9 @@ func (r *Relay) answerQuestion(ctx context.Context, conversationID, toolCallID, if answer.Prompt != "" { item["prompt"] = answer.Prompt } - if answer.Custom { + // K-brain takes one label per answer: several picks of a multi-select + // question arrive as their joined labels, which it only accepts as custom. + if answer.Custom || len(answer.SelectedLabels) > 1 { item["custom"] = true } out = append(out, item) @@ -831,6 +871,21 @@ func nonEmpty(values ...string) []string { return out } +// clampDiffRows cuts diff rows to max bytes at a row boundary. +func clampDiffRows(diff string, max int) (string, bool) { + if len(diff) <= max { + return diff, false + } + end := strings.LastIndexByte(diff[:max], '\n') + if end <= 0 { + end = max + for end > 0 && !utf8.RuneStart(diff[end]) { + end-- + } + } + return diff[:end], true +} + func clampText(text string, max int) string { if utf8.RuneCountInString(text) <= max { return text @@ -857,6 +912,15 @@ func clampRules(rules []string, max int) []string { return out } +// resultDetails is the question card's details for a question or +// AskUserQuestion result; nil for other tools. +func resultDetails(name, output string, args map[string]any, isError bool) map[string]any { + if name == kbrainQuestionTool { + return questionToolResultDetails(args, output, isError) + } + return questionResultDetails(name, output) +} + // questionResultDetails turns K-brain's AskUserQuestion result text into the // tool-result details the WebUI question card reads to show the chosen answers // (the desktop does the same in crates/agent-gui/src/lib/kbrain/questions.ts). diff --git a/crates/agent-gateway/internal/kbrain/question_tool.go b/crates/agent-gateway/internal/kbrain/question_tool.go new file mode 100644 index 000000000..fba3f056a --- /dev/null +++ b/crates/agent-gateway/internal/kbrain/question_tool.go @@ -0,0 +1,246 @@ +package kbrain + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +// K-brain's own question tool (Stack-Cairn/K-brain#162). Its call shows as the +// AskUserQuestion card and its plain-text result is read back into the card's +// answers, as the desktop does in crates/agent-ui/src/lib/chat/askUserQuestion.ts. +const ( + kbrainQuestionTool = "question" + // kbrainRunDetailsKey carries how a shell tool ran (exit code, timeout, + // duration) in tool-result details; crates/agent-ui/src/lib/chat/toolRunInfo.ts + // reads it. + kbrainRunDetailsKey = "kbrainRun" + questionNoAnswer = "(no answer)" +) + +var ( + questionHeadingPattern = regexp.MustCompile(`^(\d+)\. `) + questionAnswerPattern = regexp.MustCompile(`^ {3}→ ?(.*)$`) + questionCancelPattern = regexp.MustCompile(`(?i)dismissed the question|stopped the turn|cancel`) +) + +// toolResultName is the tool name the transcript shows for a K-brain result. +func toolResultName(name string) string { + if name == kbrainQuestionTool { + return askUserQuestionTool + } + return name +} + +// normalizeQuestionToolArgs turns question arguments, in either of the tool's +// forms (question/options/multiple, or questions[]), into AskUserQuestion's +// questions[] with ids q1, q2, … where none is given and the text as prompt. +func normalizeQuestionToolArgs(args map[string]any) map[string]any { + out := map[string]any{} + for key, value := range args { + if strings.HasPrefix(key, "__") { + out[key] = value + } + } + raw, _ := args["questions"].([]any) + if len(raw) == 0 { + raw = []any{map[string]any{"question": args["question"], "options": args["options"], "multiple": args["multiple"]}} + } + questions := make([]any, 0, len(raw)) + for index, value := range raw { + question, ok := value.(map[string]any) + if !ok { + questions = append(questions, value) + continue + } + item := make(map[string]any, len(question)+1) + for key, field := range question { + if key != "question" { + item[key] = field + } + } + if id := trimmedString(question["id"]); id != "" { + item["id"] = id + } else { + item["id"] = fmt.Sprintf("q%d", index+1) + } + prompt := trimmedString(question["question"]) + if prompt == "" { + prompt = trimmedString(question["prompt"]) + } + item["prompt"] = prompt + questions = append(questions, item) + } + out["questions"] = questions + return out +} + +func trimmedString(value any) string { + text, _ := value.(string) + return strings.TrimSpace(text) +} + +type questionItem struct { + id string + prompt string + labels map[string]bool + multiple bool +} + +func questionItems(raw any) []questionItem { + list, _ := raw.([]any) + out := make([]questionItem, 0, len(list)) + for _, value := range list { + question, ok := value.(map[string]any) + if !ok { + continue + } + item := questionItem{id: trimmedString(question["id"]), prompt: trimmedString(question["prompt"]), labels: map[string]bool{}} + if item.prompt == "" { + item.prompt = trimmedString(question["question"]) + } + item.multiple, _ = question["multiple"].(bool) + options, _ := question["options"].([]any) + for _, option := range options { + if record, ok := option.(map[string]any); ok { + if label := trimmedString(record["label"]); label != "" { + item.labels[label] = true + } + } + } + if item.id == "" || item.prompt == "" || len(item.labels) == 0 { + continue + } + out = append(out, item) + } + return out +} + +// questionAnswerTexts reads each question's answer from the question tool's +// result, in both of K-brain's spellings (tools.AskAnswerText); nil when the +// text is neither. +func questionAnswerTexts(questions []questionItem, output string) []string { + text := strings.TrimSpace(output) + const single = "User answered " + if strings.HasPrefix(text, single) { + prompt := "" + if len(questions) > 0 { + prompt = questions[0].prompt + } + exact := single + "\"" + prompt + "\": " + start := len(exact) + if !strings.HasPrefix(text, exact) { + index := strings.Index(text, "\": ") + if index < 0 { + return nil + } + start = index + 3 + } + return []string{strings.TrimSpace(text[start:])} + } + if !strings.HasPrefix(text, "The user answered:") { + return nil + } + answers := make([]string, len(questions)) + current, inAnswer := -1, false + lines := strings.Split(text, "\n") + for _, line := range lines[1:] { + heading := questionHeadingPattern.FindStringSubmatch(line) + if heading != nil && !inAnswer { + current, _ = strconv.Atoi(heading[1]) + current-- + continue + } + if answer := questionAnswerPattern.FindStringSubmatch(line); answer != nil && current >= 0 && current < len(answers) { + answers[current] = answer[1] + inAnswer = true + continue + } + if heading != nil { + current, _ = strconv.Atoi(heading[1]) + current-- + inAnswer = false + continue + } + if inAnswer && current >= 0 && current < len(answers) { + answers[current] += "\n" + line + } + } + for i, answer := range answers { + answer = strings.TrimSpace(answer) + if answer == questionNoAnswer { + answer = "" + } + answers[i] = answer + } + return answers +} + +// questionToolResultDetails is the settled question card for a question tool +// result. A dismissed question fails the tool and shows as cancelled; no answer +// to any question (the deadline passed) shows as timed out. WebUI splits a +// multi-select answer's joined labels back into options. +func questionToolResultDetails(args map[string]any, output string, isError bool) map[string]any { + // The recorded call is already normalized; history holds the model's own form. + rawQuestions := normalizeQuestionToolArgs(args)["questions"] + questions := questionItems(rawQuestions) + if len(questions) == 0 { + return nil + } + if isError { + if !questionCancelPattern.MatchString(output) { + return nil + } + return map[string]any{"kind": "ask_user_question", "questions": rawQuestions, "answers": []any{}, "cancelled": true} + } + texts := questionAnswerTexts(questions, output) + if texts == nil { + return nil + } + answers := []any{} + for i, question := range questions { + if i >= len(texts) || texts[i] == "" { + continue + } + item := map[string]any{"questionId": question.id, "prompt": question.prompt, "selectedLabel": texts[i]} + if !question.multiple && !question.labels[texts[i]] { + item["custom"] = true + } + answers = append(answers, item) + } + details := map[string]any{"kind": "ask_user_question", "questions": rawQuestions, "answers": answers} + if len(answers) == 0 { + details["timedOut"] = true + } + return details +} + +// shellRunTools report an exit status; only their results carry run info. +var shellRunTools = map[string]bool{"Bash": true, "bash": true, "ManagedProcess": true, "ProcessWait": true, "ProcessStop": true} + +// withRunInfo adds kbrainRun ({exitCode?, timedOut?, durationMs?}) to a shell +// tool's result details from K-brain's exit_code / timed_out / duration_ms. +func withRunInfo(name string, details map[string]any, exitCode *int, timedOut bool, durationMs int64) map[string]any { + if !shellRunTools[name] { + return details + } + info := map[string]any{} + if exitCode != nil { + info["exitCode"] = *exitCode + } + if timedOut { + info["timedOut"] = true + } + if durationMs > 0 { + info["durationMs"] = durationMs + } + if len(info) == 0 { + return details + } + if details == nil { + details = map[string]any{} + } + details[kbrainRunDetailsKey] = info + return details +} diff --git a/crates/agent-gateway/internal/kbrain/question_tool_test.go b/crates/agent-gateway/internal/kbrain/question_tool_test.go new file mode 100644 index 000000000..467c33134 --- /dev/null +++ b/crates/agent-gateway/internal/kbrain/question_tool_test.go @@ -0,0 +1,160 @@ +package kbrain + +import ( + "encoding/json" + "strings" + "testing" + + gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2" +) + +const multiQuestionArgs = `{"questions":[{"header":"Stack","question":"Which stack?","options":[{"label":"Go"},{"label":"Rust"}]},{"header":"Extras","question":"Add what?","multiple":true,"options":[{"label":"Docs"},{"label":"Tests"},{"label":"CI"}]}]}` + +func TestQuestionToolCallShowsAsMultiQuestionCard(t *testing.T) { + f := newInteractiveFixture(t) + f.send("tool.call", `{"tool_call":{"id":"ask-1","name":"question","arguments":`+multiQuestionArgs+`}}`) + events := f.take() + if len(events) != 1 || events[0].data["name"] != askUserQuestionTool || events[0].args()[argQuestionPending] != false { + t.Fatalf("question call = %+v", events) + } + questions, _ := events[0].args()["questions"].([]any) + if len(questions) != 2 { + t.Fatalf("questions = %+v", events[0].args()) + } + second, _ := questions[1].(map[string]any) + if second["id"] != "q2" || second["header"] != "Extras" || second["prompt"] != "Add what?" || second["multiple"] != true { + t.Fatalf("second question = %+v", second) + } + + f.send("question.requested", `{"question_id":"question-1","tool_call_id":"ask-1","run_id":"kb-run","questions":[{"id":"q1","header":"Stack","prompt":"Which stack?","options":[{"label":"Go"},{"label":"Rust"}]},{"id":"q2","header":"Extras","prompt":"Add what?","multiple":true,"options":[{"label":"Docs"},{"label":"Tests"},{"label":"CI"}]}]}`) + f.take() + answers := `[{"questionId":"q1","prompt":"Which stack?","selectedLabel":"Go"},{"questionId":"q2","prompt":"Add what?","selectedLabel":"Docs, CI","selectedLabels":["Docs","CI"]},{"questionId":"q3","selectedLabel":"Tests","selectedLabels":["Tests"]}]` + if response := f.queue("tool_answer", "ask-1", answers); !response.Accepted { + t.Fatalf("answer = %+v", response) + } + posts := f.postsTo("/v1/sessions/kb-session/questions/question-1") + sent, _ := posts[0]["answers"].([]any) + if len(sent) != 3 { + t.Fatalf("answers = %+v", posts) + } + single, multi, one := sent[0].(map[string]any), sent[1].(map[string]any), sent[2].(map[string]any) + if single["custom"] != nil || multi["selected_label"] != "Docs, CI" || multi["custom"] != true || one["custom"] != nil { + t.Fatalf("multi-select answers must go as joined custom labels: %+v", sent) + } + + f.send("question.resolved", `{"question_id":"question-1","tool_call_id":"ask-1"}`) + f.take() + result := "The user answered:\n1. Which stack?\n → Go\n2. Add what?\n → Docs, CI" + raw, _ := json.Marshal(map[string]any{"tool_result": map[string]any{"id": "ask-1", "name": "question", "output": result}}) + f.send("tool.result", string(raw)) + events = f.take() + if len(events) != 1 || events[0].kind != gatewayv2.ChatEvent_TOOL_RESULT || events[0].data["name"] != askUserQuestionTool { + t.Fatalf("result = %+v", events) + } + details, _ := events[0].data["details"].(map[string]any) + got, _ := details["answers"].([]any) + if details["kind"] != "ask_user_question" || len(got) != 2 { + t.Fatalf("details = %+v", events[0].data["details"]) + } + if first := got[0].(map[string]any); first["questionId"] != "q1" || first["selectedLabel"] != "Go" || first["custom"] != nil { + t.Fatalf("first answer = %+v", first) + } + if second := got[1].(map[string]any); second["questionId"] != "q2" || second["selectedLabel"] != "Docs, CI" { + t.Fatalf("second answer = %+v", second) + } +} + +func TestQuestionToolResultText(t *testing.T) { + args := normalizeQuestionToolArgs(map[string]any{"question": "Proceed?", "options": []any{map[string]any{"label": "Yes"}, map[string]any{"label": "No"}}}) + details := questionToolResultDetails(args, `User answered "Proceed?": Maybe later`, false) + answers, _ := details["answers"].([]any) + if len(answers) != 1 || answers[0].(map[string]any)["selectedLabel"] != "Maybe later" || answers[0].(map[string]any)["custom"] != true { + t.Fatalf("single form = %+v", details) + } + var multi map[string]any + _ = json.Unmarshal([]byte(multiQuestionArgs), &multi) + timedOut := questionToolResultDetails(multi, "The user answered:\n1. Which stack?\n → (no answer)\n2. Add what?\n → (no answer)", false) + if timedOut["timedOut"] != true { + t.Fatalf("no answers = %+v", timedOut) + } + if cancelled := questionToolResultDetails(multi, "the user dismissed the question", true); cancelled["cancelled"] != true { + t.Fatalf("dismissed = %+v", cancelled) + } + if questionToolResultDetails(multi, "boom", true) != nil || questionToolResultDetails(multi, "unrelated", false) != nil { + t.Fatal("other results carry no question details") + } +} + +func TestHistoryReplaysQuestionToolAndRunInfo(t *testing.T) { + exit := 2 + messages := []Message{ + {Role: "assistant", ToolCalls: []ToolCall{ + {ID: "ask-1", Name: "question", Arguments: json.RawMessage(multiQuestionArgs)}, + {ID: "sh-1", Name: "Bash", Arguments: json.RawMessage(`{"command":"false"}`), ExitCode: &exit, DurationMs: 1234}, + }}, + {Role: "tool", ToolCallID: "ask-1", Name: "question", Content: []ContentBlock{{Type: "text", Text: "The user answered:\n1. Which stack?\n → Rust\n2. Add what?\n → Tests"}}}, + {Role: "tool", ToolCallID: "sh-1", Name: "Bash", StopReason: "error", Content: []ContentBlock{{Type: "text", Text: "exit status 2"}}}, + } + raw, err := historyMessagesJSON(messages, 0) + if err != nil { + t.Fatal(err) + } + var entries []map[string]any + if err := json.Unmarshal([]byte(raw), &entries); err != nil { + t.Fatal(err) + } + call := entries[0]["content"].([]any)[0].(map[string]any) + questions, _ := call["arguments"].(map[string]any)["questions"].([]any) + if call["name"] != askUserQuestionTool || len(questions) != 2 || questions[0].(map[string]any)["prompt"] != "Which stack?" { + t.Fatalf("question call = %+v", call) + } + if entries[1]["toolName"] != askUserQuestionTool || !strings.Contains(raw, `"selectedLabel":"Rust"`) { + t.Fatalf("question result = %+v", entries[1]) + } + run, _ := entries[2]["details"].(map[string]any)[kbrainRunDetailsKey].(map[string]any) + if run["exitCode"] != float64(2) || run["durationMs"] != float64(1234) || run["timedOut"] != nil { + t.Fatalf("run info = %+v", entries[2]["details"]) + } +} + +func TestShellToolResultCarriesRunInfo(t *testing.T) { + event, _, err := ToChatEvent(Event{Type: "tool.result", Payload: json.RawMessage(`{"tool_result":{"id":"sh","name":"Bash","output":"killed","failed":true,"timed_out":true,"duration_ms":30000}}`)}) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + _ = json.Unmarshal([]byte(event.GetData()), &payload) + run, _ := payload["details"].(map[string]any)[kbrainRunDetailsKey].(map[string]any) + if run["timedOut"] != true || run["durationMs"] != float64(30000) || run["exitCode"] != nil { + t.Fatalf("run info = %+v", payload) + } + event, _, _ = ToChatEvent(Event{Type: "tool.result", Payload: json.RawMessage(`{"tool_result":{"id":"rd","name":"Read","output":"x","duration_ms":12}}`)}) + if strings.Contains(event.GetData(), kbrainRunDetailsKey) { + t.Fatalf("non-shell tools carry no run info: %s", event.GetData()) + } +} + +func TestPermissionPreviewReachesApprovalCard(t *testing.T) { + f := newInteractiveFixture(t) + f.send("tool.call", `{"tool_call":{"id":"edit-1","name":"Edit","arguments":{"file_path":"/w/a.go","old_string":"a","new_string":"b"}}}`) + f.take() + f.send("permission.requested", `{"permission_id":"perm-1","tool":"Edit","description":"Edit /w/a.go","options":[{"id":"allow_once"},{"id":"reject"}],"tool_call_id":"edit-1","preview":{"path":"/w/a.go","diff":"3 - a\n3 + b","truncated":false}}`) + args := f.take()[0].args() + preview, _ := args[argApprovalPreview].(map[string]any) + if preview["diff"] != "3 - a\n3 + b" || preview["path"] != "/w/a.go" || preview["truncated"] != false || preview["newFile"] != false { + t.Fatalf("preview = %+v", args) + } + + f.send("tool.call", `{"tool_call":{"id":"write-1","name":"Write","arguments":{"file_path":"/w/big.bin"}}}`) + f.take() + f.send("permission.requested", `{"permission_id":"perm-2","tool":"Write","tool_call_id":"write-1","preview":{"path":"/w/big.bin","new_file":true,"truncated":true}}`) + preview, _ = f.take()[0].args()[argApprovalPreview].(map[string]any) + if preview["diff"] != "" || preview["truncated"] != true || preview["newFile"] != true { + t.Fatalf("too-large preview = %+v", preview) + } + + diff, cut := clampDiffRows(strings.Repeat("1 + abcdefghij\n", 4000), approvalPreviewMax) + if !cut || len(diff) > approvalPreviewMax || strings.HasSuffix(diff, "\n") { + t.Fatalf("diff clamp = %d cut=%v", len(diff), cut) + } +} diff --git a/crates/agent-gateway/web/test/kbrain-question-preview.test.mjs b/crates/agent-gateway/web/test/kbrain-question-preview.test.mjs new file mode 100644 index 000000000..577e8147a --- /dev/null +++ b/crates/agent-gateway/web/test/kbrain-question-preview.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs"; + +// What WebUI reads from K-brain's question tool and permission previews, on the desktop +// sync and on the Gateway's direct link (crates/agent-gateway/internal/kbrain). +const rootDir = fileURLToPath(new URL("../", import.meta.url)); +const loader = createWebModuleLoader({ rootDir }); +const shared = loader.loadModule("@liveagent/ui/lib/chat/askUserQuestion.ts"); +const approvalArgs = loader.loadModule("@liveagent/ui/lib/chat/toolApprovalArgs.ts"); +const { toolCallArgsForDisplay } = loader.loadModule("@liveagent/ui/lib/chat/uiMessages.ts"); + +test("a multi-select answer from WebUI carries its labels for the Gateway", () => { + const questions = shared.parseAskUserQuestionItems([ + { id: "q1", header: "Extras", prompt: "Add what?", multiple: true, options: [{ label: "Docs" }, { label: "CI" }, { label: "Tests" }] }, + ]); + const answers = shared.resolveAskUserQuestionAnswers(questions, [ + { questionId: "q1", selectedLabels: ["Tests", "Docs"] }, + ]); + const sent = JSON.parse(JSON.stringify(answers)); + assert.deepEqual(sent[0].selectedLabels, ["Docs", "Tests"]); + assert.equal(sent[0].selectedLabel, "Docs, Tests"); +}); + +test("the Gateway's direct-link approval preview shows K-brain's diff rows", () => { + const item = approvalArgs.pendingToolApprovalFromToolCall({ + id: "edit-1", + name: "edit", + arguments: { + path: "/w/a.go", + __toolApprovalPending: true, + __toolApprovalPreview: { path: "/w/a.go", before: "", after: "", diff: "3 - a\n3 + b", newFile: false, truncated: false }, + }, + }); + assert.deepEqual(item.preview, { path: "/w/a.go", before: "", after: "", truncated: false, diff: "3 - a\n3 + b" }); +}); + +test("the original question arguments stay out of the displayed tool arguments", () => { + const display = toolCallArgsForDisplay({ + type: "toolCall", + id: "ask", + name: "AskUserQuestion", + arguments: { questions: [], [shared.KBRAIN_QUESTION_ORIGINAL_ARGUMENTS_ARG]: { question: "Proceed?" } }, + }); + assert.equal(shared.KBRAIN_QUESTION_ORIGINAL_ARGUMENTS_ARG in display, false); +}); diff --git a/crates/agent-gui/src/lib/kbrain/history.ts b/crates/agent-gui/src/lib/kbrain/history.ts index b306ab03f..b32092770 100644 --- a/crates/agent-gui/src/lib/kbrain/history.ts +++ b/crates/agent-gui/src/lib/kbrain/history.ts @@ -10,6 +10,7 @@ import { normalizeHostedSearchBlock, } from "@liveagent/ui/lib/chat/hostedSearch"; import { taskResultDetails } from "@liveagent/ui/lib/chat/taskProgress"; +import { withToolRunInfo } from "@liveagent/ui/lib/chat/toolRunInfo"; import { parseUserMessageContentWithUploads } from "@liveagent/ui/lib/chat/uploadedFiles"; import { type ConversationViewState, @@ -35,7 +36,11 @@ import { getKBrainSessionId, setKBrainSessionId, } from "./mapping"; -import { questionResultDetails } from "./questions"; +import { + kbrainToolResultName, + normalizeKBrainQuestionToolCall, + questionResultDetails, +} from "./questions"; import { getConfiguredKBrainConnection } from "./runtimeConnection"; import { confirmKBrainMutation, hasKBrainMutationSync } from "./sessionSyncState"; import { displayImageResultDetails, kbrainToolImages } from "./toolImages"; @@ -46,6 +51,7 @@ import type { KBrainMessageRef, KBrainModelRef, KBrainSession, + KBrainToolCall, } from "./types"; const KBRAIN_API = "kbrain.agent.v1" as AssistantMessage["api"]; @@ -156,7 +162,7 @@ function toMessage( message: KBrainMessage, index: number, cwd?: string, - toolArguments?: unknown, + toolCall?: KBrainToolCall, taskRunId?: string, ): Message | null { const timestamp = epoch(message.created_at, 0); @@ -214,33 +220,29 @@ function toMessage( const isError = message.stop_reason === "error" || message.stop_reason === "cancelled"; const images = kbrainToolImages(message.content, output); const content = [...textAndFiles, ...images.content]; + const text = content + .filter((part) => part.type === "text") + .map((part) => part.text) + .join(""); return { role: "toolResult", toolCallId: message.tool_call_id || `kbrain-tool-${index}`, - toolName: message.name || "tool", + toolName: kbrainToolResultName(message.name), content, - details: - questionResultDetails( - message.name, - content - .filter((part) => part.type === "text") - .map((part) => part.text) - .join(""), - ) ?? - taskResultDetails( - message.name, - toolArguments, - { - output: content - .filter((part) => part.type === "text") - .map((part) => part.text) - .join(""), - isError: message.stop_reason === "error" || message.stop_reason === "cancelled", - }, - taskRunId ?? "history", - index, - ) ?? - displayImageResultDetails(message.name, images.images, isError), + // 运行信息(退出码/超时/耗时)在历史里挂在 assistant 的 tool_calls[] 上。 + details: withToolRunInfo( + message.name, + questionResultDetails(message.name, text, toolCall?.arguments, isError) ?? + taskResultDetails( + message.name, + toolCall?.arguments, + { output: text, isError }, + taskRunId ?? "history", + index, + ) ?? + displayImageResultDetails(message.name, images.images, isError), + toolCall, + ), isError, timestamp, }; @@ -254,12 +256,14 @@ function toMessage( } content.push(...toHostedSearch(message.hosted_search)); for (const call of message.tool_calls ?? []) { - content.push({ - type: "toolCall", - id: call.id, - name: call.name, - arguments: (call.arguments ?? {}) as Record, - }); + content.push( + normalizeKBrainQuestionToolCall({ + type: "toolCall", + id: call.id, + name: call.name, + arguments: (call.arguments ?? {}) as Record, + }), + ); } return { role: "assistant", @@ -405,16 +409,16 @@ export function convertMessages( cwd?: string, taskRunId?: string, ): Message[] { - const toolArguments = new Map(); + const toolCalls = new Map(); for (const message of messages) { - for (const call of message.tool_calls ?? []) toolArguments.set(call.id, call.arguments); + for (const call of message.tool_calls ?? []) toolCalls.set(call.id, call); } return messages.flatMap((message, index) => { const converted = toMessage( message, offset + index, cwd, - message.role === "tool" ? toolArguments.get(message.tool_call_id ?? "") : undefined, + message.role === "tool" ? toolCalls.get(message.tool_call_id ?? "") : undefined, taskRunId, ); return converted ? [converted] : []; @@ -447,9 +451,9 @@ function historyWindow( ) { throw new Error("K-brain history message_offsets are invalid"); } - const toolArguments = new Map(); + const toolCalls = new Map(); for (const message of source) { - for (const call of message.tool_calls ?? []) toolArguments.set(call.id, call.arguments); + for (const call of message.tool_calls ?? []) toolCalls.set(call.id, call); } const indexedMessages = source.flatMap((message, index) => { const offset = offsets[index]; @@ -457,7 +461,7 @@ function historyWindow( message, offset, session.cwd, - message.role === "tool" ? toolArguments.get(message.tool_call_id ?? "") : undefined, + message.role === "tool" ? toolCalls.get(message.tool_call_id ?? "") : undefined, session.id, ); return converted ? [{ message: converted, offset }] : []; diff --git a/crates/agent-gui/src/lib/kbrain/questions.ts b/crates/agent-gui/src/lib/kbrain/questions.ts index 53c0fc03e..a06960b85 100644 --- a/crates/agent-gui/src/lib/kbrain/questions.ts +++ b/crates/agent-gui/src/lib/kbrain/questions.ts @@ -1,10 +1,70 @@ -import type { AskUserQuestionResultDetails } from "@liveagent/ui/lib/chat/askUserQuestion"; +import type { ToolCall } from "@liveagent/app/lib/agentTypes"; +import { + ASK_USER_QUESTION_TOOL_NAME, + type AskUserQuestionResultDetails, + KBRAIN_QUESTION_ORIGINAL_ARGUMENTS_ARG, + KBRAIN_QUESTION_TOOL_NAME, + normalizeKBrainQuestionToolArguments, + parseKBrainQuestionToolResult, +} from "@liveagent/ui/lib/chat/askUserQuestion"; +/** + * K-brain's own `question` tool (Stack-Cairn/K-brain#162) shows as the AskUserQuestion + * card: live (where question.requested already names it so) and in restored history. + */ +export function normalizeKBrainQuestionToolCall(call: ToolCall): ToolCall { + if (call.name !== KBRAIN_QUESTION_TOOL_NAME) return call; + return { + ...call, + name: ASK_USER_QUESTION_TOOL_NAME, + arguments: { + ...normalizeKBrainQuestionToolArguments(call.arguments), + [KBRAIN_QUESTION_ORIGINAL_ARGUMENTS_ARG]: call.arguments ?? {}, + }, + }; +} + +export { KBRAIN_QUESTION_ORIGINAL_ARGUMENTS_ARG }; + +/** The K-brain name and arguments of a call {@link normalizeKBrainQuestionToolCall} renamed. */ +export function originalKBrainToolCall(call: Pick): { + name: string; + arguments: unknown; +} { + const original = call.arguments?.[KBRAIN_QUESTION_ORIGINAL_ARGUMENTS_ARG]; + if (call.name !== ASK_USER_QUESTION_TOOL_NAME || original === undefined) { + return { name: call.name, arguments: call.arguments }; + } + return { name: KBRAIN_QUESTION_TOOL_NAME, arguments: original }; +} + +/** The tool result name the transcript shows for a K-brain tool result. */ +export function kbrainToolResultName(name: string | undefined): string { + return name === KBRAIN_QUESTION_TOOL_NAME ? ASK_USER_QUESTION_TOOL_NAME : name || "tool"; +} + +/** + * Question card details from a K-brain tool result: AskUserQuestion returns the + * resolution as JSON; the `question` tool returns plain text that is read against the + * call's arguments. + */ export function questionResultDetails( name: string | undefined, output: string, + args?: unknown, + isError = false, +): AskUserQuestionResultDetails | undefined { + if (name === KBRAIN_QUESTION_TOOL_NAME) { + return parseKBrainQuestionToolResult(args, output, isError) ?? undefined; + } + if (name !== ASK_USER_QUESTION_TOOL_NAME) return undefined; + return questionResolutionDetails(output); +} + +/** A question.resolved payload (or the AskUserQuestion result that carries it) as card details. */ +export function questionResolutionDetails( + output: string, ): AskUserQuestionResultDetails | undefined { - if (name !== "AskUserQuestion") return undefined; try { const start = output.indexOf("{"); const result = JSON.parse(output.slice(start)); diff --git a/crates/agent-gui/src/lib/kbrain/turn.ts b/crates/agent-gui/src/lib/kbrain/turn.ts index f617f2781..03f668e61 100644 --- a/crates/agent-gui/src/lib/kbrain/turn.ts +++ b/crates/agent-gui/src/lib/kbrain/turn.ts @@ -6,6 +6,7 @@ import type { ToolResultMessage, } from "@liveagent/app/lib/agentTypes"; import { toast } from "@liveagent/ui/components/ui/toast-manager"; +import { KBRAIN_QUESTION_TOOL_NAME } from "@liveagent/ui/lib/chat/askUserQuestion"; import { type HostedSearchBlock, normalizeHostedSearchBlock, @@ -16,13 +17,20 @@ import { type RunProgress, } from "@liveagent/ui/lib/chat/runProgress"; import { taskResultDetails } from "@liveagent/ui/lib/chat/taskProgress"; +import { withToolRunInfo } from "@liveagent/ui/lib/chat/toolRunInfo"; import type { Locale } from "../../i18n/config"; import type { ConversationViewState } from "../chat/conversation/conversationState"; import { appendMessagesToConversation } from "../chat/conversation/conversationState"; import { createKBrainClient } from "./client"; import { hookWarningNotice, parseHookWarning } from "./hookWarning"; import { getKBrainSessionId, setKBrainSessionId } from "./mapping"; -import { questionResultDetails } from "./questions"; +import { + KBRAIN_QUESTION_ORIGINAL_ARGUMENTS_ARG, + kbrainToolResultName, + normalizeKBrainQuestionToolCall, + originalKBrainToolCall, + questionResultDetails, +} from "./questions"; import { aiReviewTitle, localizeAIReviewReason } from "./reviewNotice"; import { isSubagentEventType } from "./subagentEvents"; import { displayImageResultDetails, kbrainToolImages } from "./toolImages"; @@ -134,9 +142,22 @@ export function contextToKBrainMessages(context: Context): KBrainMessage[] { role: "system", content: [{ type: "text", text: context.systemPrompt }], }); + // K-brain `question` calls show as AskUserQuestion; send them back under their own name. + const renamed = new Set(); for (const message of context.messages) { const canonical = canonicalMessage(message); - if (canonical) messages.push(canonical); + if (!canonical) continue; + for (const call of canonical.tool_calls ?? []) { + const original = originalKBrainToolCall(call as Pick); + if (original.name === call.name) continue; + call.name = original.name; + call.arguments = original.arguments; + renamed.add(call.id); + } + if (canonical.role === "tool" && renamed.has(canonical.tool_call_id ?? "")) { + canonical.name = KBRAIN_QUESTION_TOOL_NAME; + } + messages.push(canonical); } return messages; } @@ -161,6 +182,8 @@ export type KBrainPermissionRequest = { shell?: string; /** What allow_always would remember, from backends with permission_rules. */ suggestion?: { rules: string[]; default_scope?: string }; + /** The change a file tool will make; diff rows are " ". */ + preview?: { path: string; diff?: string; new_file?: boolean; truncated?: boolean }; }; /** The answer to a permission request: a bare decision, or one with the remember @@ -278,12 +301,12 @@ function usage(message?: KBrainMessage): AssistantMessage["usage"] { } function toolCall(call: KBrainToolCall): ToolCall { - return { + return normalizeKBrainQuestionToolCall({ type: "toolCall", id: call.id, name: call.name, arguments: (call.arguments ?? {}) as Record, - }; + }); } function toolResult( @@ -297,12 +320,15 @@ function toolResult( return { role: "toolResult", toolCallId: result.id, - toolName: result.name ?? "tool", + toolName: kbrainToolResultName(result.name), content: [{ type: "text", text: result.output }, ...images.content], - details: - questionResultDetails(result.name, result.output) ?? - taskResultDetails(result.name, call?.arguments, result, runId, revision) ?? - displayImageResultDetails(result.name, images.images, isError), + details: withToolRunInfo( + result.name, + questionResultDetails(result.name, result.output, call?.arguments, isError) ?? + taskResultDetails(result.name, call?.arguments, result, runId, revision) ?? + displayImageResultDetails(result.name, images.images, isError), + result, + ), isError, timestamp: Date.now(), }; @@ -656,6 +682,9 @@ async function executeKBrainTurn(params: KBrainTurnParams): Promise(); const pendingQuestions = new Map(); + // question.resolved carries the exact answers (custom flags included); the later + // tool.result of K-brain's `question` tool is only text, so its card keeps these. + const resolvedQuestionResults = new Map(); const pendingClientTools = new Map(); // Never cleared: replayed events after a reconnect must not run the same action twice. const handledClientTools = new Set(); @@ -733,12 +762,17 @@ async function executeKBrainTurn(params: KBrainTurnParams): Promise(event); if (!payload?.tool_result) break; - const result = toolResult( + const computed = toolResult( payload.tool_result, calls.get(payload.tool_result.id), event.run_id, event.seq, ); + const resolvedQuestion = resolvedQuestionResults.get(computed.toolCallId); + const result = + resolvedQuestion?.details && !computed.isError + ? { ...computed, details: resolvedQuestion.details } + : computed; const call = calls.get(result.toolCallId) ?? { type: "toolCall", id: result.toolCallId, @@ -846,6 +880,7 @@ async function executeKBrainTurn(params: KBrainTurnParams): Promise { } finally { if (call) pushGatewayToolCall(call); } + // K-brain takes one label per answer: several picks of a multi-select + // question go as their joined labels, marked custom so it accepts them. return answers.map((answer) => ({ question_id: answer.questionId, selected_label: answer.selectedLabel, - ...(answer.custom ? { custom: true } : {}), + ...(askUserQuestionAnswerIsCustomForBackend(answer) ? { custom: true } : {}), })); }, onPermissionRequest: async (request, signal, refusal) => { @@ -413,8 +419,11 @@ export async function runKBrainConversationTurn(params: Params): Promise { sessionWide: request.scope === "session", rememberRules, defaultScope: request.suggestion?.default_scope, - // The edit being approved, from the call's full arguments. - preview: call ? approvalPreviewFromToolCall(request.tool, call.arguments) : undefined, + // The edit being approved: K-brain's own preview of the change when it sends + // one, else worked out from the call's full arguments. + preview: + approvalPreviewFromKBrain(request.tool, request.preview) ?? + (call ? approvalPreviewFromToolCall(request.tool, call.arguments) : undefined), notice: refusal, signal: signal ? AbortSignal.any([approvalController.signal, signal]) diff --git a/crates/agent-gui/test/chat/ask-user-question-card.test.mjs b/crates/agent-gui/test/chat/ask-user-question-card.test.mjs index cfa6966aa..00dfdac2a 100644 --- a/crates/agent-gui/test/chat/ask-user-question-card.test.mjs +++ b/crates/agent-gui/test/chat/ask-user-question-card.test.mjs @@ -525,3 +525,81 @@ test("legacy timeout answers remain selected", () => { assert.match(treeText(tree), /chat.askUser.timedOut/); assert.equal(treeText(tree).includes("chat.askUser.timedOutNoSelection"), false); }); + +const headedQuestions = [ + { id: "q1", header: "Stack", prompt: "Which stack?", options: [{ label: "Go" }, { label: "Rust" }] }, + { id: "q2", prompt: "Untitled question", options: [{ label: "Yes" }, { label: "No" }] }, + { + id: "q3", + header: "Extras", + prompt: "Add what?", + multiple: true, + options: [{ label: "Docs" }, { label: "Tests" }, { label: "CI" }], + }, +]; + +test("each question's header labels its tab, falling back to Q1…", () => { + const card = createCardHarness(); + const props = { questions: headedQuestions, deadlineAt: Date.now() + 60_000 }; + let tree = card.render(props); + const tabs = findAll(tree, (node) => node.type === "button" && node.props?.role === "tab"); + assert.deepEqual( + tabs.map((tab) => [tab.props["data-ask-question-tab"], treeText(tab), tab.props["aria-selected"]]), + [ + ["q1", "Stack", true], + ["q2", "chat.askUser.tabFallback2", false], + ["q3", "Extras", false], + ], + ); + tabs[2].props.onClick(); + tree = card.render(props); + assert.match(treeText(tree), /Add what\?/); + assert.match(treeText(tree), /chat\.askUser\.multipleHint/); +}); + +test("a multiple question toggles several options and submits all of them", async () => { + const submitted = []; + const card = createCardHarness(); + const props = { + questions: [headedQuestions[2]], + deadlineAt: Date.now() + 60_000, + onSubmit: async (answers) => { + submitted.push(answers); + return { ok: true }; + }, + }; + const checkboxes = (tree) => + findAll(tree, (node) => node.type === "button" && node.props?.role === "checkbox" && !node.props["aria-label"]); + let tree = card.render(props); + assert.match(treeText(tree), /Extras/, "a single question still shows its header"); + assert.equal(checkboxes(tree).length, 3); + checkboxes(tree)[2].props.onClick(); + tree = card.render(props); + checkboxes(tree)[0].props.onClick(); + tree = card.render(props); + checkboxes(tree)[1].props.onClick(); + tree = card.render(props); + checkboxes(tree)[1].props.onClick(); + tree = card.render(props); + assert.deepEqual(checkboxes(tree).map((box) => box.props["aria-checked"]), [true, false, true]); + + findSubmitButton(tree).props.onClick(); + await flushPromises(); + assert.equal(submitted.length, 1); + const [answer] = submitted[0]; + assert.equal(answer.questionId, "q3"); + assert.deepEqual(answer.selectedLabels, ["Docs", "CI"]); + assert.equal(answer.selectedLabel, "Docs, CI"); + assert.equal(answer.custom, undefined); +}); + +test("a settled multiple answer shows every chosen option checked", () => { + const card = createCardHarness(); + const tree = card.render({ + questions: [headedQuestions[2]], + interactive: false, + answers: [{ questionId: "q3", prompt: "Add what?", selectedLabel: "Docs, CI" }], + }); + const boxes = findAll(tree, (node) => node.type === "button" && node.props?.role === "checkbox" && !node.props["aria-label"]); + assert.deepEqual(boxes.map((box) => box.props["aria-checked"]), [true, false, true]); +}); diff --git a/crates/agent-gui/test/chat/edit-diff-view.test.mjs b/crates/agent-gui/test/chat/edit-diff-view.test.mjs index 6976bb0ff..d07d5fc4c 100644 --- a/crates/agent-gui/test/chat/edit-diff-view.test.mjs +++ b/crates/agent-gui/test/chat/edit-diff-view.test.mjs @@ -38,3 +38,23 @@ test("edit tool diff keeps real unified diff data without the legacy renderer", assert.doesNotMatch(source, /@git-diff-view\/react/); assert.doesNotMatch(source, / { + const { createTsModuleLoader } = await import("../helpers/load-ts-module.mjs"); + const { parseDiffRowsText } = createTsModuleLoader().loadModule("@liveagent/ui/lib/chat/diffRows.ts"); + assert.deepEqual(parseDiffRowsText("11 keep\n12 - old\n12 + new\n12 +\n13 \n- bare\n… +4 more lines"), [ + { type: "ctx", lineNumber: 11, text: "keep" }, + { type: "del", lineNumber: 12, text: "old" }, + { type: "add", lineNumber: 12, text: "new" }, + { type: "add", lineNumber: 12, text: "" }, + { type: "ctx", lineNumber: 13, text: "" }, + { type: "del", lineNumber: null, text: "bare" }, + { type: "note", lineNumber: null, text: "… +4 more lines" }, + ]); + assert.deepEqual(parseDiffRowsText(""), []); +}); + +test("edit tool diff shows a backend diff as given instead of diffing two texts", () => { + assert.match(source, /if \(diffText !== undefined\) return buildTextDiffRows\(diffText\)/); + assert.match(source, /data-diff-note=""/); +}); diff --git a/crates/agent-gui/test/chat/kbrain-approval-ui.test.mjs b/crates/agent-gui/test/chat/kbrain-approval-ui.test.mjs index 8fae99b47..f394a1060 100644 --- a/crates/agent-gui/test/chat/kbrain-approval-ui.test.mjs +++ b/crates/agent-gui/test/chat/kbrain-approval-ui.test.mjs @@ -188,3 +188,29 @@ test("both hosts read the marker: the desktop from its store, WebUI from the syn assert.deepEqual(webAdapter.usePermissionAutoResolved("x", { __toolPermissionAutoResolved: { source: "read_only" } }), { source: "read_only" }); assert.equal(webAdapter.usePermissionAutoResolved("x", { command: "ls" }), null); }); + +test("an edit approval renders K-brain's diff rows with line numbers and the cut note", async () => { + const { host, cleanup } = await renderBar([{ + toolCallId: "e1", + toolName: "Edit", + summary: "src/app.ts", + preview: { + path: "src/app.ts", + before: "", + after: "", + truncated: true, + diff: "11 const keep = true;\n12 - const port = 3000;\n12 + const port = 8080;\n… +3 more lines", + }, + }]); + try { + const figure = host.querySelector("[data-chat-code-diff]"); + assert.equal(Boolean(figure), true, "the diff view is shown"); + assert.match(figure.textContent, /src\/app\.ts/); + assert.match(figure.textContent, /\+1-1/); + assert.match(figure.textContent, /12const port = 8080;/); + assert.equal(host.querySelector("[data-diff-note]")?.textContent, "… +3 more lines"); + assert.match(host.textContent, /预览已截断/); + } finally { + await cleanup(); + } +}); diff --git a/crates/agent-gui/test/chat/review-la2b-approval-card-r4.test.mjs b/crates/agent-gui/test/chat/review-la2b-approval-card-r4.test.mjs index e9ca93fa9..943b3e917 100644 --- a/crates/agent-gui/test/chat/review-la2b-approval-card-r4.test.mjs +++ b/crates/agent-gui/test/chat/review-la2b-approval-card-r4.test.mjs @@ -152,3 +152,29 @@ test("model-written approval keys never reach WebUI on the first tool_call event `synthetic keys from the model reached the Gateway: ${JSON.stringify({ leaked: [...new Set(leaked)], webuiCards: cards })}`, ); }); + +// K-brain sends its own preview of the change (permission.requested.preview); the +// approval shows that diff instead of one worked out from the call's arguments. +test("K-brain's permission preview reaches the desktop approval and the WebUI copy", async (t) => { + const { listed, gatewayEvents, errors } = await runTurn(t, { + answers: [["approve"]], + events: (event) => { + event("tool.call", { tool_call: { id: "ed", name: "Edit", arguments: { file_path: "/w/a.ts", old_string: "a", new_string: "b" } } }); + event("permission.requested", { + permission_id: "perm-ed", tool: "Edit", description: "Edit /w/a.ts", tool_call_id: "ed", + options: [{ id: "allow_once" }, { id: "reject" }], + preview: { path: "/w/a.ts", diff: "4 - const a = 1;\n4 + const a = 2;", truncated: false }, + }); + }, + }); + assert.equal(errors.length, 0, `turn failed: ${errors.join(" | ")}`); + assert.equal(listed.length, 1); + assert.equal(listed[0].preview?.diff, "4 - const a = 1;\n4 + const a = 2;"); + assert.equal(listed[0].preview?.before, ""); + const cards = gatewayEvents + .filter((value) => value.type === "tool_call" && value.id === "ed") + .map((value) => args.pendingToolApprovalFromToolCall({ id: value.id, name: value.name, arguments: value.arguments })) + .filter(Boolean); + assert.equal(cards.length > 0, true, "WebUI saw the pending approval"); + assert.equal(cards[0].preview?.diff, "4 - const a = 1;\n4 + const a = 2;"); +}); diff --git a/crates/agent-gui/test/chat/tool-approval-card.test.mjs b/crates/agent-gui/test/chat/tool-approval-card.test.mjs index 0646d566b..f9ee4934b 100644 --- a/crates/agent-gui/test/chat/tool-approval-card.test.mjs +++ b/crates/agent-gui/test/chat/tool-approval-card.test.mjs @@ -247,3 +247,64 @@ test("the length cap never leaves half an emoji either", () => { assert.equal(lone.test(preview.after), false, `lead=${JSON.stringify(lead)}`); } }); + +// K-brain sends the change itself (permission.requested.preview): diff rows computed +// against the file on disk, a new-file flag, and truncated when cut or left out. +test("K-brain's preview of the change is shown as its own diff rows", async () => { + const loader = createTsModuleLoader(); + const { approvalPreviewFromKBrain, pendingToolApprovalFromToolCall } = loader.loadModule("@liveagent/ui/lib/chat/toolApprovalArgs.ts"); + const preview = approvalPreviewFromKBrain("edit", { path: "/w/a.go", diff: "3 - a := 1\n3 + a := 2", truncated: false }); + assert.deepEqual(preview, { path: "/w/a.go", before: "", after: "", truncated: false, diff: "3 - a := 1\n3 + a := 2" }); + assert.deepEqual(approvalPreviewFromKBrain("Write", { path: "big.bin", new_file: true, truncated: true }), { + path: "big.bin", before: "", after: "", truncated: true, diff: "", newFile: true, + }); + assert.equal(approvalPreviewFromKBrain("Bash", { path: "x", diff: "1 + y" }), undefined); + assert.equal(approvalPreviewFromKBrain("Edit", { path: "x", diff: "" }), undefined, "nothing to show falls back to the arguments"); + const long = approvalPreviewFromKBrain("Edit", { path: "x", diff: "1 + abcdefgh\n".repeat(5000) }); + assert.equal(long.truncated, true); + assert.equal(long.diff.length <= 32 * 1024 && !long.diff.endsWith("\n"), true); + + // WebUI reads the copy the desktop or the Gateway syncs onto the call. + const remote = pendingToolApprovalFromToolCall({ + id: "w1", + name: "Write", + arguments: { + path: "notes.md", + content: "ignored: the synced preview wins", + __toolApprovalPending: true, + __toolApprovalPreview: { path: "notes.md", before: "", after: "", diff: "1 + # Notes", newFile: true, truncated: false }, + }, + }); + assert.deepEqual(remote.preview, { path: "notes.md", before: "", after: "", truncated: false, diff: "1 + # Notes", newFile: true }); +}); + +test("the approval card renders K-brain diff rows, a new file and a change too large to preview", async () => { + diffs.length = 0; + const { host, cleanup } = await renderBar([{ + toolCallId: "c7", + toolName: "Write", + summary: "notes.md", + preview: { path: "notes.md", before: "", after: "", truncated: false, diff: "1 + # Notes\n2 + hi", newFile: true }, + }]); + try { + assert.deepEqual(diffs.at(-1), { beforeText: "", afterText: "", diffText: "1 + # Notes\n2 + hi", filePath: "notes.md" }); + assert.equal(host.querySelector("[data-approval-preview-new-file]") !== null, true); + assert.doesNotMatch(host.textContent, /预览已截断/); + } finally { + await cleanup(); + } + diffs.length = 0; + const large = await renderBar([{ + toolCallId: "c8", + toolName: "Edit", + summary: "huge.json", + preview: { path: "huge.json", before: "", after: "", truncated: true, diff: "" }, + }]); + try { + assert.equal(diffs.length, 0, "no diff when K-brain left it out"); + assert.match(large.host.textContent, /改动过大,无法预览/); + assert.match(large.host.querySelector("[data-approval-preview]")?.textContent ?? "", /huge\.json/); + } finally { + await large.cleanup(); + } +}); diff --git a/crates/agent-gui/test/chat/tool-argument-display.test.mjs b/crates/agent-gui/test/chat/tool-argument-display.test.mjs index a43455cf2..4aa2febb3 100644 --- a/crates/agent-gui/test/chat/tool-argument-display.test.mjs +++ b/crates/agent-gui/test/chat/tool-argument-display.test.mjs @@ -905,3 +905,22 @@ test("failed file operations keep an expandable error disclosure", () => { assert.match(html, /aria-expanded="false"/); assert.doesNotMatch(html, /data-chat-file-link/); }); + +test("generic results show K-brain run info as one line instead of raw details JSON", () => { + const renderToolDisplay = createToolArgsRenderer(); + const toolCall = { type: "toolCall", id: "native-bash", name: "bash", arguments: { command: "exit 3" } }; + const onlyRunInfo = renderToolDisplay.renderResult(toolCall, { kbrainRun: { exitCode: 3, durationMs: 16 } }); + assert.match(onlyRunInfo, /data-tool-run-info=""/); + assert.match(onlyRunInfo, /3 · 16ms/); + assert.doesNotMatch(onlyRunInfo, /kbrainRun|exitCode|durationMs/); + + const withOther = renderToolDisplay.renderResult(toolCall, { + kbrainRun: { exitCode: 0 }, + note: "kept", + }); + assert.match(withOther, /data-tool-run-info=""/); + assert.match(withOther, /note/); + assert.doesNotMatch(withOther, /kbrainRun/); + + assert.equal(renderToolDisplay.renderResult(toolCall, {}), ""); +}); diff --git a/crates/agent-gui/test/providers/kbrain-question-tool.test.mjs b/crates/agent-gui/test/providers/kbrain-question-tool.test.mjs new file mode 100644 index 000000000..3cb4134b9 --- /dev/null +++ b/crates/agent-gui/test/providers/kbrain-question-tool.test.mjs @@ -0,0 +1,259 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +// K-brain's own `question` tool (Stack-Cairn/K-brain#162): multi-question asks with +// headers, multi-select answers, the plain-text result read back in history, and how a +// shell command ran (exit code / timeout / duration). +const loader = createTsModuleLoader(); +const shared = loader.loadModule("@liveagent/ui/lib/chat/askUserQuestion.ts"); +const runInfo = loader.loadModule("@liveagent/ui/lib/chat/toolRunInfo.ts"); +const history = loader.loadModule("src/lib/kbrain/history.ts"); +const turn = loader.loadModule("src/lib/kbrain/turn.ts"); + +const questionArgs = { + questions: [ + { header: "Stack", question: "Which stack?", options: [{ label: "Go" }, { label: "Rust" }] }, + { + header: "Extras", + question: "Add what?", + multiple: true, + options: [{ label: "Docs" }, { label: "Tests, unit" }, { label: "CI" }], + }, + ], +}; + +test("question tool arguments become AskUserQuestion questions in both forms", () => { + const normalized = shared.normalizeKBrainQuestionToolArguments({ ...questionArgs, __marker: 1 }); + assert.equal(normalized.__marker, 1); + assert.deepEqual( + normalized.questions.map((question) => [question.id, question.header, question.prompt, question.multiple]), + [ + ["q1", "Stack", "Which stack?", undefined], + ["q2", "Extras", "Add what?", true], + ], + ); + const single = shared.normalizeKBrainQuestionToolArguments({ + question: "Proceed?", + options: [{ label: "Yes" }, { label: "No" }], + multiple: true, + }); + assert.deepEqual(single.questions, [ + { options: [{ label: "Yes" }, { label: "No" }], multiple: true, id: "q1", prompt: "Proceed?" }, + ]); +}); + +test("question tool results read back into settled card answers", () => { + const answered = shared.parseKBrainQuestionToolResult( + questionArgs, + "The user answered:\n1. Which stack?\n → Rust\n2. Add what?\n → Docs, Tests, unit", + false, + ); + assert.equal(answered.kind, "ask_user_question"); + assert.equal(answered.timedOut, undefined); + assert.deepEqual(answered.answers[0], { questionId: "q1", prompt: "Which stack?", selectedLabel: "Rust" }); + // A label holding the separator still splits back into the listed options. + assert.deepEqual(answered.answers[1].selectedLabels, ["Docs", "Tests, unit"]); + assert.equal(answered.answers[1].custom, undefined); + + const typed = shared.parseKBrainQuestionToolResult( + { question: "Proceed?", options: [{ label: "Yes" }, { label: "No" }] }, + 'User answered "Proceed?": After lunch', + false, + ); + assert.deepEqual(typed.answers, [ + { questionId: "q1", prompt: "Proceed?", selectedLabel: "After lunch", custom: true }, + ]); + + const timedOut = shared.parseKBrainQuestionToolResult( + questionArgs, + "The user answered:\n1. Which stack?\n → (no answer)\n2. Add what?\n → (no answer)", + false, + ); + assert.equal(timedOut.timedOut, true); + assert.equal(timedOut.answers.length, 0); + assert.equal( + shared.parseKBrainQuestionToolResult(questionArgs, "the user dismissed the question", true).cancelled, + true, + ); + assert.equal(shared.parseKBrainQuestionToolResult(questionArgs, "invalid arguments", true), null); +}); + +test("multi-select answers keep their labels and go to K-brain as joined custom text", () => { + const questions = shared.parseAskUserQuestionItems( + shared.normalizeKBrainQuestionToolArguments(questionArgs).questions, + ); + const multi = questions[1]; + const answer = shared.buildMultiSelectAnswer(multi, ["CI", "Docs"], "and a changelog"); + assert.deepEqual(answer.selectedLabels, ["Docs", "CI"]); + assert.equal(answer.selectedLabel, "Docs, CI, and a changelog"); + assert.equal(answer.custom, true); + assert.equal(shared.askUserQuestionAnswerIsCustomForBackend(answer), true); + + const resolved = shared.resolveAskUserQuestionAnswers(questions, [ + { questionId: "q1", selectedLabel: "Go" }, + { questionId: "q2", selectedLabels: ["Docs", "CI"] }, + ]); + assert.equal(resolved[1].selectedLabel, "Docs, CI"); + assert.equal(shared.askUserQuestionAnswerIsCustomForBackend(resolved[0]), false); + // Several labels are not one listed option, so K-brain only accepts them as custom. + assert.equal(shared.askUserQuestionAnswerIsCustomForBackend(resolved[1]), true); + const one = shared.resolveAskUserQuestionAnswers(questions, [ + { questionId: "q1", selectedLabel: "Go" }, + { questionId: "q2", selectedLabels: ["CI"] }, + ]); + assert.equal(shared.askUserQuestionAnswerIsCustomForBackend(one[1]), false); + // A label that is not listed is refused unless it is typed text. + assert.equal( + shared.resolveAskUserQuestionAnswers(questions, [ + { questionId: "q1", selectedLabel: "Go" }, + { questionId: "q2", selectedLabels: ["Docs", "Nope"] }, + ]), + null, + ); +}); + +test("history replays question calls as the question card and keeps run info", () => { + const messages = history.convertMessages([ + { + role: "assistant", + tool_calls: [ + { id: "ask-1", name: "question", arguments: questionArgs }, + { id: "sh-1", name: "Bash", arguments: { command: "false" }, exit_code: 1, duration_ms: 2300 }, + { id: "sh-2", name: "Bash", arguments: { command: "sleep 99" }, timed_out: true, duration_ms: 30000 }, + ], + }, + { + role: "tool", + tool_call_id: "ask-1", + name: "question", + content: [{ type: "text", text: "The user answered:\n1. Which stack?\n → Go\n2. Add what?\n → CI" }], + }, + { role: "tool", tool_call_id: "sh-1", name: "Bash", stop_reason: "error", content: [{ type: "text", text: "exit 1" }] }, + { role: "tool", tool_call_id: "sh-2", name: "Bash", stop_reason: "error", content: [{ type: "text", text: "killed" }] }, + ]); + const [assistant, question, failed, killed] = messages; + const call = assistant.content.find((part) => part.id === "ask-1"); + assert.equal(call.name, "AskUserQuestion"); + assert.deepEqual( + call.arguments.questions.map((item) => item.header), + ["Stack", "Extras"], + ); + assert.equal(question.toolName, "AskUserQuestion"); + assert.deepEqual( + question.details.answers.map((answer) => answer.selectedLabel), + ["Go", "CI"], + ); + assert.deepEqual(runInfo.readToolRunInfo(failed.details), { exitCode: 1, durationMs: 2300 }); + assert.equal(runInfo.formatToolRunInfo(runInfo.readToolRunInfo(failed.details)), "exit 1 · 2.3s"); + assert.equal(runInfo.formatToolRunInfo(runInfo.readToolRunInfo(killed.details)), "timed out · 30.0s"); + + // Sent back to K-brain (a new session from this transcript), the call keeps its own name. + const [, back] = turn.contextToKBrainMessages({ messages: [{ role: "user", content: "hi", timestamp: 0 }, ...messages] }); + assert.equal(back.tool_calls[0].name, "question"); + assert.deepEqual(back.tool_calls[0].arguments, questionArgs); + const backResult = turn.contextToKBrainMessages({ messages }).find((message) => message.tool_call_id === "ask-1"); + assert.equal(backResult.name, "question"); +}); + +test("run info is only kept for shell tools and formats compactly", () => { + assert.equal(runInfo.withToolRunInfo("Read", undefined, { duration_ms: 5 }), undefined); + assert.deepEqual(runInfo.withToolRunInfo("Bash", { kept: true }, { exit_code: 0, duration_ms: 40 }), { + kept: true, + kbrainRun: { exitCode: 0, durationMs: 40 }, + }); + assert.equal(runInfo.withToolRunInfo("Bash", undefined, {}), undefined); + assert.equal(runInfo.formatToolRunInfo({ exitCode: 0, durationMs: 40 }), "exit 0 · 40ms"); + assert.equal(runInfo.formatToolRunDuration(125_000), "2m5s"); + assert.equal( + runInfo.formatToolRunInfo({ exitCode: 3 }, { exitCode: "退出码 {code}", timedOut: "已超时" }), + "退出码 3", + ); + assert.equal(runInfo.formatToolRunInfo(null), ""); +}); + +function sse(seq, type, payload) { + return `data: ${JSON.stringify({ + version: "kbrain.agent.v1", seq, conversation_id: "backend-q", run_id: "run-q", type, payload, + })}\n\n`; +} + +test("a live question tool asks on the card, settles from question.resolved and Bash results carry run info", async () => { + const storage = new Map(); + globalThis.localStorage = { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, String(value)), + removeItem: (key) => storage.delete(key), + }; + const request = { + question_id: "question-1", tool_call_id: "ask-1", run_id: "run-q", deadline_at: Date.now() + 60_000, + questions: [ + { id: "q1", header: "Stack", prompt: "Which stack?", options: [{ label: "Go" }, { label: "Rust" }] }, + { id: "q2", header: "Extras", prompt: "Add what?", multiple: true, options: [{ label: "Docs" }, { label: "CI" }] }, + ], + }; + let controller; + const posted = []; + const fetch = async (url, init = {}) => { + const path = new URL(String(url)).pathname; + if (path === "/v1/sessions") return new Response(JSON.stringify({ id: "backend-q", messages: [], last_seq: 0 })); + if (path.endsWith("/runs")) { + return new Response(JSON.stringify({ version: "kbrain.agent.v1", conversation_id: "backend-q", run_id: "run-q", accepted_seq: 1 }), { status: 202 }); + } + if (path.endsWith("/events")) { + return new Response(new ReadableStream({ start(value) { + controller = value; + controller.enqueue(new TextEncoder().encode( + sse(2, "tool.call", { tool_call: { id: "ask-1", name: "question", arguments: questionArgs } }) + + sse(3, "question.requested", request), + )); + } }), { headers: { "content-type": "text/event-stream" } }); + } + if (path.endsWith("/questions/question-1")) { + posted.push(JSON.parse(init.body)); + const answers = [ + { question_id: "q1", prompt: "Which stack?", selected_label: "Go" }, + { question_id: "q2", prompt: "Add what?", selected_label: "Docs, CI", custom: true }, + ]; + controller.enqueue(new TextEncoder().encode( + sse(4, "question.resolved", { ...request, kind: "ask_user_question", answers }) + + sse(5, "tool.result", { tool_result: { id: "ask-1", name: "question", output: "The user answered:\n1. Which stack?\n → Go\n2. Add what?\n → Docs, CI" } }) + + sse(6, "tool.call", { tool_call: { id: "sh-1", name: "Bash", arguments: { command: "make" } } }) + + sse(7, "tool.result", { tool_result: { id: "sh-1", name: "Bash", output: "make: *** Error 2", failed: true, exit_code: 2, duration_ms: 1500 } }) + + sse(8, "run.completed", {}), + )); + controller.close(); + return new Response(JSON.stringify({ ok: true })); + } + throw new Error(`unexpected ${path}`); + }; + const calls = []; + const results = []; + const result = await turn.runKBrainTurn({ + conversationId: "conversation-q", sessionId: "host-q", cwd: "/tmp/w", model: { provider: "fixture", model: "m" }, + prompt: "ask", context: { messages: [{ role: "user", content: "ask", timestamp: 1 }] }, + signal: new AbortController().signal, fetch, + onTextDelta() {}, onThinkingDelta() {}, + onToolCall: (call) => calls.push(call), + onToolResult: (_call, value) => results.push(value), + onQuestionRequest: async () => [ + { question_id: "q1", selected_label: "Go" }, + { question_id: "q2", selected_label: "Docs, CI", custom: true }, + ], + }); + assert.equal(result.stopReason, "stop", result.errorMessage); + assert.equal(posted.length, 1); + const askCalls = calls.filter((call) => call.id === "ask-1"); + assert.equal(askCalls.every((call) => call.name === "AskUserQuestion"), true); + assert.deepEqual(askCalls.at(-1).arguments.questions.map((question) => question.header), ["Stack", "Extras"]); + // The model's own call is kept for a transcript sent back to K-brain. + assert.deepEqual(askCalls.at(-1).arguments.__kbrainQuestionArguments, questionArgs); + const question = results.filter((value) => value.toolCallId === "ask-1").at(-1); + const shell = results.find((value) => value.toolCallId === "sh-1"); + assert.equal(question.toolName, "AskUserQuestion"); + // The card reads the joined custom labels K-brain stored back as the options picked. + const settled = shared.parseAskUserQuestionResultDetails(question.details); + assert.deepEqual(settled.answers[1].selectedLabels, ["Docs", "CI"]); + assert.equal(settled.answers[1].customText, undefined); + assert.deepEqual(runInfo.readToolRunInfo(shell.details), { exitCode: 2, durationMs: 1500 }); +}); diff --git a/crates/agent-ui/src/components/chat/AskUserQuestionCard.tsx b/crates/agent-ui/src/components/chat/AskUserQuestionCard.tsx index 9d9beac9e..de7b3604a 100644 --- a/crates/agent-ui/src/components/chat/AskUserQuestionCard.tsx +++ b/crates/agent-ui/src/components/chat/AskUserQuestionCard.tsx @@ -1,4 +1,4 @@ -// AskUserQuestion 的聊天卡片:逐题切换、每题单选,推荐项排在首位; +// AskUserQuestion 的聊天卡片:逐题切换(顶部按 header 列出各题),单选或多选,推荐项排在首位; // 纯展示组件,提交动作由调用方注入(GUI 直连工具挂起表,WebUI 走网关)。 // 两端直接复用本组件,端差异一律留在各端的 ToolCallItem。 @@ -14,6 +14,8 @@ import { ASK_USER_QUESTION_TIMEOUT_MS, type AskUserQuestionAnswer, type AskUserQuestionItem, + buildMultiSelectAnswer, + interpretAskUserQuestionAnswer, } from "../../lib/chat/askUserQuestion"; import { cn } from "../../lib/shared/utils"; import { MotionDirectionalPanel } from "../MotionDirectionalPanel"; @@ -65,6 +67,55 @@ function useAnswerCountdown(active: boolean, deadlineAt?: number) { return remainingMs; } +/** 题目的短标签:header,缺省回退为“Q1”“Q2”…。 */ +export function askUserQuestionTabLabel( + question: AskUserQuestionItem, + index: number, + fallback: string, +) { + return question.header || `${fallback}${index + 1}`; +} + +/** 选项左侧的选择标记:单选为圆点,多选为方框加勾。 */ +function ChoiceMark({ + multiple, + selected, + inline = false, +}: { + multiple: boolean; + selected: boolean; + /** 与输入框同行时垂直居中,不加顶部偏移。 */ + inline?: boolean; +}) { + return ( +