Skip to content

Commit 33a095c

Browse files
peyton-altclaude
andcommitted
feat: stream-driven progress and TTY-gated deadline for explain --generate
Switches Claude CLI invocation to --output-format stream-json with --include-partial-messages --verbose, rendering real phase updates as the summary generates: Generating checkpoint summary... (transcript: 47.0 KB) → Sending request to Anthropic… → Anthropic responded (TTFT 1.9s, 35.9k cached input tokens) — generating… → Writing summary… (~1.2k tokens) ✓ Summary generated (3.1s, 1.9k output tokens) Replaces the unconditional 30s wall-clock timeout with a TTY-gated deadline strategy: no deadline interactive (user Ctrl+C with full progress visibility), idle-based 5min in non-TTY/CI, optional summary_timeout_seconds settings override. Adds StreamingTextGenerator optional agent capability interface, stream NDJSON parser with captured fixtures, summaryProgressWriter using the existing statusStyles lipgloss helper, idle watchdog with atomic timestamps, and progress callback threading through summarize. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Entire-Checkpoint: a19df6695459
1 parent cd538a4 commit 33a095c

16 files changed

Lines changed: 1165 additions & 343 deletions

cmd/entire/cli/agent/agent.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,49 @@ type TextGenerator interface {
187187
GenerateText(ctx context.Context, prompt string, model string) (string, error)
188188
}
189189

190+
// ProgressPhase identifies a coarse stage in streaming text generation.
191+
type ProgressPhase string
192+
193+
const (
194+
// PhaseConnecting is emitted once when the CLI signals it is making the upstream request.
195+
PhaseConnecting ProgressPhase = "connecting"
196+
// PhaseFirstToken is emitted once when the upstream responds with the first event,
197+
// carrying TTFT and input/cache token counts.
198+
PhaseFirstToken ProgressPhase = "first-token"
199+
// PhaseGenerating is emitted repeatedly as text or thinking deltas arrive.
200+
// OutputTokens carries a running estimate based on delta sizes.
201+
PhaseGenerating ProgressPhase = "generating"
202+
// PhaseDone is emitted once when the final result event is received without error.
203+
PhaseDone ProgressPhase = "done"
204+
)
205+
206+
// GenerationProgress reports a snapshot of streaming text generation progress.
207+
// Fields not relevant to the current Phase may be zero-valued.
208+
type GenerationProgress struct {
209+
Phase ProgressPhase
210+
OutputTokens int // running estimate during PhaseGenerating; final at PhaseDone
211+
InputTokens int // populated at PhaseFirstToken
212+
CachedInputTokens int // populated at PhaseFirstToken
213+
TTFTms int // time-to-first-token, populated at PhaseFirstToken
214+
DurationMs int // populated at PhaseDone (final result event)
215+
}
216+
217+
// ProgressFn receives streaming progress updates. It must not block — invoke it
218+
// from the same goroutine that reads the stream and keep handlers fast.
219+
type ProgressFn func(GenerationProgress)
220+
221+
// StreamingTextGenerator is an optional interface for text generators whose
222+
// underlying CLI exposes a streaming output mode. Callers can use AsStreamingTextGenerator
223+
// to detect support and fall back to plain GenerateText when unavailable.
224+
type StreamingTextGenerator interface {
225+
Agent
226+
227+
// GenerateTextStreaming invokes the agent's streaming text generation and
228+
// calls progress for each phase update. progress may be nil to suppress
229+
// reporting. The returned string is the final response text.
230+
GenerateTextStreaming(ctx context.Context, prompt, model string, progress ProgressFn) (string, error)
231+
}
232+
190233
// HookResponseWriter is implemented by agents that support structured hook responses.
191234
// Agents that implement this can output messages (e.g., banners) to the user via
192235
// the agent's response protocol. For example, Claude Code outputs JSON with a

cmd/entire/cli/agent/capabilities.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type DeclaredCaps struct {
2121
TranscriptPreparer bool `json:"transcript_preparer"`
2222
TokenCalculator bool `json:"token_calculator"`
2323
TextGenerator bool `json:"text_generator"`
24+
StreamingTextGenerator bool `json:"streaming_text_generator"`
2425
HookResponseWriter bool `json:"hook_response_writer"`
2526
SubagentAwareExtractor bool `json:"subagent_aware_extractor"`
2627
}
@@ -105,6 +106,22 @@ func AsTextGenerator(ag Agent) (TextGenerator, bool) { //nolint:ireturn // type-
105106
return tg, true
106107
}
107108

109+
// AsStreamingTextGenerator returns the agent as StreamingTextGenerator if it both
110+
// implements the interface and (for CapabilityDeclarer agents) has declared the capability.
111+
func AsStreamingTextGenerator(ag Agent) (StreamingTextGenerator, bool) { //nolint:ireturn // type-assertion helper must return interface
112+
if ag == nil {
113+
return nil, false
114+
}
115+
stg, ok := ag.(StreamingTextGenerator)
116+
if !ok {
117+
return nil, false
118+
}
119+
if cd, ok := ag.(CapabilityDeclarer); ok {
120+
return stg, cd.DeclaredCapabilities().StreamingTextGenerator
121+
}
122+
return stg, true
123+
}
124+
108125
// AsHookResponseWriter returns the agent as HookResponseWriter if it both
109126
// implements the interface and (for CapabilityDeclarer agents) has declared the capability.
110127
func AsHookResponseWriter(ag Agent) (HookResponseWriter, bool) { //nolint:ireturn // type-assertion helper must return interface

cmd/entire/cli/agent/capabilities_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,20 @@ func (m *mockFullAgent) CalculateTotalTokenUsage([]byte, int, string) (*TokenUsa
9494
return nil, nil //nolint:nilnil // test mock
9595
}
9696

97+
// StreamingTextGenerator
98+
func (m *mockFullAgent) GenerateTextStreaming(context.Context, string, string, ProgressFn) (string, error) {
99+
return "", nil
100+
}
101+
102+
// mockBuiltinStreamingAgent is a built-in agent that implements StreamingTextGenerator but NOT CapabilityDeclarer.
103+
type mockBuiltinStreamingAgent struct {
104+
mockBaseAgent
105+
}
106+
107+
func (m *mockBuiltinStreamingAgent) GenerateTextStreaming(context.Context, string, string, ProgressFn) (string, error) {
108+
return "", nil
109+
}
110+
97111
// mockBuiltinPromptAgent is a built-in agent that implements PromptExtractor but NOT CapabilityDeclarer.
98112
type mockBuiltinPromptAgent struct {
99113
mockBaseAgent
@@ -371,3 +385,43 @@ func TestAsPromptExtractor(t *testing.T) {
371385
}
372386
})
373387
}
388+
389+
func TestAsStreamingTextGenerator(t *testing.T) {
390+
t.Parallel()
391+
392+
t.Run("not implemented", func(t *testing.T) {
393+
t.Parallel()
394+
ag := &mockBaseAgent{}
395+
_, ok := AsStreamingTextGenerator(ag)
396+
if ok {
397+
t.Error("expected false for agent not implementing StreamingTextGenerator")
398+
}
399+
})
400+
401+
t.Run("builtin agent", func(t *testing.T) {
402+
t.Parallel()
403+
ag := &mockBuiltinStreamingAgent{}
404+
stg, ok := AsStreamingTextGenerator(ag)
405+
if !ok || stg == nil {
406+
t.Error("expected true for built-in agent implementing StreamingTextGenerator")
407+
}
408+
})
409+
410+
t.Run("declared true", func(t *testing.T) {
411+
t.Parallel()
412+
ag := &mockFullAgent{caps: DeclaredCaps{StreamingTextGenerator: true}}
413+
stg, ok := AsStreamingTextGenerator(ag)
414+
if !ok || stg == nil {
415+
t.Error("expected true when capability declared true")
416+
}
417+
})
418+
419+
t.Run("declared false", func(t *testing.T) {
420+
t.Parallel()
421+
ag := &mockFullAgent{caps: DeclaredCaps{StreamingTextGenerator: false}}
422+
_, ok := AsStreamingTextGenerator(ag)
423+
if ok {
424+
t.Error("expected false when capability declared false")
425+
}
426+
})
427+
}

cmd/entire/cli/agent/claudecode/claude_test.go

Lines changed: 70 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import (
44
"context"
55
"errors"
66
"os/exec"
7+
"strings"
78
"testing"
9+
10+
"github.com/entireio/cli/cmd/entire/cli/agent"
811
)
912

1013
func TestResolveSessionFile(t *testing.T) {
@@ -26,34 +29,56 @@ func TestProtectedDirs(t *testing.T) {
2629
}
2730
}
2831

29-
func TestGenerateText_ArrayResponse(t *testing.T) {
32+
func TestGenerateTextStreaming_SuccessEmitsPhases(t *testing.T) {
3033
t.Parallel()
31-
ag := &ClaudeCodeAgent{
32-
CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
33-
response := `[{"type":"system","subtype":"init"},{"type":"assistant","message":"Working on it"},{"type":"result","result":"final generated text"}]`
34-
return exec.CommandContext(ctx, "sh", "-c", "printf '%s' '"+response+"'")
35-
},
36-
}
37-
38-
result, err := ag.GenerateText(context.Background(), "prompt", "")
34+
body := strings.Join([]string{
35+
`{"type":"system","subtype":"status","status":"requesting"}`,
36+
`{"type":"stream_event","event":{"type":"message_start","ttft_ms":1500,"message":{"usage":{"input_tokens":10,"cache_read_input_tokens":2000}}}}`,
37+
`{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"hello world"}}}`,
38+
`{"type":"result","subtype":"success","is_error":false,"result":"hello world","duration_ms":1700}`,
39+
}, "\n")
40+
ag := newAgentWithStdout(body)
41+
var phases []agent.ProgressPhase
42+
got, err := ag.GenerateTextStreaming(context.Background(), "p", "", func(p agent.GenerationProgress) {
43+
phases = append(phases, p.Phase)
44+
})
3945
if err != nil {
40-
t.Fatalf("unexpected error: %v", err)
46+
t.Fatalf("err = %v; want nil", err)
47+
}
48+
if got != "hello world" {
49+
t.Fatalf("got = %q; want %q", got, "hello world")
50+
}
51+
want := []agent.ProgressPhase{agent.PhaseConnecting, agent.PhaseFirstToken, agent.PhaseGenerating, agent.PhaseDone}
52+
if !equalPhases(phases, want) {
53+
t.Fatalf("phases = %v; want %v", phases, want)
4154
}
55+
}
4256

43-
if result != "final generated text" {
44-
t.Fatalf("GenerateText() = %q, want %q", result, "final generated text")
57+
func TestGenerateTextStreaming_EnvelopeErrorReturnsClaudeError(t *testing.T) {
58+
t.Parallel()
59+
body := strings.Join([]string{
60+
`{"type":"system","subtype":"status","status":"requesting"}`,
61+
`{"type":"result","subtype":"success","is_error":true,"api_error_status":401,"result":"Auth required"}`,
62+
}, "\n")
63+
ag := newAgentWithStdout(body)
64+
_, err := ag.GenerateTextStreaming(context.Background(), "p", "", nil)
65+
var ce *ClaudeError
66+
if !errors.As(err, &ce) {
67+
t.Fatalf("err = %v; want *ClaudeError", err)
68+
}
69+
if ce.Kind != ClaudeErrorAuth {
70+
t.Fatalf("Kind = %v; want %v", ce.Kind, ClaudeErrorAuth)
4571
}
4672
}
4773

48-
func TestGenerateText_EnvelopeErrorReturnsClaudeError(t *testing.T) {
74+
func TestGenerateTextStreaming_StderrAuthFallback(t *testing.T) {
4975
t.Parallel()
5076
ag := &ClaudeCodeAgent{
5177
CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
52-
response := `{"type":"result","subtype":"success","is_error":true,"api_error_status":401,"result":"Auth required"}`
53-
return exec.CommandContext(ctx, "sh", "-c", "printf '%s' '"+response+"'")
78+
return exec.CommandContext(ctx, "sh", "-c", "printf 'Invalid API key' 1>&2; exit 2")
5479
},
5580
}
56-
_, err := ag.GenerateText(context.Background(), "prompt", "")
81+
_, err := ag.GenerateTextStreaming(context.Background(), "p", "", nil)
5782
var ce *ClaudeError
5883
if !errors.As(err, &ce) {
5984
t.Fatalf("err = %v; want *ClaudeError", err)
@@ -63,14 +88,14 @@ func TestGenerateText_EnvelopeErrorReturnsClaudeError(t *testing.T) {
6388
}
6489
}
6590

66-
func TestGenerateText_CLIMissing(t *testing.T) {
91+
func TestGenerateTextStreaming_CLIMissing(t *testing.T) {
6792
t.Parallel()
6893
ag := &ClaudeCodeAgent{
6994
CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
7095
return exec.CommandContext(ctx, "/nonexistent/binary/that/does/not/exist")
7196
},
7297
}
73-
_, err := ag.GenerateText(context.Background(), "prompt", "")
98+
_, err := ag.GenerateTextStreaming(context.Background(), "p", "", nil)
7499
var ce *ClaudeError
75100
if !errors.As(err, &ce) {
76101
t.Fatalf("err = %v; want *ClaudeError", err)
@@ -80,19 +105,37 @@ func TestGenerateText_CLIMissing(t *testing.T) {
80105
}
81106
}
82107

83-
func TestGenerateText_StderrAuthFallback(t *testing.T) {
108+
func TestGenerateText_DelegatesToStreaming(t *testing.T) {
84109
t.Parallel()
85-
ag := &ClaudeCodeAgent{
110+
body := `{"type":"result","subtype":"success","is_error":false,"result":"ok"}`
111+
ag := newAgentWithStdout(body)
112+
got, err := ag.GenerateText(context.Background(), "p", "")
113+
if err != nil {
114+
t.Fatalf("err = %v; want nil", err)
115+
}
116+
if got != "ok" {
117+
t.Fatalf("got = %q; want %q", got, "ok")
118+
}
119+
}
120+
121+
// newAgentWithStdout returns a ClaudeCodeAgent whose CommandRunner produces a
122+
// subprocess that prints the given body to stdout and exits 0.
123+
func newAgentWithStdout(body string) *ClaudeCodeAgent {
124+
return &ClaudeCodeAgent{
86125
CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
87-
return exec.CommandContext(ctx, "sh", "-c", "printf 'Invalid API key' 1>&2; exit 2")
126+
return exec.CommandContext(ctx, "sh", "-c", "cat <<'ENDOFSTREAM'\n"+body+"\nENDOFSTREAM")
88127
},
89128
}
90-
_, err := ag.GenerateText(context.Background(), "prompt", "")
91-
var ce *ClaudeError
92-
if !errors.As(err, &ce) {
93-
t.Fatalf("err = %v; want *ClaudeError", err)
129+
}
130+
131+
func equalPhases(a, b []agent.ProgressPhase) bool {
132+
if len(a) != len(b) {
133+
return false
94134
}
95-
if ce.Kind != ClaudeErrorAuth {
96-
t.Fatalf("Kind = %v; want %v", ce.Kind, ClaudeErrorAuth)
135+
for i := range a {
136+
if a[i] != b[i] {
137+
return false
138+
}
97139
}
140+
return true
98141
}

0 commit comments

Comments
 (0)