package assistant import ( "context" "strconv" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" ) // These run the REAL SDK against a stub Anthropic endpoint. // // No API key is needed and no request leaves the machine, but every byte the // SDK would send is built and every byte it would receive is parsed - which is // where the likely bugs are: a tool schema the API would reject, tool results // split across messages, a principal that fails to reach the tool. type stub struct { *httptest.Server requests []map[string]any replies []string } func newStub(replies ...string) *stub { s := &stub{replies: replies} s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) var parsed map[string]any _ = json.Unmarshal(body, &parsed) s.requests = append(s.requests, parsed) i := len(s.requests) - 1 if i >= len(s.replies) { i = len(s.replies) - 1 } w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, s.replies[i]) })) return s } func textReply(text string) string { return `{"id":"msg_1","type":"message","role":"assistant","model":"claude-opus-5", "content":[{"type":"text","text":` + strconv.Quote(text) + `}], "stop_reason":"end_turn","usage":{"input_tokens":10,"output_tokens":5}}` } func toolReply(name, args string) string { return `{"id":"msg_1","type":"message","role":"assistant","model":"claude-opus-5", "content":[{"type":"tool_use","id":"toolu_1","name":"` + name + `","input":` + args + `}], "stop_reason":"tool_use","usage":{"input_tokens":10,"output_tokens":5}}` } func clientFor(t *testing.T, s *stub) (*Client, *fakeStore) { t.Helper() reg, fs := registry() c := &Client{Tools: reg, APIKey: "test-key", Model: "claude-opus-5"} c.api = newTestAPI(s.URL) return c, fs } func TestAQuestionWithNoToolsReturnsTheAnswer(t *testing.T) { s := newStub(textReply("Everything is working.")) defer s.Close() c, _ := clientFor(t, s) out, err := c.Ask(context.Background(), owner(), []Turn{{Role: "user", Text: "is everything ok"}}) if err != nil { t.Fatal(err) } if out.Text != "Everything is working." { t.Fatalf("got %q", out.Text) } } // The whole tool loop, end to end through the real SDK. func TestAToolCallIsExecutedAndItsResultFedBack(t *testing.T) { s := newStub( toolReply("list_shops", `{}`), textReply("You have one shop, Chennai, and it is online."), ) defer s.Close() c, _ := clientFor(t, s) out, err := c.Ask(context.Background(), owner(), []Turn{{Role: "user", Text: "what shops do I have"}}) if err != nil { t.Fatal(err) } if len(out.Used) != 1 || out.Used[0] != "list_shops" { t.Fatalf("tools used: %v", out.Used) } if !strings.Contains(out.Text, "Chennai") { t.Fatalf("got %q", out.Text) } if len(s.requests) != 2 { t.Fatalf("made %d requests, want 2", len(s.requests)) } // The second request must carry the tool RESULT back, and the real shop // name must be in it - proving the tool actually ran against the store. second, _ := json.Marshal(s.requests[1]) if !strings.Contains(string(second), "tool_result") { t.Fatalf("no tool_result was sent back:\n%s", second) } if !strings.Contains(string(second), "Chennai") { t.Fatalf("the tool result did not contain real data:\n%s", second) } } // Text produced alongside a tool call is thinking-out-loud, not the answer. // Keeping it would prefix every answer with "Let me check that for you." func TestChatterBeforeAToolCallIsNotTheAnswer(t *testing.T) { s := newStub( `{"id":"m","type":"message","role":"assistant","model":"claude-opus-5", "content":[{"type":"text","text":"Let me check."}, {"type":"tool_use","id":"t1","name":"list_shops","input":{}}], "stop_reason":"tool_use","usage":{"input_tokens":1,"output_tokens":1}}`, textReply("One shop, and it is fine."), ) defer s.Close() c, _ := clientFor(t, s) out, _ := c.Ask(context.Background(), owner(), []Turn{{Role: "user", Text: "how are things"}}) if strings.Contains(out.Text, "Let me check") { t.Fatalf("thinking-out-loud leaked into the answer: %q", out.Text) } } // The tenancy line. The model names another company's shop; the tool runs as // the signed-in user and cannot reach it. func TestTheModelCannotReachAnotherCompanyByNamingIt(t *testing.T) { s := newStub( toolReply("check_shop", `{"shop":"Rival Flagship"}`), textReply("I could not find a shop by that name."), ) defer s.Close() c, _ := clientFor(t, s) if _, err := c.Ask(context.Background(), owner(), []Turn{{Role: "user", Text: "check Rival Flagship"}}); err != nil { t.Fatal(err) } second, _ := json.Marshal(s.requests[1]) if strings.Contains(string(second), "site-9") { t.Fatalf("another tenant's data reached the model:\n%s", second) } if !strings.Contains(string(second), "no shop matching") { t.Fatalf("expected a refusal in the tool result:\n%s", second) } } // A tool that errors must come back as a result the model can recover from, // not kill the turn and leave the user with a blank panel. func TestAFailingToolStillProducesAnAnswer(t *testing.T) { s := newStub( toolReply("footfall", `{"from":"nonsense","to":"also nonsense"}`), textReply("I need dates like 2026-09-01."), ) defer s.Close() c, _ := clientFor(t, s) out, err := c.Ask(context.Background(), owner(), []Turn{{Role: "user", Text: "footfall for last tuesday"}}) if err != nil { t.Fatalf("a bad argument killed the turn: %v", err) } if out.Text == "" { t.Fatal("no answer was produced") } } // A model that loops forever must stop, and say something rather than nothing. func TestALoopingModelIsBounded(t *testing.T) { s := newStub(toolReply("list_shops", `{}`)) // always asks for a tool defer s.Close() c, _ := clientFor(t, s) out, err := c.Ask(context.Background(), owner(), []Turn{{Role: "user", Text: "loop"}}) if err != nil { t.Fatal(err) } if len(s.requests) > maxIterations { t.Fatalf("made %d requests, cap is %d", len(s.requests), maxIterations) } if out.Text == "" { t.Fatal("gave up silently - the user would see an empty panel") } } // Every tool must serialise into something the API would accept: a name, a // description, and an object schema. A malformed one is a 400 at runtime. func TestEveryToolSerialisesIntoTheRequest(t *testing.T) { s := newStub(textReply("hello")) defer s.Close() c, reg := clientFor(t, s) _ = reg if _, err := c.Ask(context.Background(), owner(), []Turn{{Role: "user", Text: "hi"}}); err != nil { t.Fatal(err) } sent, _ := json.Marshal(s.requests[0]) for _, tool := range c.Tools.Tools() { if !strings.Contains(string(sent), `"`+tool.Name+`"`) { t.Errorf("tool %q never reached the request", tool.Name) } } // The tools that need arguments must send `required`, or the model may // omit one and the failure surfaces as a confusing "that did not work". if !strings.Contains(string(sent), `"required"`) { t.Errorf("no tool declared required arguments:\n%s", sent) } } // Who is asking has to reach the model, or it cannot say "a manager can do // that" when it refuses something. func TestTheModelIsToldWhoItIsSpeakingTo(t *testing.T) { s := newStub(textReply("hello")) defer s.Close() c, _ := clientFor(t, s) p := owner() p.FullName = "Aravind" if _, err := c.Ask(context.Background(), p, []Turn{{Role: "user", Text: "hi"}}); err != nil { t.Fatal(err) } sent, _ := json.Marshal(s.requests[0]) if !strings.Contains(string(sent), "Aravind") || !strings.Contains(string(sent), "owner") { t.Fatalf("the model was not told who is asking:\n%s", sent) } } // A deployment with no key is a supported configuration, not a fault. func TestNoAPIKeyIsASupportedState(t *testing.T) { c := &Client{Tools: &Registry{}} c.APIKey = "" t.Setenv("ANTHROPIC_API_KEY", "") if c.Configured() { t.Fatal("reported configured with no key") } if _, err := c.Ask(context.Background(), owner(), []Turn{{Role: "user", Text: "hi"}}); !errors.Is(err, ErrNotConfigured) { t.Fatalf("got %v, want ErrNotConfigured", err) } } // ---------------------------------------------------------- workspace id // An identity-linked key is rejected on EVERY endpoint without this header - // including /v1/models, so the id cannot be discovered from the key. It has to // be configuration, and it has to be sent when set. func TestTheWorkspaceHeaderIsSentWhenConfigured(t *testing.T) { var seen string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seen = r.Header.Get("anthropic-workspace-id") w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, textReply("ok")) })) defer srv.Close() reg, _ := registry() c := &Client{Tools: reg, APIKey: "k", Workspace: "wrkspc_test", Model: "claude-sonnet-5"} c.api = newTestAPIWithWorkspace(srv.URL, c.Workspace) if _, err := c.Ask(context.Background(), owner(), []Turn{{Role: "user", Text: "hi"}}); err != nil { t.Fatal(err) } if seen != "wrkspc_test" { t.Fatalf("workspace header was %q, want wrkspc_test", seen) } } // A classic API key needs no workspace and ignores the header, so omitting it // must not break anything. func TestNoWorkspaceHeaderWhenNoneIsConfigured(t *testing.T) { var present bool srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, present = r.Header["Anthropic-Workspace-Id"] w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, textReply("ok")) })) defer srv.Close() reg, _ := registry() c := &Client{Tools: reg, APIKey: "k", Model: "claude-sonnet-5"} c.api = newTestAPI(srv.URL) if _, err := c.Ask(context.Background(), owner(), []Turn{{Role: "user", Text: "hi"}}); err != nil { t.Fatal(err) } if present { t.Fatal("sent an empty workspace header") } } // The operator sees "something went wrong at our end", which is true and // useless when the fix is one environment variable. This is what lets the // handler say the useful thing instead. func TestAMissingWorkspaceIsRecognisedAsMisconfiguration(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) fmt.Fprint(w, `{"type":"error","error":{"type":"invalid_request_error", "message":"anthropic-workspace-id is required when authenticating with an identity-linked API key; send the id of the workspace this request acts in."}}`) })) defer srv.Close() reg, _ := registry() c := &Client{Tools: reg, APIKey: "k", Model: "claude-sonnet-5"} c.api = newTestAPI(srv.URL) _, err := c.Ask(context.Background(), owner(), []Turn{{Role: "user", Text: "hi"}}) if err == nil { t.Fatal("no error") } if !NeedsWorkspace(err) { t.Fatalf("not recognised as a workspace problem: %v", err) } } // The model is a deployment decision, not a rebuild. func TestTheModelCanBeChangedByEnvironment(t *testing.T) { c := &Client{Tools: &Registry{}} if got := c.modelID(); got != "claude-sonnet-5" { t.Errorf("default model is %q", got) } t.Setenv("BEHAVISION_ASSISTANT_MODEL", "claude-haiku-4-5") if got := c.modelID(); got != "claude-haiku-4-5" { t.Errorf("env override ignored, got %q", got) } // An explicit field still wins, so a test or a caller can pin it. c.Model = "claude-opus-5" if got := c.modelID(); got != "claude-opus-5" { t.Errorf("explicit model ignored, got %q", got) } }