diff options
| author | Claude <noreply@anthropic.com> | 2026-05-24 08:47:07 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-05-24 08:47:07 +0000 |
| commit | c7d95f3992d24f86ff71e5f3e18260a8ef8a09f0 (patch) | |
| tree | ca62aca2287952907cdde0263672a0911b37d30e /internal/executor/channel_test.go | |
| parent | 48a8e49cdda833f8af22e839ca3c102c40c97e17 (diff) | |
feat(executor): introduce AgentChannel seam for runner signals
Defines AgentChannel — the normalized interface by which a runner reports
agent-originated signals (AskUser, ReportSummary, SpawnSubtask,
RecordProgress) — plus a default storeChannel implementation backed by
storage. Runner.Run now takes an AgentChannel; the pool constructs one
per execution.
The file transport routes its post-exit summary detection through
ch.ReportSummary (buffered onto the execution so the pool still applies
its extract/synthesize fallbacks, no double-write). AskUser returns
ErrAgentBlocked since write-and-exit cannot answer in-session; question
persistence stays with the pool's BlockedError handling. SpawnSubtask
and RecordProgress are implemented and tested, ready for the MCP
transport in Phase 2 where the channel becomes fully load-bearing.
Store gains CreateEvent so the channel can emit agent_message events.
https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39
Diffstat (limited to 'internal/executor/channel_test.go')
| -rw-r--r-- | internal/executor/channel_test.go | 118 |
1 files changed, 118 insertions, 0 deletions
diff --git a/internal/executor/channel_test.go b/internal/executor/channel_test.go new file mode 100644 index 0000000..822dd35 --- /dev/null +++ b/internal/executor/channel_test.go @@ -0,0 +1,118 @@ +package executor + +import ( + "context" + "errors" + "testing" + + "github.com/thepeterstone/claudomator/internal/event" + "github.com/thepeterstone/claudomator/internal/storage" + "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_ReturnsBlocked(t *testing.T) { + ch := newStoreChannel(&fakeChannelStore{}, "task-1", &storage.Execution{}) + 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) + } +} + +func TestStoreChannel_ReportSummary_BuffersOntoExec(t *testing.T) { + e := &storage.Execution{} + ch := newStoreChannel(&fakeChannelStore{}, "task-1", e) + if err := ch.ReportSummary(context.Background(), "did the thing"); err != nil { + t.Fatalf("ReportSummary: %v", err) + } + if e.Summary != "did the thing" { + t.Errorf("expected exec.Summary set, got %q", e.Summary) + } +} + +func TestStoreChannel_ReportSummary_NilExec(t *testing.T) { + ch := newStoreChannel(&fakeChannelStore{}, "task-1", nil) + if err := ch.ReportSummary(context.Background(), "x"); err != nil { + t.Errorf("expected nil err with nil exec, got %v", err) + } +} + +func TestStoreChannel_SpawnSubtask_CreatesChildWithParent(t *testing.T) { + store := &fakeChannelStore{} + ch := newStoreChannel(store, "parent-1", &storage.Execution{}) + 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) + } +} + +func TestStoreChannel_SpawnSubtask_PropagatesError(t *testing.T) { + store := &fakeChannelStore{createTaskErr: errors.New("boom")} + ch := newStoreChannel(store, "parent-1", &storage.Execution{}) + 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", &storage.Execution{}) + 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) + } +} |
