summaryrefslogtreecommitdiff
path: root/internal/executor
diff options
context:
space:
mode:
Diffstat (limited to 'internal/executor')
-rw-r--r--internal/executor/agentmcp.go23
-rw-r--r--internal/executor/agentmcp_test.go41
-rw-r--r--internal/executor/channel.go70
-rw-r--r--internal/executor/channel_test.go206
-rw-r--r--internal/executor/container_test.go3
-rw-r--r--internal/executor/executor.go7
-rw-r--r--internal/executor/executor_test.go7
-rw-r--r--internal/executor/nativerunner_test.go45
8 files changed, 396 insertions, 6 deletions
diff --git a/internal/executor/agentmcp.go b/internal/executor/agentmcp.go
index 2177fbd..c0088c5 100644
--- a/internal/executor/agentmcp.go
+++ b/internal/executor/agentmcp.go
@@ -73,11 +73,17 @@ type recordProgressInput struct {
Message string `json:"message" jsonschema:"a short progress note describing what you are doing"`
}
+type proposeEpicInput struct {
+ Name string `json:"name" jsonschema:"short descriptive name for the epic; matched by exact name to reuse an existing epic instead of creating a duplicate"`
+ Description string `json:"description,omitempty" jsonschema:"optional longer description of the initiative"`
+ StoryIDs []string `json:"story_ids" jsonschema:"the story IDs to group under this epic"`
+}
+
func textResult(text string) *mcp.CallToolResult {
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}
}
-// newAgentServer builds an MCP server exposing the four agent tools bound to ch.
+// newAgentServer builds an MCP server exposing the five agent tools bound to ch.
func newAgentServer(ch AgentChannel) *mcp.Server {
s := mcp.NewServer(&mcp.Implementation{Name: "claudomator", Version: "1"}, nil)
@@ -137,6 +143,21 @@ func newAgentServer(ch AgentChannel) *mcp.Server {
return textResult("Noted."), nil, nil
})
+ mcp.AddTool(s, &mcp.Tool{
+ Name: "propose_epic",
+ Description: "Group one or more stories under a new or existing epic (matched by exact name) when they form a cohesive initiative. Only call this when you've been given several story IDs and independently judge that they belong together.",
+ }, func(ctx context.Context, _ *mcp.CallToolRequest, in proposeEpicInput) (*mcp.CallToolResult, any, error) {
+ id, err := ch.ProposeEpic(ctx, EpicProposal{
+ Name: in.Name,
+ Description: in.Description,
+ StoryIDs: in.StoryIDs,
+ })
+ if err != nil {
+ return nil, nil, err
+ }
+ return textResult("Proposed epic " + id), nil, nil
+ })
+
return s
}
diff --git a/internal/executor/agentmcp_test.go b/internal/executor/agentmcp_test.go
index 4aeb7cb..c73d6db 100644
--- a/internal/executor/agentmcp_test.go
+++ b/internal/executor/agentmcp_test.go
@@ -12,11 +12,13 @@ import (
// recordingChannel is a fake AgentChannel that records tool invocations.
type recordingChannel struct {
- asked string
- summary string
- spawned []SubtaskSpec
- progress []string
- spawnID string
+ asked string
+ summary string
+ spawned []SubtaskSpec
+ progress []string
+ spawnID string
+ proposedEpics []EpicProposal
+ epicID string
}
func (c *recordingChannel) AskUser(_ context.Context, q string) (string, error) {
@@ -35,6 +37,10 @@ func (c *recordingChannel) RecordProgress(_ context.Context, m string) error {
c.progress = append(c.progress, m)
return nil
}
+func (c *recordingChannel) ProposeEpic(_ context.Context, spec EpicProposal) (string, error) {
+ c.proposedEpics = append(c.proposedEpics, spec)
+ return c.epicID, nil
+}
func resultText(t *testing.T, res *mcp.CallToolResult) string {
t.Helper()
@@ -178,6 +184,31 @@ func TestAgentServer_RecordProgress(t *testing.T) {
}
}
+func TestAgentServer_ProposeEpic(t *testing.T) {
+ ch := &recordingChannel{epicID: "epic-1"}
+ cs := connectInMemory(t, newAgentServer(ch))
+ res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
+ Name: "propose_epic",
+ Arguments: map[string]any{"name": "Checkout revamp", "description": "relaunch", "story_ids": []string{"s1", "s2"}},
+ })
+ if err != nil {
+ t.Fatalf("CallTool: %v", err)
+ }
+ if len(ch.proposedEpics) != 1 {
+ t.Fatalf("expected 1 proposed epic, got %d", len(ch.proposedEpics))
+ }
+ spec := ch.proposedEpics[0]
+ if spec.Name != "Checkout revamp" || spec.Description != "relaunch" {
+ t.Errorf("epic spec not propagated: %+v", spec)
+ }
+ if len(spec.StoryIDs) != 2 || spec.StoryIDs[0] != "s1" || spec.StoryIDs[1] != "s2" {
+ t.Errorf("story_ids not propagated: %+v", spec.StoryIDs)
+ }
+ if txt := resultText(t, res); !strings.Contains(txt, "epic-1") {
+ t.Errorf("expected returned epic ID in result, got %q", txt)
+ }
+}
+
type bearerRT struct {
token string
base http.RoundTripper
diff --git a/internal/executor/channel.go b/internal/executor/channel.go
index 30d826c..ef64ede 100644
--- a/internal/executor/channel.go
+++ b/internal/executor/channel.go
@@ -2,12 +2,15 @@ package executor
import (
"context"
+ "database/sql"
"encoding/json"
+ "errors"
"sync"
"time"
"github.com/thepeterstone/claudomator/internal/agentchannel"
"github.com/thepeterstone/claudomator/internal/event"
+ "github.com/thepeterstone/claudomator/internal/story"
"github.com/thepeterstone/claudomator/internal/task"
"github.com/google/uuid"
)
@@ -29,12 +32,24 @@ type AgentChannel = agentchannel.AgentChannel
type SubtaskSpec = agentchannel.SubtaskSpec
type BlockedError = agentchannel.BlockedError
+// EpicProposal is the Phase 7c sibling of SubtaskSpec above: an alias so
+// existing code that refers to executor.EpicProposal (agentmcp.go, tests)
+// compiles against the same type agentchannel.AgentChannel.ProposeEpic uses.
+type EpicProposal = agentchannel.EpicProposal
+
var ErrAgentBlocked = agentchannel.ErrAgentBlocked
// channelStore is the subset of storage the default channel needs.
type channelStore interface {
CreateTask(t *task.Task) error
CreateEvent(e *event.Event) error
+ // CreateEpic, GetEpicByName, GetStory, and UpdateStory back
+ // storeChannel.ProposeEpic (Phase 7c): matching an existing epic by exact
+ // name (or creating a new one), then attaching the given stories to it.
+ CreateEpic(e *story.Epic) error
+ GetEpicByName(name string) (*story.Epic, error)
+ GetStory(id string) (*story.Story, error)
+ UpdateStory(st *story.Story) error
}
// pendingAsker is implemented by channels that buffer an ask_user call so the
@@ -153,4 +168,59 @@ func (c *storeChannel) RecordProgress(_ context.Context, message string) error {
Actor: event.ActorAgent,
Payload: payload,
})
+}
+
+// ProposeEpic groups spec.StoryIDs under a new or existing epic, matched by
+// exact spec.Name — the simplest reasonable de-dup; a fuzzy-matching pass is
+// left to a later phase if it turns out to matter. Story IDs that don't
+// resolve are skipped (not fatal to the call): one bad ID shouldn't prevent
+// grouping the rest. The event is attached to the epic's own ID, matching
+// the story orchestrator's convention (KindEvalVerdict/KindArbitrationDecided)
+// of attaching planning-layer ceremony events to the entity they're about
+// rather than to a task ID — events.task_id has no enforced FK, so this is
+// exactly the tolerance already built in for that.
+func (c *storeChannel) ProposeEpic(_ context.Context, spec agentchannel.EpicProposal) (string, error) {
+ epic, err := c.store.GetEpicByName(spec.Name)
+ if err != nil {
+ if !errors.Is(err, sql.ErrNoRows) {
+ return "", err
+ }
+ epic = &story.Epic{
+ ID: uuid.NewString(),
+ Name: spec.Name,
+ Description: spec.Description,
+ DiscoverySource: "agent",
+ }
+ if err := c.store.CreateEpic(epic); err != nil {
+ return "", err
+ }
+ }
+
+ grouped := make([]string, 0, len(spec.StoryIDs))
+ for _, sid := range spec.StoryIDs {
+ st, err := c.store.GetStory(sid)
+ if err != nil {
+ continue // unresolved story ID: skip, don't fail the whole call
+ }
+ st.EpicID = epic.ID
+ if err := c.store.UpdateStory(st); err != nil {
+ continue
+ }
+ grouped = append(grouped, sid)
+ }
+
+ payload, _ := json.Marshal(struct {
+ EpicID string `json:"epic_id"`
+ Name string `json:"name"`
+ StoryIDs []string `json:"story_ids"`
+ }{EpicID: epic.ID, Name: epic.Name, StoryIDs: grouped})
+ if err := c.store.CreateEvent(&event.Event{
+ TaskID: epic.ID,
+ Kind: event.KindEpicProposed,
+ Actor: event.ActorAgent,
+ Payload: payload,
+ }); err != nil {
+ return epic.ID, err
+ }
+ return epic.ID, nil
} \ No newline at end of file
diff --git a/internal/executor/channel_test.go b/internal/executor/channel_test.go
index 157cb0c..e05228a 100644
--- a/internal/executor/channel_test.go
+++ b/internal/executor/channel_test.go
@@ -2,10 +2,14 @@ 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"
)
@@ -13,6 +17,15 @@ 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 {
@@ -28,6 +41,46 @@ func (f *fakeChannelStore) CreateEvent(e *event.Event) error {
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"}`)
@@ -204,3 +257,156 @@ func TestStoreChannel_RecordProgress_EmitsAgentMessage(t *testing.T) {
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)
+ }
+}
diff --git a/internal/executor/container_test.go b/internal/executor/container_test.go
index b06d153..84b3208 100644
--- a/internal/executor/container_test.go
+++ b/internal/executor/container_test.go
@@ -832,3 +832,6 @@ func (noopChannel) SpawnSubtask(_ context.Context, _ SubtaskSpec) (string, error
return "", nil
}
func (noopChannel) RecordProgress(_ context.Context, _ string) error { return nil }
+func (noopChannel) ProposeEpic(_ context.Context, _ EpicProposal) (string, error) {
+ return "", nil
+}
diff --git a/internal/executor/executor.go b/internal/executor/executor.go
index ed38d7d..3c41250 100644
--- a/internal/executor/executor.go
+++ b/internal/executor/executor.go
@@ -16,6 +16,7 @@ import (
"github.com/thepeterstone/claudomator/internal/retry"
"github.com/thepeterstone/claudomator/internal/role"
"github.com/thepeterstone/claudomator/internal/storage"
+ "github.com/thepeterstone/claudomator/internal/story"
"github.com/thepeterstone/claudomator/internal/task"
"github.com/google/uuid"
)
@@ -48,6 +49,12 @@ type Store interface {
// ListDependents returns tasks that directly depend on taskID. Used by
// cascadeFail to proactively cancel a failed task's downstream subtree.
ListDependents(taskID string) ([]*task.Task, error)
+ // CreateEpic, GetEpicByName, GetStory, and UpdateStory back
+ // storeChannel.ProposeEpic (Phase 7c) — see internal/executor/channel.go.
+ CreateEpic(e *story.Epic) error
+ GetEpicByName(name string) (*story.Epic, error)
+ GetStory(id string) (*story.Story, error)
+ UpdateStory(st *story.Story) error
}
// LogPather is an optional interface runners can implement to provide the log
diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go
index e7e445e..7a7da48 100644
--- a/internal/executor/executor_test.go
+++ b/internal/executor/executor_test.go
@@ -14,6 +14,7 @@ import (
"github.com/thepeterstone/claudomator/internal/event"
"github.com/thepeterstone/claudomator/internal/storage"
+ "github.com/thepeterstone/claudomator/internal/story"
"github.com/thepeterstone/claudomator/internal/task"
)
@@ -1167,6 +1168,12 @@ func (m *minimalMockStore) GetActiveRoleConfig(_ string) (*storage.RoleConfigRow
return nil, sql.ErrNoRows
}
func (m *minimalMockStore) ListDependents(_ string) ([]*task.Task, error) { return nil, nil }
+func (m *minimalMockStore) CreateEpic(_ *story.Epic) error { return nil }
+func (m *minimalMockStore) GetEpicByName(_ string) (*story.Epic, error) {
+ return nil, sql.ErrNoRows
+}
+func (m *minimalMockStore) GetStory(_ string) (*story.Story, error) { return nil, sql.ErrNoRows }
+func (m *minimalMockStore) UpdateStory(_ *story.Story) error { return nil }
func (m *minimalMockStore) lastStateUpdate() (string, task.State, bool) {
m.mu.Lock()
diff --git a/internal/executor/nativerunner_test.go b/internal/executor/nativerunner_test.go
index b081b8e..afdb49d 100644
--- a/internal/executor/nativerunner_test.go
+++ b/internal/executor/nativerunner_test.go
@@ -19,6 +19,7 @@ import (
"github.com/thepeterstone/claudomator/internal/llm"
"github.com/thepeterstone/claudomator/internal/provider/openaicompat"
"github.com/thepeterstone/claudomator/internal/storage"
+ "github.com/thepeterstone/claudomator/internal/story"
"github.com/thepeterstone/claudomator/internal/task"
)
@@ -237,6 +238,50 @@ func TestNativeRunner_Run_ToolLoop_SpawnSubtask_RolePassthrough(t *testing.T) {
}
}
+// TestNativeRunner_Run_ToolLoop_ProposeEpic is an end-to-end-ish test (fake
+// LLM server, real agentloop.Loop/tools.go dispatch, real storeChannel)
+// proving a propose_epic tool call reaches AgentChannel.ProposeEpic
+// correctly through internal/agentloop/tools.go's dispatchTool, and from
+// there through storeChannel.ProposeEpic into a created epic with the given
+// stories grouped under it — mirroring the spawn_subtask role-passthrough
+// coverage above (Phase 6) for this phase's new tool (Phase 7c).
+func TestNativeRunner_Run_ToolLoop_ProposeEpic(t *testing.T) {
+ srv := fakeChatServer(t, []fakeTurn{
+ {toolCalls: []llm.ToolCall{toolCall("c1", "propose_epic", `{"name":"Checkout revamp","description":"relaunch","story_ids":["story-1","story-2"]}`)}},
+ {content: "finished"},
+ })
+ defer srv.Close()
+
+ r := newLocalRunner(t, srv)
+ tt := localTask()
+ store := &fakeChannelStore{
+ stories: map[string]*story.Story{
+ "story-1": {ID: "story-1", Name: "s1"},
+ "story-2": {ID: "story-2", Name: "s2"},
+ },
+ }
+ ch := newStoreChannel(store, tt.ID)
+ exec := &storage.Execution{ID: uuid.New().String(), TaskID: tt.ID}
+
+ if err := r.Run(context.Background(), tt, exec, ch); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ if len(store.epics) != 1 {
+ t.Fatalf("expected 1 created epic, got %d", len(store.epics))
+ }
+ var epicID string
+ for id, e := range store.epics {
+ epicID = id
+ if e.Name != "Checkout revamp" || e.Description != "relaunch" {
+ t.Errorf("epic fields not propagated: %+v", e)
+ }
+ }
+ if store.stories["story-1"].EpicID != epicID || store.stories["story-2"].EpicID != epicID {
+ t.Errorf("stories not grouped under the proposed epic: %+v / %+v", store.stories["story-1"], store.stories["story-2"])
+ }
+}
+
func TestNativeRunner_Run_RecordProgress(t *testing.T) {
srv := fakeChatServer(t, []fakeTurn{
{toolCalls: []llm.ToolCall{toolCall("c1", "record_progress", `{"message":"halfway there"}`)}},