package executor import ( "context" "errors" "testing" "github.com/thepeterstone/claudomator/internal/event" "github.com/thepeterstone/claudomator/internal/task" ) type fakeChannelStore struct { createdTasks []*task.Task createdEvents []*event.Event createTaskErr error } func (f *fakeChannelStore) CreateTask(t *task.Task) error { if f.createTaskErr != nil { return f.createTaskErr } f.createdTasks = append(f.createdTasks, t) return nil } func (f *fakeChannelStore) CreateEvent(e *event.Event) error { f.createdEvents = append(f.createdEvents, e) return nil } func TestStoreChannel_AskUser_BuffersAndBlocks(t *testing.T) { ch := newStoreChannel(&fakeChannelStore{}, "task-1") answer, err := ch.AskUser(context.Background(), `{"text":"q"}`) if !errors.Is(err, ErrAgentBlocked) { t.Errorf("expected ErrAgentBlocked, got %v", err) } if answer != "" { t.Errorf("expected empty answer, got %q", answer) } q, blocked := ch.PendingQuestion() if !blocked || q != `{"text":"q"}` { t.Errorf("expected buffered question, got q=%q blocked=%v", q, blocked) } } func TestStoreChannel_PendingQuestion_DefaultNotBlocked(t *testing.T) { ch := newStoreChannel(&fakeChannelStore{}, "task-1") if q, blocked := ch.PendingQuestion(); blocked || q != "" { t.Errorf("expected no pending question, got q=%q blocked=%v", q, blocked) } } func TestStoreChannel_ReportSummary_Buffers(t *testing.T) { ch := newStoreChannel(&fakeChannelStore{}, "task-1") if _, ok := ch.ReportedSummary(); ok { t.Error("expected no summary before report") } if err := ch.ReportSummary(context.Background(), "did the thing"); err != nil { t.Fatalf("ReportSummary: %v", err) } sum, ok := ch.ReportedSummary() if !ok || sum != "did the thing" { t.Errorf("expected buffered summary, got %q ok=%v", sum, ok) } } func TestStoreChannel_SpawnSubtask_CreatesChildWithParent(t *testing.T) { store := &fakeChannelStore{} ch := newStoreChannel(store, "parent-1") id, err := ch.SpawnSubtask(context.Background(), SubtaskSpec{ Name: "child", Instructions: "do it", Model: "sonnet", MaxBudgetUSD: 1.5, }) if err != nil { t.Fatalf("SpawnSubtask: %v", err) } if id == "" { t.Error("expected non-empty subtask ID") } if len(store.createdTasks) != 1 { t.Fatalf("expected 1 created task, got %d", len(store.createdTasks)) } ct := store.createdTasks[0] if ct.ParentTaskID != "parent-1" { t.Errorf("ParentTaskID: got %q want parent-1", ct.ParentTaskID) } if ct.ID != id { t.Errorf("returned id %q != created task id %q", id, ct.ID) } if ct.Agent.Instructions != "do it" || ct.Agent.Model != "sonnet" || ct.Agent.MaxBudgetUSD != 1.5 { t.Errorf("agent config not propagated: %+v", ct.Agent) } if ct.State != task.StatePending { t.Errorf("expected PENDING child, got %s", ct.State) } } // TestStoreChannel_SpawnSubtask_RoleSet verifies that when spec.Role is set, // the child task gets Agent.Role populated and Agent.Type/Model left empty // so executor.Pool.execute()'s role-based dispatch (Phase 5) resolves them // from the role's escalation ladder, exactly like any other role-typed task. func TestStoreChannel_SpawnSubtask_RoleSet(t *testing.T) { store := &fakeChannelStore{} ch := newStoreChannel(store, "parent-1") id, err := ch.SpawnSubtask(context.Background(), SubtaskSpec{ Name: "evaluator", Instructions: "evaluate the change", Model: "should-be-ignored", MaxBudgetUSD: 2.0, Role: "evaluator_quality", }) if err != nil { t.Fatalf("SpawnSubtask: %v", err) } if len(store.createdTasks) != 1 { t.Fatalf("expected 1 created task, got %d", len(store.createdTasks)) } ct := store.createdTasks[0] if ct.ID != id { t.Errorf("returned id %q != created task id %q", id, ct.ID) } if ct.Agent.Role != "evaluator_quality" { t.Errorf("Agent.Role: want evaluator_quality, got %q", ct.Agent.Role) } if ct.Agent.Type != "" { t.Errorf("Agent.Type: want empty (resolved later by role dispatch), got %q", ct.Agent.Type) } if ct.Agent.Model != "" { t.Errorf("Agent.Model: want empty (resolved later by role dispatch), got %q", ct.Agent.Model) } if ct.Agent.Instructions != "evaluate the change" || ct.Agent.MaxBudgetUSD != 2.0 { t.Errorf("non-role fields not propagated: %+v", ct.Agent) } } // TestStoreChannel_SpawnSubtask_NoRole_RegressionUnchanged is a regression // test proving that spec.Role == "" (every pre-Phase-6 caller) still // produces exactly today's behavior: Agent.Type hardcoded to "claude" and // Agent.Model set from spec.Model. This must keep passing unmodified by any // future change to role-typed spawning. func TestStoreChannel_SpawnSubtask_NoRole_RegressionUnchanged(t *testing.T) { store := &fakeChannelStore{} ch := newStoreChannel(store, "parent-1") id, err := ch.SpawnSubtask(context.Background(), SubtaskSpec{ Name: "child", Instructions: "do it", Model: "sonnet", MaxBudgetUSD: 1.5, }) if err != nil { t.Fatalf("SpawnSubtask: %v", err) } if len(store.createdTasks) != 1 { t.Fatalf("expected 1 created task, got %d", len(store.createdTasks)) } ct := store.createdTasks[0] if ct.ID != id { t.Errorf("returned id %q != created task id %q", id, ct.ID) } if ct.ParentTaskID != "parent-1" { t.Errorf("ParentTaskID: got %q want parent-1", ct.ParentTaskID) } if ct.Agent.Type != "claude" { t.Errorf("Agent.Type: want claude (unchanged backward-compat default), got %q", ct.Agent.Type) } if ct.Agent.Model != "sonnet" { t.Errorf("Agent.Model: want sonnet, got %q", ct.Agent.Model) } if ct.Agent.Role != "" { t.Errorf("Agent.Role: want empty, got %q", ct.Agent.Role) } if ct.Agent.Instructions != "do it" || ct.Agent.MaxBudgetUSD != 1.5 { t.Errorf("agent config not propagated: %+v", ct.Agent) } if ct.State != task.StatePending { t.Errorf("expected PENDING child, got %s", ct.State) } } func TestStoreChannel_SpawnSubtask_PropagatesError(t *testing.T) { store := &fakeChannelStore{createTaskErr: errors.New("boom")} ch := newStoreChannel(store, "parent-1") if _, err := ch.SpawnSubtask(context.Background(), SubtaskSpec{Name: "x"}); err == nil { t.Error("expected error from CreateTask") } } func TestStoreChannel_RecordProgress_EmitsAgentMessage(t *testing.T) { store := &fakeChannelStore{} ch := newStoreChannel(store, "task-1") if err := ch.RecordProgress(context.Background(), "halfway done"); err != nil { t.Fatalf("RecordProgress: %v", err) } if len(store.createdEvents) != 1 { t.Fatalf("expected 1 event, got %d", len(store.createdEvents)) } e := store.createdEvents[0] if e.Kind != event.KindAgentMessage || e.Actor != event.ActorAgent { t.Errorf("got kind=%v actor=%v want agent_message/agent", e.Kind, e.Actor) } if e.TaskID != "task-1" { t.Errorf("TaskID: got %q want task-1", e.TaskID) } }