Files
goclaw/internal/http/agents_create_summon_test.go
yatul 4f80824177 feat(agents): let create opt out of background summoning with "summon": false (#1570)
Creating a predefined agent with a description kicks off summoning in a
background goroutine. It finishes 10-20 seconds after the create response and
writes SOUL.md, IDENTITY.md, CAPABILITIES.md and the agent frontmatter.

For a client that manages agents as code that is data loss. Such a client
brings its own context files and writes them through agents.files.set as soon
as create returns; summoning lands afterwards and replaces them. Nothing
fails: no error from the API, nothing in the logs, and the agent quietly runs
on generated text instead of the committed role. The only way to avoid it
today is to poll the agent status until it leaves "summoning" — which means
every automation has to know that summoning exists at all.

POST /v1/agents now accepts an optional "summon" field. Absent or true keeps
today's behaviour, so existing clients are unaffected. False creates the agent
active with the seeded template files and starts no LLM call; summoning is
still available afterwards via POST /v1/agents/{id}/resummon.

The status fallback also now resets an explicitly requested "summoning" status
when nothing will summon the agent: that status is only ever cleared by the
summoner, so such an agent would otherwise stay stuck in it forever.

Surface parity: CLI and WS RPC (agents.create) create agents without a
description and never summoned, so they need no flag; the web UI create flow
wants summoning and keeps the default.
2026-09-21 17:28:58 +07:00

105 lines
3.7 KiB
Go

package http
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func TestShouldSummonOnCreate(t *testing.T) {
cases := []struct {
name string
requested bool
agentType string
description string
haveSummoner bool
want bool
}{
{"default for a described predefined agent", true, store.AgentTypePredefined, "a sarcastic Rust reviewer", true, true},
{"opted out by the client", false, store.AgentTypePredefined, "a sarcastic Rust reviewer", true, false},
{"no description to summon from", true, store.AgentTypePredefined, "", true, false},
{"open agents are never summoned", true, store.AgentTypeOpen, "a sarcastic Rust reviewer", true, false},
{"no summoner configured", true, store.AgentTypePredefined, "a sarcastic Rust reviewer", false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := shouldSummonOnCreate(tc.requested, tc.agentType, tc.description, tc.haveSummoner); got != tc.want {
t.Fatalf("shouldSummonOnCreate = %v, want %v", got, tc.want)
}
})
}
}
// createAgentForSummonTest posts body to the create handler with a summoner
// configured, and returns the created agent's status.
//
// The summoner is deliberately built with nil dependencies: if the handler
// ever starts summoning when the client opted out, SummonAgent would reach
// for them, and the test fails loudly instead of silently passing.
func createAgentForSummonTest(t *testing.T, body string) (string, *gatewayOperatorAgentStore) {
t.Helper()
agents := &gatewayOperatorAgentStore{}
handler := &AgentsHandler{
agents: agents,
defaultWorkspace: "/tmp/workspace",
summoner: NewAgentSummoner(agents, nil, nil, nil),
}
req := httptest.NewRequest(http.MethodPost, "/v1/agents", bytes.NewReader([]byte(body)))
req = req.WithContext(gatewayOperatorContext())
rr := httptest.NewRecorder()
handler.handleCreate(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
var response struct {
ID uuid.UUID `json:"id"`
Status string `json:"status"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v body=%s", err, rr.Body.String())
}
return response.Status, agents
}
func TestAgentsCreateSummonFalseGoesStraightToActive(t *testing.T) {
status, agents := createAgentForSummonTest(t, `{
"agent_key":"scout",
"display_name":"Scout",
"provider":"anthropic",
"model":"claude",
"agent_description":"reads the morning news and writes a digest",
"summon":false
}`)
// Asserting the status is enough to prove summoning never started, with no
// need to wait for it: handleCreate starts the goroutine if and only if the
// status it decided synchronously is "summoning" (`if req.Status ==
// store.AgentStatusSummoning { go h.summoner.SummonAgent(...) }`). So a
// persisted "active" status means the goroutine was not spawned.
if status != store.AgentStatusActive {
t.Fatalf("status=%q, want %q: a client that brings its own context files must not wait for summoning",
status, store.AgentStatusActive)
}
if len(agents.agents) != 1 {
t.Fatalf("expected exactly one created agent, got %d", len(agents.agents))
}
if got := agents.agents[0].Status; got != store.AgentStatusActive {
t.Fatalf("stored status=%q, want %q", got, store.AgentStatusActive)
}
}
func TestAgentsCreateWithoutSummonFieldKeepsSummoning(t *testing.T) {
// No "summon" field: existing clients must keep the old behaviour.
if !shouldSummonOnCreate(true, store.AgentTypePredefined, "reads the morning news", true) {
t.Fatal("omitting the summon field must still summon a described predefined agent")
}
}