Skip to content
Merged
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
2 changes: 1 addition & 1 deletion internal/observe/doc.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Package observe defines curated Run observability events, the Sink Agent
// adapters emit into, and the Run-owned Observer that writes Phase logs under
// .ship/runs, dumps high-signal terminal one-liners after each Phase, and
// .ship/runs, dumps one high-signal tools+tokens line after each Phase, and
// prints an Abort banner with report paths and last tools on Phase failure.
package observe
29 changes: 19 additions & 10 deletions internal/observe/observer.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ type Port interface {
// BeginPhase starts buffering for one Phase and returns the Sink Agent
// adapters emit into. Mid-Phase Emit must not write tool lines to Out.
BeginPhase(phase string) Sink
// EndPhase dumps high-signal one-liners for tools and token totals
// collected since BeginPhase, then clears the buffer.
// EndPhase dumps one high-signal line with the tool total and token
// totals collected since BeginPhase, then clears the buffer.
EndPhase()
// Abort prints a stderr banner with the Run report directory, Phase log
// path, and last tool lines, then clears the buffer. Used on Phase
Expand All @@ -28,7 +28,7 @@ type Port interface {
Abort()
}

// Observer buffers curated events for a Phase and dumps one-liners to Out
// Observer buffers curated events for a Phase and dumps a summary line to Out
// when EndPhase is called. Safe for concurrent Emit from an Agent adapter.
// Dir is the workspace root; Phase reports land under Dir/.ship/runs/<run-id>/.
type Observer struct {
Expand Down Expand Up @@ -96,26 +96,35 @@ func (o *Observer) Emit(e Event) {
_ = enc.Encode(e)
}

// EndPhase writes high-signal one-liners for buffered tools (and later tokens)
// then clears the buffer.
// EndPhase writes one high-signal line with the tool total and any token
// totals, then clears the buffer. Per-tool detail stays in the Phase log.
func (o *Observer) EndPhase() {
o.mu.Lock()
events := o.events
o.events = nil
o.mu.Unlock()

var tools int
var tokens *TokenCounts
for _, e := range events {
switch e.Kind {
case KindTool:
fmt.Fprintf(o.Out, "tool %s %dms %s\n", e.Name, e.DurationMS, e.Status)
tools++
case KindPhaseEnd:
if e.Tokens == nil {
continue
if e.Tokens != nil {
tokens = e.Tokens
}
fmt.Fprintf(o.Out, "tokens input=%d output=%d cache_read=%d cache_write=%d\n",
e.Tokens.Input, e.Tokens.Output, e.Tokens.CacheRead, e.Tokens.CacheWrite)
}
}
if tools == 0 && tokens == nil {
return
}
if tokens == nil {
fmt.Fprintf(o.Out, "tools %d\n", tools)
return
}
fmt.Fprintf(o.Out, "tools %d tokens input=%d output=%d cache_read=%d cache_write=%d\n",
tools, tokens.Input, tokens.Output, tokens.CacheRead, tokens.CacheWrite)
}

// Abort writes a banner with Run report and Phase log paths plus the last
Expand Down
84 changes: 29 additions & 55 deletions internal/observe/observer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import (
"github.com/maxBRT/ship-cli/internal/observe"
)

func TestObserver_EndPhase_dumpsToolOneLiners(t *testing.T) {
// Worked example: same curated tool fields as Phase logs (name,
// duration_ms, status); terminal presentation is one line each.
func TestObserver_EndPhase_dumpsOneToolsAndTokensLine(t *testing.T) {
// Terminal dump is one high-signal line: tool total plus tokens.
// Per-tool detail stays in the Phase JSON log only.
var out strings.Builder
obs := observe.New(t.TempDir(), &out)

Expand All @@ -26,57 +26,58 @@ func TestObserver_EndPhase_dumpsToolOneLiners(t *testing.T) {
DurationMS: 7,
Status: observe.ToolError,
})
sink.Emit(observe.Event{
Kind: observe.KindPhaseEnd,
Outcome: observe.OutcomeSuccess,
Tokens: &observe.TokenCounts{
Input: 120,
Output: 45,
CacheRead: 10,
CacheWrite: 2,
},
})
obs.EndPhase()

got := out.String()
wantLines := []string{
"tool Read 42ms ok",
"tool Write 7ms error",
want := "tools 2 tokens input=120 output=45 cache_read=10 cache_write=2\n"
if got != want {
t.Errorf("dump = %q, want %q", got, want)
}
for _, want := range wantLines {
if !strings.Contains(got, want) {
t.Errorf("dump missing %q; got:\n%s", want, got)
}
if strings.Contains(got, "tool Read") || strings.Contains(got, "tool Write") {
t.Errorf("dump must not list per-tool lines; got:\n%s", got)
}
}

func TestObserver_EndPhase_dumpsPhaseTokenTotals(t *testing.T) {
// Same curated token fields as phase_end in Phase logs; one terminal line.
func TestObserver_EndPhase_toolsOnlyWhenUsageAbsent(t *testing.T) {
var out strings.Builder
obs := observe.New(t.TempDir(), &out)

sink := obs.BeginPhase("Review")
sink := obs.BeginPhase("Final")
sink.Emit(observe.Event{
Kind: observe.KindTool,
Name: "Shell",
DurationMS: 10,
DurationMS: 3,
Status: observe.ToolOK,
})
sink.Emit(observe.Event{
Kind: observe.KindPhaseEnd,
Outcome: observe.OutcomeSuccess,
Tokens: &observe.TokenCounts{
Input: 120,
Output: 45,
CacheRead: 10,
CacheWrite: 2,
},
})
obs.EndPhase()

got := out.String()
if !strings.Contains(got, "tool Shell 10ms ok") {
t.Errorf("dump missing tool line; got:\n%s", got)
want := "tools 1\n"
if got != want {
t.Errorf("dump = %q, want %q", got, want)
}
wantTokens := "tokens input=120 output=45 cache_read=10 cache_write=2"
if !strings.Contains(got, wantTokens) {
t.Errorf("dump missing %q; got:\n%s", wantTokens, got)
if strings.Contains(got, "tokens") {
t.Errorf("dump = %q, want no tokens when usage absent", got)
}
}

func TestObserver_Emit_doesNotWriteUntilEndPhase(t *testing.T) {
// Throbber stays the only live UI during the Phase: buffered Emit must
// not interleave tool lines before EndPhase.
// not interleave dump lines before EndPhase.
var out strings.Builder
obs := observe.New(t.TempDir(), &out)

Expand All @@ -92,35 +93,8 @@ func TestObserver_Emit_doesNotWriteUntilEndPhase(t *testing.T) {
}

obs.EndPhase()
if !strings.Contains(out.String(), "tool Read 42ms ok") {
t.Errorf("after EndPhase dump missing tool line; got:\n%s", out.String())
}
}

func TestObserver_EndPhase_omitsTokensLineWhenUsageAbsent(t *testing.T) {
var out strings.Builder
obs := observe.New(t.TempDir(), &out)

sink := obs.BeginPhase("Final")
sink.Emit(observe.Event{
Kind: observe.KindTool,
Name: "Shell",
DurationMS: 3,
Status: observe.ToolOK,
})
sink.Emit(observe.Event{
Kind: observe.KindPhaseEnd,
Outcome: observe.OutcomeSuccess,
// Tokens absent: degrade gracefully — no tokens line.
})
obs.EndPhase()

got := out.String()
if !strings.Contains(got, "tool Shell 3ms ok") {
t.Errorf("dump missing tool line; got:\n%s", got)
}
if strings.Contains(got, "tokens") {
t.Errorf("dump = %q, want no tokens line when usage absent", got)
if got := out.String(); got != "tools 1\n" {
t.Errorf("after EndPhase dump = %q, want tools 1\\n", got)
}
}

Expand Down
15 changes: 7 additions & 8 deletions internal/run/orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,13 @@ func TestRun_afterPhase_dumpsHighSignalToolAndTokenLines(t *testing.T) {
}

got := dump.String()
// Three Phases each dump the same worked-example lines.
wantTool := "tool Read 42ms ok"
wantTokens := "tokens input=120 output=45 cache_read=10 cache_write=2"
if c := strings.Count(got, wantTool); c != 3 {
t.Errorf("tool dump lines = %d, want 3; got:\n%s", c, got)
}
if c := strings.Count(got, wantTokens); c != 3 {
t.Errorf("token dump lines = %d, want 3; got:\n%s", c, got)
// Three Phases each dump one high-signal tools+tokens line.
want := "tools 1 tokens input=120 output=45 cache_read=10 cache_write=2"
if c := strings.Count(got, want); c != 3 {
t.Errorf("phase dump lines = %d, want 3; got:\n%s", c, got)
}
if strings.Contains(got, "tool Read") {
t.Errorf("dump must not list per-tool lines; got:\n%s", got)
}
}

Expand Down
Loading