diff --git a/internal/observe/doc.go b/internal/observe/doc.go index b542eb5..d1be63f 100644 --- a/internal/observe/doc.go +++ b/internal/observe/doc.go @@ -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 diff --git a/internal/observe/observer.go b/internal/observe/observer.go index 023e6b4..5226d81 100644 --- a/internal/observe/observer.go +++ b/internal/observe/observer.go @@ -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 @@ -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//. type Observer struct { @@ -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 diff --git a/internal/observe/observer_test.go b/internal/observe/observer_test.go index 1221d59..db73288 100644 --- a/internal/observe/observer_test.go +++ b/internal/observe/observer_test.go @@ -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) @@ -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) @@ -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) } } diff --git a/internal/run/orchestrator_test.go b/internal/run/orchestrator_test.go index 3c45efd..a8b8892 100644 --- a/internal/run/orchestrator_test.go +++ b/internal/run/orchestrator_test.go @@ -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) } }