Files
2026-07-13 13:00:08 +08:00

883 lines
35 KiB
Go

package agent
import (
"context"
"errors"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"reasonix/internal/event"
"reasonix/internal/jobs"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
func testTaskContext() context.Context {
return WithParentSession(context.Background(), "parent-session")
}
// TestTaskToolReturnsSubAgentFinalAnswer runs a task against a mock provider
// that emits a single text turn, and verifies the tool returns that text with a
// transcript reference — sub-agent intermediate state isn't supposed to leak.
func TestTaskToolReturnsSubAgentFinalAnswer(t *testing.T) {
sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "found 3 callers of Foo"},
{Type: provider.ChunkDone},
}}
parentReg := tool.NewRegistry()
task := newTestTaskTool(t, sub, parentReg, "test-sys-prompt", "", "", nil)
out, err := task.Execute(testTaskContext(), []byte(`{"prompt":"find callers of Foo"}`))
if err != nil {
t.Fatalf("Execute: %v", err)
}
_ = subagentRefFromOutput(t, out)
if !strings.Contains(out, "found 3 callers of Foo") {
t.Errorf("got %q, want sub-agent final answer", out)
}
if !strings.Contains(out, "To continue this same subagent transcript in a later call, pass this ref as `continue_from`. Start a fresh subagent when the next task is independent.") {
t.Errorf("got %q, want continuation guidance", out)
}
// The sub-agent must have received the prompt as its user message and
// the configured system prompt at the top — proving the session was
// fresh, not the parent's.
if sys := sub.lastReq.Messages[0]; sys.Role != provider.RoleSystem || sys.Content != "test-sys-prompt" {
t.Errorf("first message = %+v, want system 'test-sys-prompt'", sys)
}
if got := lastUser(sub.lastReq); !strings.Contains(got, `<subagent-context event="SubagentStart">`) || !strings.HasSuffix(got, "find callers of Foo") {
t.Errorf("sub-agent user = %q, want SubagentStart context plus prompt", got)
}
}
func TestSubagentResultWarnsOnHostDecisionLanguage(t *testing.T) {
out := GuardSubagentHostDecisionText("等待用户批准后再执行修改")
if !strings.Contains(out, "Subagent boundary") {
t.Fatalf("guarded output missing boundary warning:\n%s", out)
}
plain := "found 3 callers of Foo"
if got := GuardSubagentHostDecisionText(plain); got != plain {
t.Fatalf("plain output changed: %q", got)
}
}
func TestTaskToolInjectsWorkspaceContextIntoSubagentPrompt(t *testing.T) {
sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "answer"},
{Type: provider.ChunkDone},
}}
workspace := t.TempDir()
task := NewTaskTool(sub, nil, tool.NewRegistry(), 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
WithTranscripts(NewSubagentStore(t.TempDir()), workspace, "base-model", "base-effort")
if _, err := task.Execute(testTaskContext(), []byte(`{"prompt":"inspect project"}`)); err != nil {
t.Fatalf("Execute: %v", err)
}
if sys := sub.lastReq.Messages[0]; sys.Role != provider.RoleSystem || sys.Content != "sys" {
t.Fatalf("system prompt = %+v, want original prompt", sys)
}
got := lastUser(sub.lastReq)
if !strings.Contains(got, `<workspace-context event="SubagentWorkspace">`) ||
!strings.Contains(got, "Current workspace: "+strconv.Quote(workspace)) ||
!strings.Contains(got, `prefer "." or relative paths`) ||
!strings.HasSuffix(got, "inspect project") {
t.Fatalf("sub-agent user = %q, want workspace context plus prompt", got)
}
}
func TestTaskToolCancelDuringStuckProviderReturnsPromptly(t *testing.T) {
task := newTestTaskTool(t, stuckStreamProvider{}, tool.NewRegistry(), "sys", "", "", nil)
ctx, cancel := context.WithCancel(testTaskContext())
done := make(chan error, 1)
go func() {
_, err := task.Execute(ctx, []byte(`{"prompt":"wait on stuck provider"}`))
done <- err
}()
time.Sleep(50 * time.Millisecond)
cancel()
select {
case err := <-done:
if err == nil {
t.Fatal("Execute returned nil after context cancellation")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("Execute error = %v, want context cancellation", err)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("TaskTool.Execute did not return promptly after cancellation")
}
}
func TestTaskToolSchemaExposesOnlyContinueFromForPersistence(t *testing.T) {
task := NewTaskTool(&mockProvider{name: "sub"}, nil, tool.NewRegistry(), 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil)
schema := string(task.Schema())
if !strings.Contains(schema, `"continue_from"`) {
t.Fatalf("task schema = %s, want continue_from", schema)
}
if strings.Contains(schema, "fork_from") {
t.Fatalf("task schema = %s, want no fork_from", schema)
}
}
func TestParallelTasksSchemaDoesNotExposePersistentContinuation(t *testing.T) {
task := NewTaskTool(&mockProvider{name: "sub"}, nil, tool.NewRegistry(), 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil)
parallel := NewParallelTasksTool(task, tool.NewRegistry())
schema := string(parallel.Schema())
if strings.Contains(schema, "continue_from") || strings.Contains(schema, "fork_from") {
t.Fatalf("parallel_tasks schema = %s, want no persistent continuation fields", schema)
}
}
func TestTaskToolInheritsReasoningLanguageFromContext(t *testing.T) {
sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "done"},
{Type: provider.ChunkDone},
}}
task := newTestTaskTool(t, sub, tool.NewRegistry(), "sys", "", "", nil)
ctx := WithReasoningLanguagePreference(testTaskContext(), "zh")
if _, err := task.Execute(ctx, []byte(`{"prompt":"inspect auth"}`)); err != nil {
t.Fatalf("Execute: %v", err)
}
got := lastUser(sub.lastReq)
if !strings.HasPrefix(got, "<reasoning-language>") || !strings.Contains(got, "简体中文") || !strings.HasSuffix(got, "inspect auth") {
t.Fatalf("sub-agent user = %q, want reasoning-language-prefixed prompt", got)
}
}
// TestTaskToolFiltersTools verifies the whitelist behaviour: when the caller
// names a subset of tools, the sub-agent's registry contains exactly that set
// with recursive delegation tools available while max_subagent_depth leaves one
// more layer.
func TestTaskToolFiltersTools(t *testing.T) {
sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "ok"},
{Type: provider.ChunkDone},
}}
parentReg := tool.NewRegistry()
parentReg.Add(fakeTool{name: "read_file", readOnly: true})
parentReg.Add(fakeTool{name: "write_file", readOnly: false})
parentReg.Add(fakeTool{name: "bash", readOnly: false})
task := newTestTaskTool(t, sub, parentReg, "sys", "", "", nil)
parentReg.Add(task) // simulate the wiring in cli.setup
parentReg.Add(fakeTool{name: "run_skill", readOnly: false})
parentReg.Add(fakeTool{name: "read_only_skill", readOnly: true})
parentReg.Add(fakeTool{name: "research", readOnly: false})
args := []byte(`{"prompt":"x","tools":["read_file","task","write_file","run_skill","read_only_skill","research"]}`)
if _, err := task.Execute(testTaskContext(), args); err != nil {
t.Fatalf("Execute: %v", err)
}
// The sub-agent's tool schemas should reflect the whitelist minus always
// unavailable background/install tools. Recursive tools stay visible at depth 1.
got := map[string]bool{}
for _, s := range sub.lastReq.Tools {
got[s.Name] = true
}
for _, want := range []string{"read_file", "write_file", "task", "run_skill", "read_only_skill", "research"} {
if !got[want] {
t.Errorf("sub-agent tools = %v, want %q exposed at depth 1", got, want)
}
}
if got["bash"] {
t.Errorf("sub-agent tools = %v, want bash omitted when not requested", got)
}
}
// TestTaskToolDefaultsToParentToolsWithDepthRemaining covers the no-whitelist
// path: the first-layer sub-agent inherits parent tools except always-hidden
// background/install tools because it still has one delegation layer available.
func TestTaskToolDefaultsToParentToolsWithoutMetaTools(t *testing.T) {
sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "ok"},
{Type: provider.ChunkDone},
}}
parentReg := tool.NewRegistry()
parentReg.Add(fakeTool{name: "read_file", readOnly: true})
parentReg.Add(fakeTool{name: "grep", readOnly: true})
task := newTestTaskTool(t, sub, parentReg, "sys", "", "", nil)
parentReg.Add(task)
parentReg.Add(fakeTool{name: "run_skill", readOnly: false})
parentReg.Add(fakeTool{name: "read_only_skill", readOnly: true})
parentReg.Add(fakeTool{name: "explore", readOnly: false})
parentReg.Add(fakeTool{name: "research", readOnly: false})
parentReg.Add(fakeTool{name: "review", readOnly: false})
parentReg.Add(fakeTool{name: "security_review", readOnly: false})
parentReg.Add(fakeTool{name: "remember", readOnly: false})
if _, err := task.Execute(testTaskContext(), []byte(`{"prompt":"x"}`)); err != nil {
t.Fatalf("Execute: %v", err)
}
got := map[string]bool{}
for _, s := range sub.lastReq.Tools {
got[s.Name] = true
}
for _, want := range []string{"read_file", "grep", "remember", "task", "run_skill", "read_only_skill", "explore", "research", "review", "security_review"} {
if !got[want] {
t.Errorf("default sub-agent tools = %v, want %q inherited at depth 1", got, want)
}
}
}
func TestTaskToolAllowsSecondLayerAndStopsThere(t *testing.T) {
sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "depth two answer"},
{Type: provider.ChunkDone},
}}
parentReg := tool.NewRegistry()
parentReg.Add(fakeTool{name: "read_file", readOnly: true})
task := newTestTaskTool(t, sub, parentReg, "sys", "", "", nil).WithMaxSubagentDepth(2)
parentReg.Add(task)
parentReg.Add(fakeTool{name: "run_skill", readOnly: false})
depthOneCtx := WithSubagentDepth(testTaskContext(), 1)
if _, err := task.Execute(depthOneCtx, []byte(`{"prompt":"spawn second layer"}`)); err != nil {
t.Fatalf("depth-1 task should be able to spawn depth 2: %v", err)
}
got := map[string]bool{}
for _, s := range sub.lastReq.Tools {
got[s.Name] = true
}
if got["task"] || got["run_skill"] {
t.Fatalf("depth-2 child should not receive recursive tools; tools=%v", toolSchemaNames(sub.lastReq.Tools))
}
depthTwoCtx := WithSubagentDepth(testTaskContext(), 2)
if _, err := task.Execute(depthTwoCtx, []byte(`{"prompt":"spawn third layer"}`)); err == nil || !strings.Contains(err.Error(), "subagent delegation depth limit reached") {
t.Fatalf("depth-2 task error = %v, want depth limit", err)
}
}
func TestTaskToolMaxSubagentDepthOneRestoresSingleLayerBoundary(t *testing.T) {
sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "single layer answer"},
{Type: provider.ChunkDone},
}}
parentReg := tool.NewRegistry()
parentReg.Add(fakeTool{name: "read_file", readOnly: true})
task := newTestTaskTool(t, sub, parentReg, "sys", "", "", nil).WithMaxSubagentDepth(1)
parentReg.Add(task)
parentReg.Add(fakeTool{name: "run_skill", readOnly: false})
parentReg.Add(fakeTool{name: "read_only_task", readOnly: true})
if _, err := task.Execute(testTaskContext(), []byte(`{"prompt":"single layer"}`)); err != nil {
t.Fatalf("root task should still spawn first-layer subagent: %v", err)
}
got := map[string]bool{}
for _, s := range sub.lastReq.Tools {
got[s.Name] = true
}
for _, hidden := range []string{"task", "run_skill", "read_only_task"} {
if got[hidden] {
t.Fatalf("max_subagent_depth=1 should hide recursive tool %q; tools=%v", hidden, toolSchemaNames(sub.lastReq.Tools))
}
}
depthOneCtx := WithSubagentDepth(testTaskContext(), 1)
if _, err := task.Execute(depthOneCtx, []byte(`{"prompt":"too deep"}`)); err == nil || !strings.Contains(err.Error(), "max_subagent_depth=1") {
t.Fatalf("depth-1 task error = %v, want max depth rejection", err)
}
}
func TestTaskToolUsesConfiguredProfileForExecution(t *testing.T) {
parent := &mockProvider{name: "parent", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "parent answer"},
{Type: provider.ChunkDone},
}}
resolved := &mockProvider{name: "resolved", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "resolved answer"},
{Type: provider.ChunkDone},
}}
var gotModel, gotEffort string
resolve := func(model, effort string) (provider.Provider, *provider.Pricing, int, error) {
gotModel, gotEffort = model, effort
return resolved, nil, 0, nil
}
task := newTestTaskTool(t, parent, tool.NewRegistry(), "sys", "deepseek-pro", "max", resolve)
out, err := task.Execute(testTaskContext(), []byte(`{"prompt":"x"}`))
if err != nil {
t.Fatalf("Execute: %v", err)
}
if !strings.Contains(out, "resolved answer") {
t.Fatalf("sub-agent did not use resolved provider, got %q", out)
}
if gotModel != "deepseek-pro" || gotEffort != "max" {
t.Fatalf("resolved profile = %q/%q, want deepseek-pro/max", gotModel, gotEffort)
}
}
func TestTaskToolReturnsProfileResolutionErrors(t *testing.T) {
parent := &mockProvider{name: "parent", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "parent answer"},
{Type: provider.ChunkDone},
}}
resolve := func(string, string) (provider.Provider, *provider.Pricing, int, error) {
return nil, nil, 0, errors.New("bad effort")
}
task := newTestTaskTool(t, parent, tool.NewRegistry(), "sys", "", "", resolve)
_, err := task.Execute(testTaskContext(), []byte(`{"prompt":"x","effort":"turbo"}`))
if err == nil || !strings.Contains(err.Error(), "bad effort") {
t.Fatalf("Execute error = %v, want profile resolution error", err)
}
}
func TestTaskToolRequiresTranscriptStore(t *testing.T) {
sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "answer"},
{Type: provider.ChunkDone},
}}
task := NewTaskTool(sub, nil, tool.NewRegistry(), 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil)
_, err := task.Execute(testTaskContext(), []byte(`{"prompt":"x"}`))
if err == nil || !strings.Contains(err.Error(), "transcript store is required") {
t.Fatalf("Execute error = %v, want transcript store requirement", err)
}
}
// TestTaskToolRunsEphemerallyWithoutParentSession mirrors headless `reasonix run`:
// the store is wired but the context carries no parent session, so the sub-agent
// must run without persistence and return its plain answer (no transcript ref).
func TestTaskToolRunsEphemerallyWithoutParentSession(t *testing.T) {
sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "headless answer"},
{Type: provider.ChunkDone},
}}
task := newTestTaskTool(t, sub, tool.NewRegistry(), "sys", "", "", nil)
out, err := task.Execute(context.Background(), []byte(`{"prompt":"x"}`))
if err != nil {
t.Fatalf("Execute: %v", err)
}
if !strings.Contains(out, "headless answer") {
t.Fatalf("got %q, want sub-agent final answer", out)
}
if strings.Contains(out, "Subagent reference") {
t.Fatalf("ephemeral run should not emit a transcript reference: %q", out)
}
}
func TestReadOnlyTaskToolRunsEphemerallyWithReadOnlyRegistry(t *testing.T) {
sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "read-only findings"},
{Type: provider.ChunkDone},
}}
parentReg := tool.NewRegistry()
parentReg.Add(fakeTool{name: "read_file", readOnly: true})
parentReg.Add(fakeTool{name: "write_file", readOnly: false})
parentReg.Add(fakeTool{name: "todo_write", readOnly: true})
parentReg.Add(fakeTool{name: "complete_step", readOnly: true})
parentReg.Add(fakeTool{name: "connect_tool_source", readOnly: true})
parentReg.Add(fakeTool{name: "read_only_skill", readOnly: true})
parentReg.Add(fakeTool{name: "bash", readOnly: false})
task := newTestTaskTool(t, sub, parentReg, "writer sys", "", "", nil)
readonly := NewReadOnlyTaskTool(task)
parentReg.Add(task)
parentReg.Add(readonly)
out, err := readonly.Execute(testTaskContext(), []byte(`{"prompt":"inspect callers"}`))
if err != nil {
t.Fatalf("Execute: %v", err)
}
if !strings.Contains(out, "read-only findings") {
t.Fatalf("output = %q, want final answer", out)
}
if strings.Contains(out, "Subagent reference") {
t.Fatalf("read_only_task should not persist transcript refs: %q", out)
}
if sys := sub.lastReq.Messages[0]; sys.Role != provider.RoleSystem || sys.Content != DefaultReadOnlyTaskSystemPrompt {
t.Fatalf("read_only_task system prompt = %+v, want read-only prompt", sys)
}
if got := lastUser(sub.lastReq); !strings.Contains(got, "Current workspace: ") || !strings.HasSuffix(got, "inspect callers") {
t.Fatalf("read_only_task user = %q, want workspace context plus prompt", got)
}
got := map[string]bool{}
for _, s := range sub.lastReq.Tools {
got[s.Name] = true
}
for _, want := range []string{"read_file", "bash"} {
if !got[want] {
t.Fatalf("read_only_task sub-agent missing %q; tools=%v", want, toolSchemaNames(sub.lastReq.Tools))
}
}
for _, hidden := range []string{"write_file", "todo_write", "complete_step", "connect_tool_source", "task"} {
if got[hidden] {
t.Fatalf("read_only_task sub-agent should hide %q; tools=%v", hidden, toolSchemaNames(sub.lastReq.Tools))
}
}
for _, want := range []string{"read_only_task", "read_only_skill"} {
if !got[want] {
t.Fatalf("read_only_task depth-1 sub-agent should expose %q; tools=%v", want, toolSchemaNames(sub.lastReq.Tools))
}
}
}
func TestTaskToolRejectsContinuationWithoutParentSession(t *testing.T) {
sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "answer"},
{Type: provider.ChunkDone},
}}
task := newTestTaskTool(t, sub, tool.NewRegistry(), "sys", "", "", nil)
_, err := task.Execute(context.Background(), []byte(`{"prompt":"x","continue_from":"sa_whatever"}`))
if err == nil || !strings.Contains(err.Error(), "persisted session") {
t.Fatalf("Execute error = %v, want persisted-session requirement", err)
}
}
func TestTaskToolPersistsAndContinuesTranscript(t *testing.T) {
sub := &mockProvider{name: "sub", streams: [][]provider.Chunk{
{
{Type: provider.ChunkText, Text: "first answer"},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkText, Text: "second answer"},
{Type: provider.ChunkDone},
},
}}
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "read_file", readOnly: true})
store := NewSubagentStore(t.TempDir())
task := newTestTaskTool(t, sub, reg, "sys", "", "", nil).
WithTranscripts(store, t.TempDir(), "base-model", "base-effort")
first, err := task.Execute(testTaskContext(), []byte(`{"prompt":"first task"}`))
if err != nil {
t.Fatalf("first Execute: %v", err)
}
ref := subagentRefFromOutput(t, first)
meta, err := store.LoadMeta(ref)
if err != nil {
t.Fatalf("LoadMeta: %v", err)
}
if meta.ParentSession != "parent-session" {
t.Fatalf("parent session = %q, want parent-session", meta.ParentSession)
}
if !strings.Contains(first, "first answer") {
t.Fatalf("first output = %q, want answer", first)
}
second, err := task.Execute(testTaskContext(), []byte(`{"prompt":"second task","continue_from":"`+ref+`"}`))
if err != nil {
t.Fatalf("second Execute: %v", err)
}
if !strings.Contains(second, "second answer") {
t.Fatalf("second output = %q, want answer", second)
}
if len(sub.requests) != 2 {
t.Fatalf("provider requests = %d, want 2", len(sub.requests))
}
msgs := sub.requests[1].Messages
if len(msgs) < 4 {
t.Fatalf("continued request messages = %+v, want prior transcript plus new task", msgs)
}
if !strings.HasSuffix(msgs[1].Content, "first task") || msgs[2].Content != "first answer" || !strings.HasSuffix(lastUser(sub.requests[1]), "second task") {
t.Fatalf("continued request messages = %+v, want first task/answer then second task", msgs)
}
}
func TestTaskToolContinueFromAncestorReturnsCopiedReferenceGuidance(t *testing.T) {
sub := &mockProvider{name: "sub", streams: [][]provider.Chunk{
{
{Type: provider.ChunkText, Text: "root answer"},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkText, Text: "child answer"},
{Type: provider.ChunkDone},
},
}}
sessionDir := t.TempDir()
store := NewSubagentStore(filepath.Join(sessionDir, "subagents"))
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "read_file", readOnly: true})
task := NewTaskTool(sub, nil, reg, 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
WithTranscripts(store, t.TempDir(), "base-model", "base-effort")
rootCtx := WithParentSession(context.Background(), "root")
first, err := task.Execute(rootCtx, []byte(`{"prompt":"root task"}`))
if err != nil {
t.Fatalf("first Execute: %v", err)
}
rootRef := subagentRefFromOutput(t, first)
if err := SaveBranchMeta(filepath.Join(sessionDir, "root.jsonl"), BranchMeta{}); err != nil {
t.Fatalf("SaveBranchMeta root: %v", err)
}
if err := SaveBranchMeta(filepath.Join(sessionDir, "child.jsonl"), BranchMeta{ParentID: "root"}); err != nil {
t.Fatalf("SaveBranchMeta child: %v", err)
}
childCtx := WithParentSession(context.Background(), "child")
second, err := task.Execute(childCtx, []byte(`{"prompt":"child task","continue_from":"`+rootRef+`"}`))
if err != nil {
t.Fatalf("second Execute: %v", err)
}
childRef := subagentRefFromOutput(t, second)
if childRef == rootRef {
t.Fatalf("child ref = source ref %q, want copied ref", childRef)
}
if !strings.Contains(second, "Forked from: "+rootRef) {
t.Fatalf("second output = %q, want Forked from source ref", second)
}
if !strings.Contains(second, "The requested ref resolves to an ancestor conversation transcript") {
t.Fatalf("second output = %q, want ancestor-copy guidance", second)
}
if !strings.Contains(second, "Final answer:\nchild answer") {
t.Fatalf("second output = %q, want final answer", second)
}
}
func TestTaskToolLegacyForkFromAncestorConvertsToCopiedReference(t *testing.T) {
sub := &mockProvider{name: "sub", streams: [][]provider.Chunk{
{
{Type: provider.ChunkText, Text: "root answer"},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkText, Text: "child answer"},
{Type: provider.ChunkDone},
},
}}
sessionDir := t.TempDir()
store := NewSubagentStore(filepath.Join(sessionDir, "subagents"))
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "read_file", readOnly: true})
task := NewTaskTool(sub, nil, reg, 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
WithTranscripts(store, t.TempDir(), "base-model", "base-effort")
rootCtx := WithParentSession(context.Background(), "root")
first, err := task.Execute(rootCtx, []byte(`{"prompt":"root task"}`))
if err != nil {
t.Fatalf("first Execute: %v", err)
}
rootRef := subagentRefFromOutput(t, first)
if err := SaveBranchMeta(filepath.Join(sessionDir, "root.jsonl"), BranchMeta{}); err != nil {
t.Fatalf("SaveBranchMeta root: %v", err)
}
if err := SaveBranchMeta(filepath.Join(sessionDir, "child.jsonl"), BranchMeta{ParentID: "root"}); err != nil {
t.Fatalf("SaveBranchMeta child: %v", err)
}
childCtx := WithParentSession(context.Background(), "child")
second, err := task.Execute(childCtx, []byte(`{"prompt":"child task","fork_from":"`+rootRef+`"}`))
if err != nil {
t.Fatalf("second Execute: %v", err)
}
childRef := subagentRefFromOutput(t, second)
if childRef == rootRef {
t.Fatalf("child ref = source ref %q, want copied ref", childRef)
}
if !strings.Contains(second, "Forked from: "+rootRef) ||
!strings.Contains(second, "Final answer:\nchild answer") {
t.Fatalf("second output = %q, want copied reference guidance and final answer", second)
}
}
func TestTaskToolRejectsLegacyForkFromCurrentSession(t *testing.T) {
sub := &mockProvider{name: "sub", streams: [][]provider.Chunk{
{
{Type: provider.ChunkText, Text: "first answer"},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkText, Text: "should not run"},
{Type: provider.ChunkDone},
},
}}
task := newTestTaskTool(t, sub, tool.NewRegistry(), "sys", "", "", nil).
WithTranscripts(NewSubagentStore(t.TempDir()), t.TempDir(), "base-model", "base-effort")
first, err := task.Execute(testTaskContext(), []byte(`{"prompt":"first task"}`))
if err != nil {
t.Fatalf("first Execute: %v", err)
}
ref := subagentRefFromOutput(t, first)
_, err = task.Execute(testTaskContext(), []byte(`{"prompt":"second task","fork_from":"`+ref+`"}`))
if err == nil || !strings.Contains(err.Error(), "cannot be safely converted") {
t.Fatalf("legacy fork error = %v, want unsafe conversion rejection", err)
}
if len(sub.requests) != 1 {
t.Fatalf("provider requests = %d, want only first run", len(sub.requests))
}
}
func TestTaskToolFailedForegroundContinuationPersistsAndRejectsReuse(t *testing.T) {
sub := &mockProvider{name: "sub", streams: [][]provider.Chunk{
{
{Type: provider.ChunkText, Text: "first answer"},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkError, Err: errors.New("provider failed")},
},
}}
store := NewSubagentStore(t.TempDir())
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "read_file", readOnly: true})
task := NewTaskTool(sub, nil, reg, 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
WithTranscripts(store, t.TempDir(), "base-model", "base-effort")
first, err := task.Execute(testTaskContext(), []byte(`{"prompt":"first task"}`))
if err != nil {
t.Fatalf("first Execute: %v", err)
}
ref := subagentRefFromOutput(t, first)
_, err = task.Execute(testTaskContext(), []byte(`{"prompt":"second task","continue_from":"`+ref+`"}`))
if err == nil || !strings.Contains(err.Error(), "provider failed") {
t.Fatalf("second Execute error = %v, want provider failure", err)
}
meta, err := store.LoadMeta(ref)
if err != nil {
t.Fatalf("LoadMeta: %v", err)
}
if meta.Status != SubagentFailed {
t.Fatalf("status = %q, want failed", meta.Status)
}
loaded, err := LoadSession(store.sessionPath(ref))
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
msgs := loaded.Snapshot()
if len(msgs) != 4 || !strings.HasSuffix(msgs[1].Content, "first task") || msgs[2].Content != "first answer" || !strings.HasSuffix(msgs[3].Content, "second task") {
t.Fatalf("failed continuation transcript = %+v, want first task/answer plus second task", msgs)
}
if _, err := task.Execute(testTaskContext(), []byte(`{"prompt":"third task","continue_from":"`+ref+`"}`)); err == nil || !strings.Contains(err.Error(), "failed and cannot be continued") {
t.Fatalf("reuse error = %v, want failed ref rejection", err)
}
}
func TestTaskToolBackgroundPanicPersistsFailedMetadata(t *testing.T) {
sub := panicProvider{name: "panic-sub"}
store := NewSubagentStore(t.TempDir())
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "read_file", readOnly: true})
task := NewTaskTool(sub, nil, reg, 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
WithTranscripts(store, t.TempDir(), "base-model", "base-effort")
jm := jobs.NewManager(event.Discard)
defer jm.Close()
ctx := testTaskContext()
ctx = jobs.WithSession(ctx, "parent-session")
ctx = jobs.WithManager(ctx, jm)
out, err := task.Execute(ctx, []byte(`{"prompt":"panic task","run_in_background":true}`))
if err != nil {
t.Fatalf("Execute: %v", err)
}
ref := subagentRefFromOutput(t, out)
jobID := extractJobID(out)
if jobID == "" {
t.Fatalf("no background job id in output:\n%s", out)
}
res := jm.WaitForSession(context.Background(), "parent-session", []string{jobID}, 5)
if len(res) != 1 || res[0].Status != jobs.Failed {
t.Fatalf("background job result = %+v, want failed", res)
}
if !strings.Contains(res[0].Output, "Subagent reference (failed): "+ref) {
t.Fatalf("job output = %q, want failed subagent ref %s", res[0].Output, ref)
}
meta, err := store.LoadMeta(ref)
if err != nil {
t.Fatalf("LoadMeta: %v", err)
}
if meta.Status != SubagentFailed {
t.Fatalf("status = %q, want failed", meta.Status)
}
if _, err := task.Execute(testTaskContext(), []byte(`{"prompt":"again","continue_from":"`+ref+`"}`)); err == nil || !strings.Contains(err.Error(), "failed and cannot be continued") {
t.Fatalf("reuse error = %v, want failed continuation rejection", err)
}
}
func TestTaskToolBackgroundResultIncludesReferenceGuidance(t *testing.T) {
sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "background answer"},
{Type: provider.ChunkDone},
}}
store := NewSubagentStore(t.TempDir())
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "read_file", readOnly: true})
task := NewTaskTool(sub, nil, reg, 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
WithTranscripts(store, t.TempDir(), "base-model", "base-effort")
jm := jobs.NewManager(event.Discard)
defer jm.Close()
ctx := testTaskContext()
ctx = jobs.WithSession(ctx, "parent-session")
ctx = jobs.WithManager(ctx, jm)
out, err := task.Execute(ctx, []byte(`{"prompt":"background task","run_in_background":true}`))
if err != nil {
t.Fatalf("Execute: %v", err)
}
ref := subagentRefFromOutput(t, out)
if !strings.Contains(out, "To continue this same subagent transcript in a later call") {
t.Fatalf("start output = %q, want reference guidance", out)
}
jobID := extractJobID(out)
if jobID == "" {
t.Fatalf("no background job id in output:\n%s", out)
}
res := jm.WaitForSession(context.Background(), "parent-session", []string{jobID}, 5)
if len(res) != 1 || res[0].Status != jobs.Done {
t.Fatalf("background job result = %+v, want succeeded", res)
}
if !strings.Contains(res[0].Output, "Subagent reference: "+ref) ||
!strings.Contains(res[0].Output, "To continue this same subagent transcript in a later call") ||
!strings.Contains(res[0].Output, "Final answer:\nbackground answer") {
t.Fatalf("job output = %q, want reference guidance and final answer", res[0].Output)
}
}
func TestTaskToolBackgroundAncestorContinuationIncludesForkGuidance(t *testing.T) {
sub := &mockProvider{name: "sub", streams: [][]provider.Chunk{
{
{Type: provider.ChunkText, Text: "root answer"},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkText, Text: "child background answer"},
{Type: provider.ChunkDone},
},
}}
sessionDir := t.TempDir()
store := NewSubagentStore(filepath.Join(sessionDir, "subagents"))
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "read_file", readOnly: true})
task := NewTaskTool(sub, nil, reg, 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
WithTranscripts(store, t.TempDir(), "base-model", "base-effort")
rootCtx := WithParentSession(context.Background(), "root")
rootOut, err := task.Execute(rootCtx, []byte(`{"prompt":"root task"}`))
if err != nil {
t.Fatalf("root Execute: %v", err)
}
rootRef := subagentRefFromOutput(t, rootOut)
if err := SaveBranchMeta(filepath.Join(sessionDir, "root.jsonl"), BranchMeta{}); err != nil {
t.Fatalf("SaveBranchMeta root: %v", err)
}
if err := SaveBranchMeta(filepath.Join(sessionDir, "child.jsonl"), BranchMeta{ParentID: "root"}); err != nil {
t.Fatalf("SaveBranchMeta child: %v", err)
}
jm := jobs.NewManager(event.Discard)
defer jm.Close()
childCtx := WithParentSession(context.Background(), "child")
childCtx = jobs.WithSession(childCtx, "child")
childCtx = jobs.WithManager(childCtx, jm)
startOut, err := task.Execute(childCtx, []byte(`{"prompt":"child task","continue_from":"`+rootRef+`","run_in_background":true}`))
if err != nil {
t.Fatalf("child Execute: %v", err)
}
childRef := subagentRefFromOutput(t, startOut)
if childRef == rootRef {
t.Fatalf("child ref = source ref %q, want copied ref", childRef)
}
if !strings.Contains(startOut, "Forked from: "+rootRef) ||
!strings.Contains(startOut, "The requested ref resolves to an ancestor conversation transcript") ||
strings.Contains(startOut, "Final answer:") {
t.Fatalf("start output = %q, want fork guidance without final answer", startOut)
}
jobID := extractJobID(startOut)
if jobID == "" {
t.Fatalf("no background job id in output:\n%s", startOut)
}
res := jm.WaitForSession(context.Background(), "child", []string{jobID}, 5)
if len(res) != 1 || res[0].Status != jobs.Done {
t.Fatalf("background job result = %+v, want succeeded", res)
}
if !strings.Contains(res[0].Output, "Subagent reference: "+childRef) ||
!strings.Contains(res[0].Output, "Forked from: "+rootRef) ||
!strings.Contains(res[0].Output, "The requested ref resolves to an ancestor conversation transcript") ||
!strings.Contains(res[0].Output, "Final answer:\nchild background answer") {
t.Fatalf("job output = %q, want copied ref guidance and final answer", res[0].Output)
}
}
func TestTaskToolRejectsMismatchedContinuationProfile(t *testing.T) {
sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "answer"},
{Type: provider.ChunkDone},
}}
task := newTestTaskTool(t, sub, tool.NewRegistry(), "sys", "", "", nil).
WithTranscripts(NewSubagentStore(t.TempDir()), t.TempDir(), "base-model", "")
out, err := task.Execute(testTaskContext(), []byte(`{"prompt":"first task"}`))
if err != nil {
t.Fatalf("first Execute: %v", err)
}
ref := subagentRefFromOutput(t, out)
_, err = task.Execute(testTaskContext(), []byte(`{"prompt":"second task","continue_from":"`+ref+`","model":"other-model"}`))
if err == nil || !strings.Contains(err.Error(), "model/effort") {
t.Fatalf("mismatched model error = %v, want compatibility failure", err)
}
}
func extractJobID(msg string) string {
quote := strings.Index(msg, `"`)
if quote < 0 {
return ""
}
end := strings.Index(msg[quote+1:], `"`)
if end < 0 {
return ""
}
return msg[quote+1 : quote+1+end]
}
func subagentRefFromOutput(t *testing.T, out string) string {
t.Helper()
for _, line := range strings.Split(out, "\n") {
if strings.HasPrefix(line, "Subagent reference: ") {
return strings.TrimSpace(strings.TrimPrefix(line, "Subagent reference: "))
}
}
t.Fatalf("no subagent reference in output:\n%s", out)
return ""
}
func TestSubSinkForwardsUsageToParent(t *testing.T) {
var got []event.Event
parent := event.FuncSink(func(e event.Event) {
got = append(got, e)
})
subSinkFor("task_1", parent).Emit(event.Event{
Kind: event.Usage,
Usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 2, TotalTokens: 12},
UsageSource: event.UsageSourceSubagent,
})
if len(got) != 1 || got[0].Usage == nil || got[0].UsageSource != event.UsageSourceSubagent {
t.Fatalf("forwarded events = %+v, want subagent usage", got)
}
}
func TestTaskToolCarriesRecentKeepIntoSubsessions(t *testing.T) {
task := NewTaskTool(&mockProvider{name: "sub"}, nil, tool.NewRegistry(), 20, 0, 7, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil)
if task.recentKeep != 7 {
t.Fatalf("recentKeep = %d, want 7", task.recentKeep)
}
}
func newTestTaskTool(t *testing.T, prov provider.Provider, reg *tool.Registry, sysPrompt, subagentModel, subagentEffort string, resolve func(string, string) (provider.Provider, *provider.Pricing, int, error)) *TaskTool {
t.Helper()
return NewTaskTool(prov, nil, reg, 20, 0, 0, 0, 0, 0, 0, 0.0, "", sysPrompt, nil, 0, subagentModel, subagentEffort, resolve).
WithTranscripts(NewSubagentStore(t.TempDir()), t.TempDir(), "base-model", "base-effort")
}
type panicProvider struct{ name string }
func (p panicProvider) Name() string { return p.name }
func (p panicProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
panic("subagent boom")
}