package executor import ( "context" "database/sql" "encoding/json" "errors" "testing" "github.com/thepeterstone/claudomator/internal/agentchannel" "github.com/thepeterstone/claudomator/internal/event" "github.com/thepeterstone/claudomator/internal/story" "github.com/thepeterstone/claudomator/internal/task" ) type fakeChannelStore struct { createdTasks []*task.Task createdEvents []*event.Event createTaskErr error // Epic/story fakes back ProposeEpic (Phase 7c). epics is keyed by ID; // GetEpicByName does a linear scan by Name, mirroring the real // storage.DB.GetEpicByName's "exact match, simplest reasonable" behavior. epics map[string]*story.Epic createEpicErr error stories map[string]*story.Story updateStoryErr error updatedStories []*story.Story } 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 (f *fakeChannelStore) CreateEpic(e *story.Epic) error { if f.createEpicErr != nil { return f.createEpicErr } if f.epics == nil { f.epics = make(map[string]*story.Epic) } f.epics[e.ID] = e return nil } func (f *fakeChannelStore) GetEpicByName(name string) (*story.Epic, error) { for _, e := range f.epics { if e.Name == name { return e, nil } } return nil, sql.ErrNoRows } func (f *fakeChannelStore) GetStory(id string) (*story.Story, error) { st, ok := f.stories[id] if !ok { return nil, sql.ErrNoRows } return st, nil } func (f *fakeChannelStore) UpdateStory(st *story.Story) error { if f.updateStoryErr != nil { return f.updateStoryErr } if f.stories == nil { f.stories = make(map[string]*story.Story) } f.stories[st.ID] = st f.updatedStories = append(f.updatedStories, st) 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) } } // TestStoreChannel_ProposeEpic_CreatesEpicAndGroupsStories proves (a) from // the phase description: a new epic name creates a fresh epics row and sets // epic_id on the given stories. func TestStoreChannel_ProposeEpic_CreatesEpicAndGroupsStories(t *testing.T) { store := &fakeChannelStore{ stories: map[string]*story.Story{ "story-1": {ID: "story-1", Name: "s1"}, "story-2": {ID: "story-2", Name: "s2"}, }, } ch := newStoreChannel(store, "task-1") epicID, err := ch.ProposeEpic(context.Background(), agentchannel.EpicProposal{ Name: "Checkout revamp", Description: "Everything to relaunch checkout", StoryIDs: []string{"story-1", "story-2"}, }) if err != nil { t.Fatalf("ProposeEpic: %v", err) } if epicID == "" { t.Fatal("expected non-empty epic ID") } if len(store.epics) != 1 { t.Fatalf("expected 1 created epic, got %d", len(store.epics)) } epic := store.epics[epicID] if epic == nil { t.Fatalf("epic %q not found in store", epicID) } if epic.Name != "Checkout revamp" || epic.Description != "Everything to relaunch checkout" { t.Errorf("epic fields not propagated: %+v", epic) } if epic.DiscoverySource != "agent" { t.Errorf("DiscoverySource: want agent, got %q", epic.DiscoverySource) } if store.stories["story-1"].EpicID != epicID || store.stories["story-2"].EpicID != epicID { t.Errorf("stories not grouped under epic: %+v / %+v", store.stories["story-1"], store.stories["story-2"]) } if len(store.updatedStories) != 2 { t.Errorf("expected 2 UpdateStory calls, got %d", len(store.updatedStories)) } } // TestStoreChannel_ProposeEpic_ReusesExistingEpicByName proves (b): calling // ProposeEpic again with the same name reuses the existing epic rather than // creating a duplicate. func TestStoreChannel_ProposeEpic_ReusesExistingEpicByName(t *testing.T) { store := &fakeChannelStore{ stories: map[string]*story.Story{ "story-1": {ID: "story-1", Name: "s1"}, "story-3": {ID: "story-3", Name: "s3"}, }, } ch := newStoreChannel(store, "task-1") firstID, err := ch.ProposeEpic(context.Background(), agentchannel.EpicProposal{ Name: "Checkout revamp", StoryIDs: []string{"story-1"}, }) if err != nil { t.Fatalf("first ProposeEpic: %v", err) } secondID, err := ch.ProposeEpic(context.Background(), agentchannel.EpicProposal{ Name: "Checkout revamp", StoryIDs: []string{"story-3"}, }) if err != nil { t.Fatalf("second ProposeEpic: %v", err) } if firstID != secondID { t.Errorf("expected the same epic ID to be reused, got %q then %q", firstID, secondID) } if len(store.epics) != 1 { t.Fatalf("expected exactly 1 epic (no duplicate), got %d", len(store.epics)) } if store.stories["story-3"].EpicID != firstID { t.Errorf("story-3 not grouped under the reused epic: %+v", store.stories["story-3"]) } } // TestStoreChannel_ProposeEpic_SkipsUnresolvedStoryIDs proves that a story ID // which doesn't resolve is skipped rather than failing the whole call. func TestStoreChannel_ProposeEpic_SkipsUnresolvedStoryIDs(t *testing.T) { store := &fakeChannelStore{ stories: map[string]*story.Story{ "story-1": {ID: "story-1", Name: "s1"}, }, } ch := newStoreChannel(store, "task-1") epicID, err := ch.ProposeEpic(context.Background(), agentchannel.EpicProposal{ Name: "Checkout revamp", StoryIDs: []string{"story-1", "does-not-exist"}, }) if err != nil { t.Fatalf("ProposeEpic should not fail on one bad story ID: %v", err) } if store.stories["story-1"].EpicID != epicID { t.Errorf("story-1 should still be grouped: %+v", store.stories["story-1"]) } if len(store.updatedStories) != 1 { t.Errorf("expected exactly 1 UpdateStory call (the bad ID skipped), got %d", len(store.updatedStories)) } } // TestStoreChannel_ProposeEpic_EmitsEpicProposedEvent proves (c): a // KindEpicProposed event is emitted, attached to the epic's own ID (not a // task ID), with a payload naming the epic and the stories actually grouped. func TestStoreChannel_ProposeEpic_EmitsEpicProposedEvent(t *testing.T) { store := &fakeChannelStore{ stories: map[string]*story.Story{ "story-1": {ID: "story-1", Name: "s1"}, }, } ch := newStoreChannel(store, "task-1") epicID, err := ch.ProposeEpic(context.Background(), agentchannel.EpicProposal{ Name: "Checkout revamp", StoryIDs: []string{"story-1", "missing"}, }) if err != nil { t.Fatalf("ProposeEpic: %v", err) } if len(store.createdEvents) != 1 { t.Fatalf("expected 1 event, got %d", len(store.createdEvents)) } ev := store.createdEvents[0] if ev.Kind != event.KindEpicProposed || ev.Actor != event.ActorAgent { t.Errorf("got kind=%v actor=%v want epic_proposed/agent", ev.Kind, ev.Actor) } if ev.TaskID != epicID { t.Errorf("event should be attached to the epic ID %q, got %q", epicID, ev.TaskID) } var payload struct { EpicID string `json:"epic_id"` Name string `json:"name"` StoryIDs []string `json:"story_ids"` } if err := json.Unmarshal(ev.Payload, &payload); err != nil { t.Fatalf("unmarshal payload: %v", err) } if payload.EpicID != epicID || payload.Name != "Checkout revamp" { t.Errorf("payload epic fields: %+v", payload) } if len(payload.StoryIDs) != 1 || payload.StoryIDs[0] != "story-1" { t.Errorf("payload.StoryIDs should only include the resolved story, got %+v", payload.StoryIDs) } }