package automations import ( "context" "errors" "sync" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // fakePipeline is an in-memory Pipeline used to observe the prompt an AIAction // sends and to return a canned text. It is the mock for the loop/agent pipeline; // it implements the Pipeline interface, not a concrete loop. type fakePipeline struct { mu sync.Mutex text string err error msgs []model.Message calls int } func (f *fakePipeline) Run(ctx context.Context, sessionID string, msgs []model.Message) (*loop.Result, error) { f.mu.Lock() defer f.mu.Unlock() f.calls++ f.msgs = append(f.msgs, msgs...) if f.err != nil { return nil, f.err } return &loop.Result{Text: f.text, StopReason: "stop", Steps: 1}, nil } func (f *fakePipeline) lastMessage() model.Message { f.mu.Lock() defer f.mu.Unlock() if len(f.msgs) == 0 { return model.Message{} } return f.msgs[len(f.msgs)-1] } func TestAIAction_RunsPipelineAndPublishesResult(t *testing.T) { t.Parallel() b := bus.New() defer b.Close() got := make(chan bus.Event, 1) unsub, err := b.Subscribe(context.Background(), SubjectResult, func(_ context.Context, ev bus.Event) error { got <- ev return nil }) if err != nil { t.Fatalf("subscribe: %v", err) } defer unsub() p := &fakePipeline{text: "hello from pipeline"} act, err := NewAIAction("gen_greeting", "You are a greeter", "sess-1", p) if err != nil { t.Fatalf("NewAIAction: %v", err) } s, err := New(Config{}, Deps{Bus: b}) if err != nil { t.Fatalf("New: %v", err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() if _, err := s.AddSchedule(ctx, "@every 50ms", act); err != nil { t.Fatalf("AddSchedule: %v", err) } if err := s.Start(ctx); err != nil { t.Fatalf("Start: %v", err) } var res Result select { case ev := <-got: v, ok := ev.Payload.(Result) if !ok { t.Fatalf("unexpected payload type: %T", ev.Payload) } res = v case <-time.After(3 * time.Second): t.Fatal("AI action did not fire") } if res.Kind != "schedule" { t.Errorf("kind = %q, want schedule", res.Kind) } if res.Action != "gen_greeting" { t.Errorf("action = %q, want gen_greeting", res.Action) } if res.Output != "hello from pipeline" { t.Errorf("output = %#v, want hello from pipeline", res.Output) } last := p.lastMessage() if len(last.Content) == 0 || last.Content[0].Text != "You are a greeter" { t.Errorf("prompt = %#v, want the configured prompt", last.Content) } if p.calls < 1 { t.Errorf("pipeline not called") } if err := s.Stop(ctx); err != nil { t.Fatalf("Stop: %v", err) } } func TestAIAction_RequiresPipeline(t *testing.T) { t.Parallel() if _, err := NewAIAction("x", "p", "s", nil); err == nil { t.Fatal("expected error for nil pipeline") } else if !errors.Is(err, ErrNoPipeline) { t.Fatalf("err = %v, want ErrNoPipeline", err) } } func TestAIAction_PropagatesPipelineError(t *testing.T) { t.Parallel() p := &fakePipeline{err: context.DeadlineExceeded} act, err := NewAIAction("x", "p", "s", p) if err != nil { t.Fatalf("NewAIAction: %v", err) } if _, err := act.Execute(context.Background(), nil); err == nil { t.Fatal("expected error from pipeline") } }