feat(offscreen): record proposal_made/action_taken/proposal_dismissed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,15 +6,19 @@ package offscreen
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"keel/internal/ai"
|
"keel/internal/ai"
|
||||||
"keel/internal/harness"
|
"keel/internal/harness"
|
||||||
"keel/internal/knowledge"
|
"keel/internal/knowledge"
|
||||||
|
"keel/internal/memory"
|
||||||
"keel/internal/mode"
|
"keel/internal/mode"
|
||||||
"keel/internal/tasks"
|
"keel/internal/tasks"
|
||||||
)
|
)
|
||||||
@@ -28,6 +32,12 @@ const (
|
|||||||
statusDone = "done"
|
statusDone = "done"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
kindProposalMade = "proposal_made"
|
||||||
|
kindActionTaken = "action_taken"
|
||||||
|
kindProposalDismissed = "proposal_dismissed"
|
||||||
|
)
|
||||||
|
|
||||||
// Mode is the one-shot off-screen advisor: collect → propose → confirm/dismiss.
|
// Mode is the one-shot off-screen advisor: collect → propose → confirm/dismiss.
|
||||||
type Mode struct {
|
type Mode struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -35,12 +45,15 @@ type Mode struct {
|
|||||||
ai *ai.Service
|
ai *ai.Service
|
||||||
tasks tasks.Provider
|
tasks tasks.Provider
|
||||||
know knowledge.Source // may be nil; assembleBrief must guard
|
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
|
status string
|
||||||
gen int
|
gen int
|
||||||
proposal *ai.OffscreenProposal
|
proposal *ai.OffscreenProposal
|
||||||
errMsg string
|
errMsg string
|
||||||
done bool
|
done bool
|
||||||
|
proposalID string
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ mode.Mode = (*Mode)(nil)
|
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
|
// 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.
|
// brain; call Command(ctx, "start", nil) (or use Factory) to kick off a brief.
|
||||||
func New(svc harness.Services) *Mode {
|
func New(svc harness.Services) *Mode {
|
||||||
|
now := svc.Clock
|
||||||
|
if now == nil {
|
||||||
|
now = time.Now
|
||||||
|
}
|
||||||
m := &Mode{
|
m := &Mode{
|
||||||
ai: svc.AI,
|
ai: svc.AI,
|
||||||
tasks: svc.Tasks,
|
tasks: svc.Tasks,
|
||||||
know: svc.Knowledge,
|
know: svc.Knowledge,
|
||||||
|
mem: svc.Memory,
|
||||||
|
now: now,
|
||||||
status: statusPending,
|
status: statusPending,
|
||||||
}
|
}
|
||||||
m.async = harness.NewAsync(&m.mu, svc.Notify)
|
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)
|
return m.confirm(ctx)
|
||||||
case "dismiss":
|
case "dismiss":
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
id := m.proposalID
|
||||||
m.done = true
|
m.done = true
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
m.recordSync(memory.Event{
|
||||||
|
Kind: kindProposalDismissed,
|
||||||
|
At: m.now(),
|
||||||
|
Data: map[string]any{"proposal_id": id, "mode": "offscreen"},
|
||||||
|
})
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("offscreen: unknown command %q", name)
|
return fmt.Errorf("offscreen: unknown command %q", name)
|
||||||
@@ -112,6 +137,18 @@ func (m *Mode) start() error {
|
|||||||
}
|
}
|
||||||
m.status = statusProposed
|
m.status = statusProposed
|
||||||
m.proposal = &p
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -129,9 +166,15 @@ func (m *Mode) confirm(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
id := m.proposalID
|
||||||
m.status = statusDone
|
m.status = statusDone
|
||||||
m.done = true
|
m.done = true
|
||||||
m.mu.Unlock()
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,3 +199,37 @@ func Factory(svc harness.Services) mode.Mode {
|
|||||||
_ = m.start()
|
_ = m.start()
|
||||||
return m
|
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[:])
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"keel/internal/ai"
|
"keel/internal/ai"
|
||||||
"keel/internal/harness"
|
"keel/internal/harness"
|
||||||
|
"keel/internal/memory"
|
||||||
"keel/internal/tasks"
|
"keel/internal/tasks"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -138,3 +139,75 @@ func TestUnknownCommandErrors(t *testing.T) {
|
|||||||
t.Fatal("unknown command should error")
|
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")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user