Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 198 additions & 0 deletions cmd/wave/commands/db_logging_emitter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package commands

import (
"testing"

"github.com/recinq/wave/internal/event"
"github.com/recinq/wave/internal/state"
)

type logEventCall struct {
runID, stepID, state, persona, message string
tokens int
durationMs int64
model, configuredModel, adapter string
}

// fakeLogEventStore satisfies state.StateStore by embedding the interface.
// Only LogEvent is implemented — any other method call panics, which is fine
// because dbLoggingEmitter.Emit only invokes LogEvent.
type fakeLogEventStore struct {
state.StateStore
calls []logEventCall
}

func (f *fakeLogEventStore) LogEvent(runID, stepID, st, persona, message string, tokens int, durationMs int64, model, configuredModel, adapter string) error {
f.calls = append(f.calls, logEventCall{
runID: runID,
stepID: stepID,
state: st,
persona: persona,
message: message,
tokens: tokens,
durationMs: durationMs,
model: model,
configuredModel: configuredModel,
adapter: adapter,
})
return nil
}

type fakeEventEmitter struct {
events []event.Event
}

func (f *fakeEventEmitter) Emit(e event.Event) {
f.events = append(f.events, e)
}

func TestDBLoggingEmitter_Emit(t *testing.T) {
tests := []struct {
name string
ev event.Event
wantPersist bool
wantMessage string
wantRunID string
}{
{
name: "empty step_progress heartbeat is dropped",
ev: event.Event{State: "step_progress"},
wantPersist: false,
},
{
name: "empty stream_activity heartbeat is dropped",
ev: event.Event{State: "stream_activity"},
wantPersist: false,
},
{
name: "stream_activity with ToolName composes message",
ev: event.Event{
State: "stream_activity",
ToolName: "Read",
ToolTarget: "cmd/wave/commands/run.go",
StepID: "step-1",
Persona: "navigator",
},
wantPersist: true,
wantMessage: "Read cmd/wave/commands/run.go",
wantRunID: "default-run",
},
{
name: "step_progress with tokens used is persisted",
ev: event.Event{
State: "step_progress",
TokensUsed: 42,
StepID: "step-1",
},
wantPersist: true,
wantMessage: "",
wantRunID: "default-run",
},
{
name: "step_progress with duration is persisted",
ev: event.Event{
State: "step_progress",
DurationMs: 100,
StepID: "step-1",
},
wantPersist: true,
wantMessage: "",
wantRunID: "default-run",
},
{
name: "running state with message is persisted",
ev: event.Event{
State: "running",
Message: "step started",
StepID: "step-1",
Persona: "implementer",
},
wantPersist: true,
wantMessage: "step started",
wantRunID: "default-run",
},
{
name: "completed state with no message still persists (not heartbeat)",
ev: event.Event{
State: "completed",
StepID: "step-1",
},
wantPersist: true,
wantMessage: "",
wantRunID: "default-run",
},
{
name: "event with PipelineID overrides default runID",
ev: event.Event{
State: "running",
Message: "child running",
PipelineID: "child-run-id",
StepID: "step-1",
},
wantPersist: true,
wantMessage: "child running",
wantRunID: "child-run-id",
},
{
name: "stream_activity with ToolName but no target",
ev: event.Event{
State: "stream_activity",
ToolName: "Bash",
StepID: "step-1",
},
wantPersist: true,
wantMessage: "Bash ",
wantRunID: "default-run",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
inner := &fakeEventEmitter{}
store := &fakeLogEventStore{}
d := &dbLoggingEmitter{inner: inner, store: store, runID: "default-run"}

d.Emit(tt.ev)

if len(inner.events) != 1 {
t.Fatalf("inner.Emit called %d times, want 1", len(inner.events))
}
if inner.events[0].State != tt.ev.State || inner.events[0].StepID != tt.ev.StepID || inner.events[0].Message != tt.ev.Message {
t.Errorf("inner received unexpected event: got %+v want %+v", inner.events[0], tt.ev)
}

if !tt.wantPersist {
if len(store.calls) != 0 {
t.Errorf("LogEvent called %d times, want 0 (heartbeat should be dropped); calls=%+v", len(store.calls), store.calls)
}
return
}

if len(store.calls) != 1 {
t.Fatalf("LogEvent called %d times, want 1", len(store.calls))
}
c := store.calls[0]
if c.runID != tt.wantRunID {
t.Errorf("LogEvent runID = %q, want %q", c.runID, tt.wantRunID)
}
if c.message != tt.wantMessage {
t.Errorf("LogEvent message = %q, want %q", c.message, tt.wantMessage)
}
if c.state != tt.ev.State {
t.Errorf("LogEvent state = %q, want %q", c.state, tt.ev.State)
}
if c.stepID != tt.ev.StepID {
t.Errorf("LogEvent stepID = %q, want %q", c.stepID, tt.ev.StepID)
}
if c.persona != tt.ev.Persona {
t.Errorf("LogEvent persona = %q, want %q", c.persona, tt.ev.Persona)
}
if c.tokens != tt.ev.TokensUsed {
t.Errorf("LogEvent tokens = %d, want %d", c.tokens, tt.ev.TokensUsed)
}
if c.durationMs != tt.ev.DurationMs {
t.Errorf("LogEvent durationMs = %d, want %d", c.durationMs, tt.ev.DurationMs)
}
})
}
}
132 changes: 83 additions & 49 deletions cmd/wave/commands/preflight_metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,100 +2,134 @@ package commands

import (
"errors"
"fmt"
"reflect"
"testing"

"github.com/recinq/wave/internal/preflight"
"github.com/recinq/wave/internal/recovery"
)

func TestExtractPreflightMetadata(t *testing.T) {
tests := []struct {
name string
err error
wantSkills []string
wantTools []string
wantNil bool
name string
err error
want *recovery.PreflightMetadata
}{
{
name: "nil error returns nil",
err: nil,
wantNil: true,
name: "nil error returns nil",
err: nil,
want: nil,
},
{
name: "non-preflight error returns nil",
err: errors.New("generic error"),
wantNil: true,
name: "non-preflight error returns nil",
err: errors.New("generic error"),
want: nil,
},
{
name: "skill error extracts missing skills",
err: &preflight.SkillError{
MissingSkills: []string{"speckit", "testkit"},
},
wantSkills: []string{"speckit", "testkit"},
want: &recovery.PreflightMetadata{
MissingSkills: []string{"speckit", "testkit"},
},
},
{
name: "tool error extracts missing tools",
err: &preflight.ToolError{
MissingTools: []string{"jq", "yq"},
},
wantTools: []string{"jq", "yq"},
want: &recovery.PreflightMetadata{
MissingTools: []string{"jq", "yq"},
},
},
{
name: "wrapped skill error extracts missing skills",
name: "errors.Join with skill error extracts missing skills",
err: errors.Join(
errors.New("preflight check failed"),
&preflight.SkillError{
MissingSkills: []string{"speckit"},
},
),
wantSkills: []string{"speckit"},
want: &recovery.PreflightMetadata{
MissingSkills: []string{"speckit"},
},
},
{
name: "wrapped tool error extracts missing tools",
name: "errors.Join with tool error extracts missing tools",
err: errors.Join(
errors.New("preflight check failed"),
&preflight.ToolError{
MissingTools: []string{"jq"},
},
),
wantTools: []string{"jq"},
want: &recovery.PreflightMetadata{
MissingTools: []string{"jq"},
},
},
{
name: "errors.Join with both skill and tool errors extracts both",
err: errors.Join(
&preflight.SkillError{MissingSkills: []string{"speckit"}},
&preflight.ToolError{MissingTools: []string{"jq"}},
),
want: &recovery.PreflightMetadata{
MissingSkills: []string{"speckit"},
MissingTools: []string{"jq"},
},
},
{
name: "fmt.Errorf %w wrapping skill error extracts missing skills",
err: fmt.Errorf("preflight failed: %w", &preflight.SkillError{
MissingSkills: []string{"speckit", "testkit"},
}),
want: &recovery.PreflightMetadata{
MissingSkills: []string{"speckit", "testkit"},
},
},
{
name: "fmt.Errorf %w wrapping tool error extracts missing tools",
err: fmt.Errorf("preflight failed: %w", &preflight.ToolError{
MissingTools: []string{"jq"},
}),
want: &recovery.PreflightMetadata{
MissingTools: []string{"jq"},
},
},
{
name: "double-wrapped fmt.Errorf %w skill error still extracts missing skills",
err: fmt.Errorf("outer: %w",
fmt.Errorf("inner: %w", &preflight.SkillError{
MissingSkills: []string{"speckit"},
}),
),
want: &recovery.PreflightMetadata{
MissingSkills: []string{"speckit"},
},
},
{
name: "skill error with empty MissingSkills returns nil",
err: &preflight.SkillError{
MissingSkills: nil,
},
want: nil,
},
{
name: "tool error with empty MissingTools returns nil",
err: &preflight.ToolError{
MissingTools: []string{},
},
want: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
meta := extractPreflightMetadata(tt.err)

if tt.wantNil {
if meta != nil {
t.Errorf("expected nil metadata, got %+v", meta)
}
return
}

if meta == nil {
t.Fatal("expected non-nil metadata")
}

if len(tt.wantSkills) > 0 {
if len(meta.MissingSkills) != len(tt.wantSkills) {
t.Errorf("MissingSkills count = %d, want %d", len(meta.MissingSkills), len(tt.wantSkills))
}
for i, skill := range tt.wantSkills {
if i >= len(meta.MissingSkills) || meta.MissingSkills[i] != skill {
t.Errorf("MissingSkills[%d] = %q, want %q", i, meta.MissingSkills[i], skill)
}
}
}
got := extractPreflightMetadata(tt.err)

if len(tt.wantTools) > 0 {
if len(meta.MissingTools) != len(tt.wantTools) {
t.Errorf("MissingTools count = %d, want %d", len(meta.MissingTools), len(tt.wantTools))
}
for i, tool := range tt.wantTools {
if i >= len(meta.MissingTools) || meta.MissingTools[i] != tool {
t.Errorf("MissingTools[%d] = %q, want %q", i, meta.MissingTools[i], tool)
}
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("extractPreflightMetadata(%v) = %+v, want %+v", tt.err, got, tt.want)
}
})
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/wave/commands/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,7 @@ func runRun(opts RunOptions, debug bool) error {
if opts.ForceModel {
execOpts = append(execOpts, pipeline.WithForceModel(true))
}
registry := adapter.NewAdapterRegistry(nil)
registry := adapter.NewAdapterRegistry(m.Runtime.Fallbacks)
for name, a := range m.Adapters {
if a.Binary != "" {
registry.SetBinary(name, a.Binary)
Expand Down
Loading
Loading