Files
larksuite__cli/internal/agents/questions_test.go
liuxinyang.lxy e82898ad40 chore(agents): prepare the agents module for open-source publication
Address the two blockers the pre-publication OPSEC review raised against this
subtree, and drop the in-repo demo provider that is not meant to ship.

Remove the example provider:
- delete agents/example (the offline demo backend and onboarding template)
- migrate the cmd-layer tests onto a new catalog-type test fake (fakecat,
  registered beside the existing instance fakes), so the catalog-specific
  paths stay covered — unknown agent id, per-agent capability differences,
  declared send params, per-operation brand scoping
- delete the example provider skill doc; retarget the remaining docs at base,
  using output captured from real `agents list` / `agents card` runs, and
  relabel the samples that can no longer be verified as structural examples

Publication norms:
- translate every user-facing string in the subtree to English (command help,
  flag usage, error messages, hints, meta.next labels), plus test messages
  and fixtures. Chinese now remains only in skills/**.md, the sanctioned
  convention, and in the test that asserts against those docs
- drop the dangling internal design-doc section references that a public
  reader cannot resolve; public-standard RFC citations are kept
- re-sync the quoted CLI output in the skill docs with the new messages

Behavior changes carried in this commit:
- run the capability gates before the --dry-run branch, so a preview is no
  longer a capability bypass: --answer / --file against an agent declaring
  input_required=false / file_input=false now report unsupported_capability
  under dry-run too, matching what a real send would answer. The --file
  confirmation gate stays after dry-run, which uploads nothing
- raise the meta.next watch window from 30s to 90s. A typical backend task
  runs ~60s, so a 30s window made a caller re-issue --watch two or three
  times, and each new process restarted the poll backoff with no gap between
  calls — enough to self-hammer the backend into a rate limit

Also trim the longest narrative comment blocks in the subtree.
2026-08-10 14:53:07 +08:00

163 lines
6.7 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"strings"
"testing"
)
// TestKeyPattern pins the shared key charset: first char alphanumeric (rejects
// flag-lookalike ids), then [A-Za-z0-9_-]; '.' is never part of a key.
func TestKeyPattern(t *testing.T) {
ok := []string{"q1", "q1_a8", "by_region", "Opt-2", "8ball"}
for _, k := range ok {
if !KeyPattern.MatchString(k) {
t.Errorf("KeyPattern should accept %q", k)
}
}
bad := []string{"", "--text", "-o", "_x", "q.1", "q 1", "\u4e2d\u6587", "q1.text"}
for _, k := range bad {
if KeyPattern.MatchString(k) {
t.Errorf("KeyPattern must reject %q", k)
}
}
}
func TestSplitAnswerKey(t *testing.T) {
if q, isText := SplitAnswerKey("q1_a8.text"); q != "q1_a8" || !isText {
t.Errorf("q1_a8.text → (%q,%v)", q, isText)
}
if q, isText := SplitAnswerKey("q1_a8"); q != "q1_a8" || isText {
t.Errorf("q1_a8 → (%q,%v)", q, isText)
}
// exactly one suffix strip: "q.text.text" leaves "q.text" (then fails KeyPattern).
if q, isText := SplitAnswerKey("q.text.text"); q != "q.text" || !isText {
t.Errorf("double suffix should strip once, got (%q,%v)", q, isText)
}
// case-sensitive: ".TEXT" is not the text form.
if _, isText := SplitAnswerKey("q1.TEXT"); isText {
t.Error(".TEXT must not count as the text form")
}
}
// TestMintQuestionIDs pins the minting shape (q<pos>_<suffix> / opt<pos>), that
// provider-supplied ids are left alone, and that minted ids pass KeyPattern.
func TestMintQuestionIDs(t *testing.T) {
qs := []Question{
{Question: "dimension?", Options: []Option{{Label: "by region"}, {OptionID: "keep", Label: "by category"}}},
{QuestionID: "biz_q", Question: "time range?"},
}
MintQuestionIDs(qs, "a8")
if qs[0].QuestionID != "q1_a8" || qs[1].QuestionID != "biz_q" {
t.Errorf("minting should fill empties only: %q, %q", qs[0].QuestionID, qs[1].QuestionID)
}
if qs[0].Options[0].OptionID != "opt1" || qs[0].Options[1].OptionID != "keep" {
t.Errorf("option minting should fill empties only: %+v", qs[0].Options)
}
if !KeyPattern.MatchString(qs[0].QuestionID) || !KeyPattern.MatchString(qs[0].Options[0].OptionID) {
t.Error("minted ids must satisfy KeyPattern")
}
}
// TestDeriveGroupSuffix pins determinism (same anchor → same suffix, any
// process) and anchor-sensitivity (new group's changed timestamp → different
// suffix — the staleness guarantee for persistence-less adapters).
func TestDeriveGroupSuffix(t *testing.T) {
a := DeriveGroupSuffix("2026-07-21T00:00:00Z")
if a != DeriveGroupSuffix("2026-07-21T00:00:00Z") {
t.Error("suffix must be deterministic")
}
if a == DeriveGroupSuffix("2026-07-21T00:00:01Z") {
t.Error("a changed anchor must change the suffix")
}
if len(a) != 6 || !KeyPattern.MatchString("q1_"+a) {
t.Errorf("suffix should be 6 chars and key-safe, got %q", a)
}
}
func normTask(state TaskState, ir *InputRequired) *AgentTask {
return &AgentTask{TaskID: "task_9", State: state, UpdatedAt: "2026-07-21T00:00:00Z", InputRequired: ir}
}
func TestNormalizeInputRequired(t *testing.T) {
// state ≠ input_required → group dropped silently.
tk := normTask(StateWorking, &InputRequired{Questions: []Question{{QuestionID: "q1", Question: "x"}}})
if n := NormalizeInputRequired(tk); n != "" || tk.InputRequired != nil {
t.Errorf("group on a non-paused task must be dropped silently, notice=%q ir=%v", n, tk.InputRequired)
}
// options: [] → absent (a zero-option question IS a free-text question).
tk = normTask(StateInputRequired, &InputRequired{Questions: []Question{{QuestionID: "q1", Question: "x", Options: []Option{}}}})
if n := NormalizeInputRequired(tk); n != "" || tk.InputRequired.Questions[0].Options != nil {
t.Errorf("empty options must normalize to absent, notice=%q", n)
}
// bare A2A shape: no questions, prompt text in Label → one ordinary
// free-text question with a deterministic id.
tk = normTask(StateInputRequired, &InputRequired{Label: "Please give a time range"})
if n := NormalizeInputRequired(tk); n != "" {
t.Errorf("bare-prompt normalization is not a defect, notice=%q", n)
}
qs := tk.InputRequired.Questions
if len(qs) != 1 || qs[0].Question != "Please give a time range" || !strings.HasPrefix(qs[0].QuestionID, "q1_") {
t.Fatalf("bare prompt should become one text question, got %+v", qs)
}
derived := qs[0].QuestionID
tk2 := normTask(StateInputRequired, &InputRequired{Label: "Please give a time range"})
_ = NormalizeInputRequired(tk2)
if tk2.InputRequired.Questions[0].QuestionID != derived {
t.Error("derived qid must be stable across renders (same anchor)")
}
// nothing at all → dropped with a notice.
tk = normTask(StateInputRequired, &InputRequired{})
if n := NormalizeInputRequired(tk); n == "" || tk.InputRequired != nil {
t.Errorf("empty group must drop with a notice, notice=%q ir=%v", n, tk.InputRequired)
}
// flag-lookalike question_id → whole group degrades to one free-text
// question (texts preserved), with a notice; the degraded key passes the
// CLI's own grammar (never a dead-end placeholder).
tk = normTask(StateInputRequired, &InputRequired{Questions: []Question{
{QuestionID: "--text", Question: "dimension?"},
{QuestionID: "q2", Question: "time range?"},
}})
n := NormalizeInputRequired(tk)
if n == "" || !strings.Contains(n, "invalid") {
t.Fatalf("illegal key must degrade with a notice, got %q", n)
}
qs = tk.InputRequired.Questions
if len(qs) != 1 || qs[0].Options != nil || !KeyPattern.MatchString(qs[0].QuestionID) {
t.Fatalf("degraded group should be one text question with a legal key, got %+v", qs)
}
if !strings.Contains(qs[0].Question, "dimension?") || !strings.Contains(qs[0].Question, "time range?") {
t.Errorf("degradation must preserve question texts, got %q", qs[0].Question)
}
// duplicate option ids within one question → same degradation path.
tk = normTask(StateInputRequired, &InputRequired{Questions: []Question{
{QuestionID: "q1", Question: "dimension?", Options: []Option{{OptionID: "a", Label: "A"}, {OptionID: "a", Label: "B"}}},
}})
if n := NormalizeInputRequired(tk); n == "" {
t.Error("duplicate option ids must degrade with a notice")
}
}
// TestSummaryText pins the triage digest: label first, else first
// question, question count suffixed when >1.
func TestSummaryText(t *testing.T) {
ir := &InputRequired{Label: "report confirmation", Questions: []Question{{Question: "dimension?"}, {Question: "time range?"}}}
if s := ir.SummaryText(); s != "report confirmation (2 questions)" {
t.Errorf("got %q", s)
}
ir = &InputRequired{Questions: []Question{{Question: "dimension?"}}}
if s := ir.SummaryText(); s != "dimension?" {
t.Errorf("got %q", s)
}
if (*InputRequired)(nil).SummaryText() != "" {
t.Error("nil group → empty summary")
}
}