| 1 | package bot |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "regexp" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | // TestChipToolsMatchJS pins the Go chip-tool names to agent/actions.mjs |
| 11 | // CHIP_TOOLS — same drift-proofing as the command floor. |
| 12 | func TestChipToolsMatchJS(t *testing.T) { |
| 13 | raw, err := os.ReadFile("../../agent/actions.mjs") |
| 14 | if err != nil { |
| 15 | t.Fatal(err) |
| 16 | } |
| 17 | var js []string |
| 18 | for _, m := range regexp.MustCompile(`name: "([a-z_]+)"`).FindAllStringSubmatch(string(raw), -1) { |
| 19 | js = append(js, m[1]) |
| 20 | } |
| 21 | tools := chipTools() |
| 22 | if len(js) != len(tools) { |
| 23 | t.Fatalf("actions.mjs has %d tools, Go has %d", len(js), len(tools)) |
| 24 | } |
| 25 | for i, tool := range tools { |
| 26 | if js[i] != tool.Name { |
| 27 | t.Errorf("tool %d: js %q, go %q", i, js[i], tool.Name) |
| 28 | } |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | func TestActionsFromToolUse(t *testing.T) { |
| 33 | content := []anthBlock{ |
| 34 | {Type: "text", Text: "Here you go."}, |
| 35 | {Type: "tool_use", Name: "set_theme", Input: map[string]any{"theme": "lagoon"}}, |
| 36 | {Type: "tool_use", Name: "set_theme", Input: map[string]any{"theme": "neon"}}, // unknown theme → dropped |
| 37 | {Type: "tool_use", Name: "set_appearance", Input: map[string]any{"appearance": "dark"}}, |
| 38 | {Type: "tool_use", Name: "delete_everything", Input: map[string]any{}}, // unknown tool → dropped |
| 39 | {Type: "tool_use", Name: "open_profile"}, |
| 40 | } |
| 41 | got := actionsFromToolUse(content) |
| 42 | if len(got) != 3 { |
| 43 | t.Fatalf("got %d actions: %+v", len(got), got) |
| 44 | } |
| 45 | if got[0]["theme"] != "lagoon" || got[1]["appearance"] != "dark" || got[2]["type"] != "open_profile" { |
| 46 | t.Fatalf("bad actions: %+v", got) |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | func TestAdaptiveThinking(t *testing.T) { |
| 51 | // Sonnet/Opus accept adaptive thinking. |
| 52 | for _, m := range []string{"claude-sonnet-5", "claude-opus-4-8"} { |
| 53 | if adaptiveThinking(m)["type"] != "adaptive" { |
| 54 | t.Errorf("%s should use adaptive thinking", m) |
| 55 | } |
| 56 | } |
| 57 | // Haiku rejects it → nil (omitted from the request). |
| 58 | for _, m := range []string{"claude-haiku-4-5", "claude-haiku-4-5-20251001"} { |
| 59 | if adaptiveThinking(m) != nil { |
| 60 | t.Errorf("%s must send no thinking field, got %v", m, adaptiveThinking(m)) |
| 61 | } |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | // TestPromisedButtonCatchesFetchPromises pins the self-heal trigger: the |
| 66 | // classic button-promises AND the fetch-promise family — the exact live stall |
| 67 | // ("Great! Let me pull up the open issues for you.", no tool call, no chip, |
| 68 | // nothing happens) must match, ordinary conversation must not. |
| 69 | func TestPromisedButtonCatchesFetchPromises(t *testing.T) { |
| 70 | matches := []string{ |
| 71 | "Tap the button below.", |
| 72 | "Here's a chip for that.", |
| 73 | "Great! Let me pull up the open issues for you.", |
| 74 | "I'll fetch the list now.", |
| 75 | "Sure — let me check the domains on your account.", |
| 76 | "One moment while I query the repository.", |
| 77 | "I will list them for you.", |
| 78 | "I'll remind you at 4pm.", |
| 79 | "Let me add that to your list.", |
| 80 | } |
| 81 | for _, s := range matches { |
| 82 | if !promisedButton.MatchString(s) { |
| 83 | t.Errorf("should match (a promise with no tool call): %q", s) |
| 84 | } |
| 85 | } |
| 86 | misses := []string{ |
| 87 | "Want help setting up a reminder for yourself instead?", |
| 88 | "Here you go — three domains, all healthy.", |
| 89 | "You can connect GitHub from Integrations.", |
| 90 | "That's a great idea!", |
| 91 | } |
| 92 | for _, s := range misses { |
| 93 | if promisedButton.MatchString(s) { |
| 94 | t.Errorf("should NOT match (ordinary conversation): %q", s) |
| 95 | } |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | // TestRetryToolsIncludeServiceOp pins the forced retry's palette: it must be |
| 100 | // able to propose the op that was promised (service_op — the live stall) and |
| 101 | // must never include the builders (a forced retry cannot trigger a build). |
| 102 | func TestRetryToolsIncludeServiceOp(t *testing.T) { |
| 103 | names := map[string]bool{} |
| 104 | for _, tool := range retryTools() { |
| 105 | names[tool.Name] = true |
| 106 | } |
| 107 | for _, want := range []string{"service_op", "service_research", "app_command"} { |
| 108 | if !names[want] { |
| 109 | t.Fatalf("forced retry cannot propose %s — the promised action stays unfulfillable", want) |
| 110 | } |
| 111 | } |
| 112 | for _, banned := range []string{"create_app", "create_bot", "remove_bot", "read_app_source"} { |
| 113 | if names[banned] { |
| 114 | t.Fatalf("forced retry must never offer %s", banned) |
| 115 | } |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // The app tools (builder + source reader) are offered together, and only when |
| 120 | // the instance served the authoring guide — no guide, no app surface at all. |
| 121 | func TestReplyToolsAppTools(t *testing.T) { |
| 122 | withGuide := &Runtime{appGuide: "the guide"} |
| 123 | names := map[string]bool{} |
| 124 | for _, tool := range withGuide.replyTools(nil) { |
| 125 | names[tool.Name] = true |
| 126 | } |
| 127 | if !names["create_app"] || !names["read_app_source"] { |
| 128 | t.Fatalf("guide present: want create_app + read_app_source, got %v", names) |
| 129 | } |
| 130 | noGuide := &Runtime{} |
| 131 | for _, tool := range noGuide.replyTools(nil) { |
| 132 | if tool.Name == "create_app" || tool.Name == "read_app_source" { |
| 133 | t.Fatalf("no guide: %s must not be offered", tool.Name) |
| 134 | } |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | // The app_command mapping: an exact slash command becomes a run_command chip; |
| 139 | // anything that isn't one clean app command never becomes a chip at all. |
| 140 | func TestActionsFromToolUseAppCommand(t *testing.T) { |
| 141 | got := actionsFromToolUse([]anthBlock{ |
| 142 | {Type: "tool_use", Name: "app_command", Input: map[string]any{"command": " /remind pick up the kids at 4pm "}}, |
| 143 | }) |
| 144 | if len(got) != 1 || got[0]["type"] != "run_command" || got[0]["command"] != "/remind pick up the kids at 4pm" { |
| 145 | t.Fatalf("chip: %+v", got) |
| 146 | } |
| 147 | bad := actionsFromToolUse([]anthBlock{ |
| 148 | {Type: "tool_use", Name: "app_command", Input: map[string]any{"command": "remind me at 4"}}, // no slash |
| 149 | {Type: "tool_use", Name: "app_command", Input: map[string]any{"command": "/todo @here buy milk"}}, // group side effect |
| 150 | {Type: "tool_use", Name: "app_command", Input: map[string]any{"command": "/todo a\nb"}}, // multi-line |
| 151 | {Type: "tool_use", Name: "app_command", Input: map[string]any{"command": "/" + strings.Repeat("x", 300)}}, // too long |
| 152 | }) |
| 153 | if len(bad) != 0 { |
| 154 | t.Fatalf("bad proposals became chips: %+v", bad) |
| 155 | } |
| 156 | } |
| 157 | |