Files
cogito/agent_spawn_callback_test.go
Ettore Di Giacinto 67811faaf5 feat: subagent enhancements (#51)
* feat(agent): add AgentID to SessionState for sub-agent tool routing

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(agent): route sub-agent tool calls through parent callback + MCPs with AgentID

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(agent): add AgentDefinition type and WithAgentDefinitions option

* feat(agent): apply agent definition (prompt/tools/limits) on spawn

* feat(clients): add temperature support to OpenAI client

* feat(agent): per-agent model+temperature via WithAgentLLMFactory

* feat(agent): per-agent injection channel and AgentManager.Inject

* feat(agent): add unified send_agent_message resume/inject tool

* feat(agent): detachable foreground spawns + AgentManager.Detach

Register every foreground sub-agent and run it in a goroutine so an
embedder can promote it to the background. spawn_agent now selects on
agent.done (completed -> return result like the old synchronous path),
agent.detach (promoted -> return the ID immediately, goroutine keeps
running), or ctx.Done. Extract the shared goroutine body into runAgent
and add derefFragment so foreground and background share lifecycle
bookkeeping. Stamp withAgentIDStamp(agentID) with the real registry ID
on BOTH foreground and background registered agents so sub-agent tool
calls carry the correct AgentID. Add AgentState.detach and
AgentManager.Detach (errors on unknown / non-detachable agent;
non-blocking send).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(agent): add WithAgentSpawnCallback and AgentState.Type for running-agent signal

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(agent): cover definitions, AgentID approval, and spawn callback in Sub-Agent Spawning suite

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:17:40 +02:00

122 lines
3.5 KiB
Go

package cogito
import (
"context"
"sync"
"testing"
)
// TestWithAgentSpawnCallbackStores asserts the option stores the fn on Options.
func TestWithAgentSpawnCallbackStores(t *testing.T) {
o := defaultOptions()
o.Apply(WithAgentSpawnCallback(func(*AgentState) {}))
if o.agentSpawnCallback == nil {
t.Fatal("spawn callback not stored")
}
}
// TestSpawnCallbackFiresForeground asserts a foreground spawn fires the spawn
// callback with a running AgentState whose Type matches the requested type.
func TestSpawnCallbackFiresForeground(t *testing.T) {
var mu sync.Mutex
var fired bool
var gotStatus AgentStatusType
var gotType string
var nonNil bool
defs := []AgentDefinition{{Name: "explore", SystemPrompt: "You are EXPLORE."}}
runner := &spawnAgentRunner{
llm: newReplyLLM("foreground done"),
manager: NewAgentManager(),
ctx: context.Background(),
agentDefinitions: defs,
// Snapshot the AgentState fields at callback time. The foreground agent
// runs in a goroutine and may mutate Status to "completed" by the time
// Run returns, so we must capture the values inside the callback (while
// Status is still running) rather than reading the live pointer after.
agentSpawnCallback: func(a *AgentState) {
mu.Lock()
fired = true
nonNil = a != nil
if a != nil {
gotStatus = a.Status
gotType = a.Type
}
mu.Unlock()
},
}
_, _, err := runner.Run(SpawnAgentArgs{AgentType: "explore", Task: "look around", Background: false})
if err != nil {
t.Fatalf("foreground spawn errored: %v", err)
}
mu.Lock()
defer mu.Unlock()
if !fired {
t.Fatal("spawn callback did not fire for foreground spawn")
}
if !nonNil {
t.Fatal("spawn callback received a nil AgentState")
}
if gotStatus != AgentStatusRunning {
t.Fatalf("spawn callback AgentState status = %q, want %q", gotStatus, AgentStatusRunning)
}
if gotType != "explore" {
t.Fatalf("spawn callback AgentState type = %q, want %q", gotType, "explore")
}
}
// TestSpawnCallbackFiresBackground asserts a background spawn fires the spawn
// callback synchronously (before Run returns the ID) with a running AgentState
// whose Type matches the requested type.
func TestSpawnCallbackFiresBackground(t *testing.T) {
var mu sync.Mutex
var fired bool
var gotStatus AgentStatusType
var gotType string
var nonNil bool
defs := []AgentDefinition{{Name: "plan", SystemPrompt: "You are PLAN."}}
runner := &spawnAgentRunner{
llm: newReplyLLM("background done"),
manager: NewAgentManager(),
ctx: context.Background(),
agentDefinitions: defs,
// The background spawn fires the callback synchronously (before Run
// returns the ID) while Status is still running, but the agent's
// goroutine may mutate Status afterward, so snapshot inside the callback.
agentSpawnCallback: func(a *AgentState) {
mu.Lock()
fired = true
nonNil = a != nil
if a != nil {
gotStatus = a.Status
gotType = a.Type
}
mu.Unlock()
},
}
out, _, err := runner.Run(SpawnAgentArgs{AgentType: "plan", Task: "make a plan", Background: true})
if err != nil {
t.Fatalf("background spawn errored: %v", err)
}
_ = out
mu.Lock()
defer mu.Unlock()
if !fired {
t.Fatal("spawn callback did not fire for background spawn")
}
if !nonNil {
t.Fatal("spawn callback received a nil AgentState")
}
if gotStatus != AgentStatusRunning {
t.Fatalf("spawn callback AgentState status = %q, want %q", gotStatus, AgentStatusRunning)
}
if gotType != "plan" {
t.Fatalf("spawn callback AgentState type = %q, want %q", gotType, "plan")
}
}