diff --git a/internal/mode/offscreen/offscreen.go b/internal/mode/offscreen/offscreen.go index a537c8a..c851a2e 100644 --- a/internal/mode/offscreen/offscreen.go +++ b/internal/mode/offscreen/offscreen.go @@ -6,15 +6,19 @@ package offscreen import ( "context" + "crypto/rand" + "encoding/hex" "encoding/json" "errors" "fmt" + "log" "sync" "time" "keel/internal/ai" "keel/internal/harness" "keel/internal/knowledge" + "keel/internal/memory" "keel/internal/mode" "keel/internal/tasks" ) @@ -28,6 +32,12 @@ const ( statusDone = "done" ) +const ( + kindProposalMade = "proposal_made" + kindActionTaken = "action_taken" + kindProposalDismissed = "proposal_dismissed" +) + // Mode is the one-shot off-screen advisor: collect → propose → confirm/dismiss. type Mode struct { mu sync.Mutex @@ -35,12 +45,15 @@ type Mode struct { ai *ai.Service tasks tasks.Provider know knowledge.Source // may be nil; assembleBrief must guard + mem memory.Store // may be nil; record helpers guard + now func() time.Time // event timestamps; never nil after New - status string - gen int - proposal *ai.OffscreenProposal - errMsg string - done bool + status string + gen int + proposal *ai.OffscreenProposal + errMsg string + done bool + proposalID string } var _ mode.Mode = (*Mode)(nil) @@ -48,10 +61,16 @@ var _ mode.Mode = (*Mode)(nil) // New builds an off-screen mode from the shared services. It does not start the // brain; call Command(ctx, "start", nil) (or use Factory) to kick off a brief. func New(svc harness.Services) *Mode { + now := svc.Clock + if now == nil { + now = time.Now + } m := &Mode{ ai: svc.AI, tasks: svc.Tasks, know: svc.Knowledge, + mem: svc.Memory, + now: now, status: statusPending, } m.async = harness.NewAsync(&m.mu, svc.Notify) @@ -78,8 +97,14 @@ func (m *Mode) Command(ctx context.Context, name string, _ json.RawMessage) erro return m.confirm(ctx) case "dismiss": m.mu.Lock() + id := m.proposalID m.done = true m.mu.Unlock() + m.recordSync(memory.Event{ + Kind: kindProposalDismissed, + At: m.now(), + Data: map[string]any{"proposal_id": id, "mode": "offscreen"}, + }) return nil default: return fmt.Errorf("offscreen: unknown command %q", name) @@ -112,6 +137,18 @@ func (m *Mode) start() error { } m.status = statusProposed m.proposal = &p + id := newProposalID() + m.proposalID = id + m.recordAsync(memory.Event{ + Kind: kindProposalMade, + At: m.now(), + Data: map[string]any{ + "proposal_id": id, + "mode": "offscreen", + "next_action": p.NextAction, + "rationale": p.Rationale, + }, + }) }) return nil } @@ -129,9 +166,15 @@ func (m *Mode) confirm(ctx context.Context) error { return err } m.mu.Lock() + id := m.proposalID m.status = statusDone m.done = true m.mu.Unlock() + m.recordSync(memory.Event{ + Kind: kindActionTaken, + At: m.now(), + Data: map[string]any{"proposal_id": id, "mode": "offscreen", "next_action": p.NextAction}, + }) return nil } @@ -156,3 +199,37 @@ func Factory(svc harness.Services) mode.Mode { _ = m.start() return m } + +// recordAsync persists an event off the mode's lock, best-effort. Used on the +// propose path, which runs under the mode mutex (async apply). +func (m *Mode) recordAsync(ev memory.Event) { + if m.mem == nil { + return + } + go func() { + if err := m.mem.Record(context.Background(), ev); err != nil { + log.Printf("offscreen: memory record %s: %v", ev.Kind, err) + } + }() +} + +// recordSync persists an event inline, best-effort. Used on confirm/dismiss, +// which already do synchronous work. +func (m *Mode) recordSync(ev memory.Event) { + if m.mem == nil { + return + } + if err := m.mem.Record(context.Background(), ev); err != nil { + log.Printf("offscreen: memory record %s: %v", ev.Kind, err) + } +} + +// newProposalID returns a short random hex id correlating a proposal with its +// outcome event. +func newProposalID() string { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return "proposal" + } + return hex.EncodeToString(b[:]) +} diff --git a/internal/mode/offscreen/offscreen_test.go b/internal/mode/offscreen/offscreen_test.go index 5ebaf87..af0d78b 100644 --- a/internal/mode/offscreen/offscreen_test.go +++ b/internal/mode/offscreen/offscreen_test.go @@ -8,6 +8,7 @@ import ( "keel/internal/ai" "keel/internal/harness" + "keel/internal/memory" "keel/internal/tasks" ) @@ -138,3 +139,75 @@ func TestUnknownCommandErrors(t *testing.T) { t.Fatal("unknown command should error") } } + +// newTestModeMem is newTestMode with an injected in-memory Store. +func newTestModeMem(t *testing.T, ft *fakeTasks, backend ai.Backend, mem *memory.Fake) *Mode { + t.Helper() + svc := harness.Services{ + AI: ai.NewService(backend), + Tasks: ft, + Memory: mem, + Clock: func() time.Time { return time.Unix(1000, 0) }, + Notify: func() {}, + } + return New(svc) +} + +// waitEvent polls the fake until an event of kind appears, or fails after 2s. +func waitEvent(t *testing.T, f *memory.Fake, kind string) memory.Event { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + for _, e := range f.Events() { + if e.Kind == kind { + return e + } + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("event %q never recorded (events: %+v)", kind, f.Events()) + return memory.Event{} +} + +func TestProposeRecordsProposalMade(t *testing.T) { + mem := memory.NewFake() + m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem) + if err := m.Command(context.Background(), "start", nil); err != nil { + t.Fatalf("start: %v", err) + } + waitStatus(t, m, statusProposed) + + ev := waitEvent(t, mem, "proposal_made") + if ev.Data["next_action"] != "call mum" || ev.Data["proposal_id"] == "" { + t.Fatalf("proposal_made data = %+v", ev.Data) + } +} + +func TestConfirmRecordsActionTakenWithSameID(t *testing.T) { + mem := memory.NewFake() + m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem) + _ = m.Command(context.Background(), "start", nil) + waitStatus(t, m, statusProposed) + made := waitEvent(t, mem, "proposal_made") + + if err := m.Command(context.Background(), "confirm", nil); err != nil { + t.Fatalf("confirm: %v", err) + } + taken := waitEvent(t, mem, "action_taken") + if taken.Data["proposal_id"] != made.Data["proposal_id"] { + t.Fatalf("action_taken id %v != proposal_made id %v", taken.Data["proposal_id"], made.Data["proposal_id"]) + } +} + +func TestDismissRecordsProposalDismissed(t *testing.T) { + mem := memory.NewFake() + m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem) + _ = m.Command(context.Background(), "start", nil) + waitStatus(t, m, statusProposed) + _ = waitEvent(t, mem, "proposal_made") + + if err := m.Command(context.Background(), "dismiss", nil); err != nil { + t.Fatalf("dismiss: %v", err) + } + _ = waitEvent(t, mem, "proposal_dismissed") +}