Skip to content

Commit bc53e6c

Browse files
authored
Merge branch 'main' into feat/import-commit-link
2 parents d108748 + 0bfed39 commit bc53e6c

60 files changed

Lines changed: 4871 additions & 746 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/dependabot.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,11 @@ updates:
3030
schedule:
3131
interval: "daily"
3232
open-pull-requests-limit: 5
33+
groups:
34+
# CodeQL subactions (init, analyze, ...) share one monorepo release and
35+
# must move together — a version mismatch fails the CodeQL job with
36+
# "Loaded a configuration file for version X, but running version Y".
37+
# Grouping bundles every github/codeql-action/* bump into one PR.
38+
codeql-action:
39+
patterns:
40+
- "github/codeql-action/*"

.github/workflows/codeql-actions.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,13 @@ jobs:
2424
ref: ${{ github.event.pull_request.head.sha }}
2525

2626
- name: Initialize CodeQL
27-
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
27+
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
2828
with:
2929
languages: actions
3030
queries: security-extended,security-and-quality
3131

3232
- name: Perform CodeQL analysis
33-
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
33+
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
3434
with:
3535
category: /language:actions
3636
# Fork PRs receive a read-only GITHUB_TOKEN, so SARIF upload to the

cmd/entire/cli/agent/agent.go

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

239+
// ProgressPhase identifies a coarse stage in streaming text generation.
240+
type ProgressPhase string
241+
242+
const (
243+
// PhaseConnecting is emitted once when the CLI signals it is making the upstream request.
244+
PhaseConnecting ProgressPhase = "connecting"
245+
// PhaseFirstToken is emitted once when the upstream responds with the first event,
246+
// carrying TTFT and input/cache token counts.
247+
PhaseFirstToken ProgressPhase = "first-token"
248+
// PhaseGenerating is emitted repeatedly as text or thinking deltas arrive.
249+
// OutputTokens carries a running estimate based on delta sizes.
250+
PhaseGenerating ProgressPhase = "generating"
251+
// PhaseDone is emitted once when the final result event is received without error.
252+
PhaseDone ProgressPhase = "done"
253+
)
254+
255+
// GenerationProgress reports a snapshot of streaming text generation progress.
256+
// Fields not relevant to the current Phase may be zero-valued.
257+
type GenerationProgress struct {
258+
Phase ProgressPhase
259+
OutputTokens int // running estimate during PhaseGenerating; final at PhaseDone
260+
InputTokens int // populated at PhaseFirstToken
261+
CachedInputTokens int // populated at PhaseFirstToken
262+
TTFTms int // time-to-first-token, populated at PhaseFirstToken
263+
DurationMs int // populated at PhaseDone (final result event)
264+
}
265+
266+
// ProgressFn receives streaming progress updates. It must not block — invoke it
267+
// from the same goroutine that reads the stream and keep handlers fast.
268+
type ProgressFn func(GenerationProgress)
269+
270+
// StreamingTextGenerator is an optional interface for text generators whose
271+
// underlying CLI exposes a streaming output mode. Callers can use AsStreamingTextGenerator
272+
// to detect support and fall back to plain GenerateText when unavailable.
273+
type StreamingTextGenerator interface {
274+
Agent
275+
276+
// GenerateTextStreaming invokes the agent's streaming text generation and
277+
// calls progress for each phase update. progress may be nil to suppress
278+
// reporting. The returned string is the final response text.
279+
GenerateTextStreaming(ctx context.Context, prompt, model string, progress ProgressFn) (string, error)
280+
}
281+
239282
// CompactedTranscript contains the result of transcript compaction into Entire
240283
// Transcript Format. Assets are accepted in the protocol shape for forward
241284
// compatibility but may not yet be persisted by all call sites.

cmd/entire/cli/agent/capabilities.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type DeclaredCaps struct {
2727
TokenCalculator bool `json:"token_calculator"`
2828
CompactTranscript bool `json:"compact_transcript"`
2929
TextGenerator bool `json:"text_generator"`
30+
StreamingTextGenerator bool `json:"streaming_text_generator"`
3031
HookResponseWriter bool `json:"hook_response_writer"`
3132
SubagentAwareExtractor bool `json:"subagent_aware_extractor"`
3233
}
@@ -100,6 +101,22 @@ func AsTextGenerator(ag Agent) (TextGenerator, bool) {
100101
return declaredCapability[TextGenerator](ag, func(c DeclaredCaps) bool { return c.TextGenerator })
101102
}
102103

104+
// AsStreamingTextGenerator returns the agent as StreamingTextGenerator if it both
105+
// implements the interface and (for CapabilityDeclarer agents) has declared the capability.
106+
func AsStreamingTextGenerator(ag Agent) (StreamingTextGenerator, bool) {
107+
if ag == nil {
108+
return nil, false
109+
}
110+
stg, ok := ag.(StreamingTextGenerator)
111+
if !ok {
112+
return nil, false
113+
}
114+
if cd, ok := ag.(CapabilityDeclarer); ok {
115+
return stg, cd.DeclaredCapabilities().StreamingTextGenerator
116+
}
117+
return stg, true
118+
}
119+
103120
// AsTranscriptCompactor returns the agent as TranscriptCompactor if it both
104121
// implements the interface and (for CapabilityDeclarer agents) has declared the capability.
105122
func AsTranscriptCompactor(ag Agent) (TranscriptCompactor, bool) {

cmd/entire/cli/agent/capabilities_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ func (m *mockFullAgent) GenerateText(context.Context, string, string) (string, e
8686
return "", nil
8787
}
8888

89+
// StreamingTextGenerator
90+
func (m *mockFullAgent) GenerateTextStreaming(context.Context, string, string, ProgressFn) (string, error) {
91+
return "", nil
92+
}
93+
8994
// TranscriptCompactor
9095
func (m *mockFullAgent) CompactTranscript(context.Context, string) (*CompactedTranscript, error) {
9196
return &CompactedTranscript{}, nil
@@ -102,6 +107,15 @@ func (m *mockFullAgent) CalculateTotalTokenUsage([]byte, int, string) (*TokenUsa
102107
return nil, nil //nolint:nilnil // test mock
103108
}
104109

110+
// mockBuiltinStreamingAgent is a built-in agent that implements StreamingTextGenerator but NOT CapabilityDeclarer.
111+
type mockBuiltinStreamingAgent struct {
112+
mockBaseAgent
113+
}
114+
115+
func (m *mockBuiltinStreamingAgent) GenerateTextStreaming(context.Context, string, string, ProgressFn) (string, error) {
116+
return "", nil
117+
}
118+
105119
// mockBuiltinPromptAgent is a built-in agent that implements PromptExtractor but NOT CapabilityDeclarer.
106120
type mockBuiltinPromptAgent struct {
107121
mockBaseAgent
@@ -439,3 +453,43 @@ func TestAsPromptExtractor(t *testing.T) {
439453
}
440454
})
441455
}
456+
457+
func TestAsStreamingTextGenerator(t *testing.T) {
458+
t.Parallel()
459+
460+
t.Run("not implemented", func(t *testing.T) {
461+
t.Parallel()
462+
ag := &mockBaseAgent{}
463+
_, ok := AsStreamingTextGenerator(ag)
464+
if ok {
465+
t.Error("expected false for agent not implementing StreamingTextGenerator")
466+
}
467+
})
468+
469+
t.Run("builtin agent", func(t *testing.T) {
470+
t.Parallel()
471+
ag := &mockBuiltinStreamingAgent{}
472+
stg, ok := AsStreamingTextGenerator(ag)
473+
if !ok || stg == nil {
474+
t.Error("expected true for built-in agent implementing StreamingTextGenerator")
475+
}
476+
})
477+
478+
t.Run("declared true", func(t *testing.T) {
479+
t.Parallel()
480+
ag := &mockFullAgent{caps: DeclaredCaps{StreamingTextGenerator: true}}
481+
stg, ok := AsStreamingTextGenerator(ag)
482+
if !ok || stg == nil {
483+
t.Error("expected true when capability declared true")
484+
}
485+
})
486+
487+
t.Run("declared false", func(t *testing.T) {
488+
t.Parallel()
489+
ag := &mockFullAgent{caps: DeclaredCaps{StreamingTextGenerator: false}}
490+
_, ok := AsStreamingTextGenerator(ag)
491+
if ok {
492+
t.Error("expected false when capability declared false")
493+
}
494+
})
495+
}

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,38 @@ func TestBuildGenerateArgs_PassesSettingsAsPath(t *testing.T) {
110110
}
111111
}
112112

113+
func TestBuildStreamingGenerateArgs_KeepsIsolationAndAuthContract(t *testing.T) {
114+
t.Parallel()
115+
// The streaming argv must keep the same isolation (--setting-sources "")
116+
// and auth-injection (--settings <path>) contract as buildGenerateArgs;
117+
// dropping the injection silently breaks apiKeyHelper (API-billing) auth
118+
// on every streaming call.
119+
args := buildStreamingGenerateArgs("haiku", "")
120+
got, ok := flagValue(args, "--setting-sources")
121+
if !ok {
122+
t.Fatalf("--setting-sources flag missing from args: %v", args)
123+
}
124+
if got != "" {
125+
t.Fatalf("--setting-sources = %q, want %q (must load no sources)", got, "")
126+
}
127+
if got, ok := flagValue(args, "--output-format"); !ok || got != "stream-json" {
128+
t.Fatalf("--output-format = %q, want stream-json: %v", got, args)
129+
}
130+
if _, ok := flagValue(args, "--settings"); ok {
131+
t.Fatalf("--settings must be absent when there is no settings path: %v", args)
132+
}
133+
134+
path := "/tmp/entire-claude-auth-123.json"
135+
args = buildStreamingGenerateArgs("haiku", path)
136+
got, ok = flagValue(args, "--settings")
137+
if !ok {
138+
t.Fatalf("--settings flag missing: %v", args)
139+
}
140+
if got != path {
141+
t.Fatalf("--settings = %q, want the file path %q", got, path)
142+
}
143+
}
144+
113145
func TestWriteAuthSettingsFile_WritesOnlyAPIKeyHelper0600(t *testing.T) {
114146
t.Parallel()
115147
helper := `echo "sk-ant-secret"` // could embed a literal key

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

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,26 @@ func buildGenerateArgs(model, settingsPath string) []string {
5151
return args
5252
}
5353

54+
// buildStreamingGenerateArgs is buildGenerateArgs for the stream-json path,
55+
// with the same isolation and auth-injection contract (see buildGenerateArgs).
56+
// --include-partial-messages enables the per-token stream_event envelopes
57+
// that PhaseFirstToken and PhaseGenerating are dispatched from, and
58+
// --verbose is required by the claude CLI for stream-json output.
59+
func buildStreamingGenerateArgs(model, settingsPath string) []string {
60+
args := []string{
61+
"--print",
62+
"--output-format", "stream-json",
63+
"--include-partial-messages",
64+
"--verbose",
65+
"--model", model,
66+
"--setting-sources", "",
67+
}
68+
if settingsPath != "" {
69+
args = append(args, "--settings", settingsPath)
70+
}
71+
return args
72+
}
73+
5474
// writeAuthSettingsFile writes a minimal claude settings file containing only
5575
// the given apiKeyHelper and returns its path plus a cleanup func. The file is
5676
// created 0600 so the (possibly key-bearing) helper is no more exposed than the
@@ -183,12 +203,21 @@ func (c *ClaudeCodeAgent) GenerateText(ctx context.Context, prompt string, model
183203
return "", classifyEnvelopeError(result, env.APIErrorStatus, exitCode)
184204
}
185205
// No structured signal on stdout — ctx cancellation is next most
186-
// informative, since the rest is a guess.
187-
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
188-
return "", context.DeadlineExceeded
189-
}
190-
if errors.Is(ctx.Err(), context.Canceled) {
191-
return "", context.Canceled
206+
// informative, since the rest is a guess. Wrap the sentinel in a
207+
// *agent.TextGenerationError (like the streaming path and every other
208+
// provider) so the explain timeout diagnostic still gets captured
209+
// stderr and the stdout byte count when this path is reached via the
210+
// old-CLI streaming fallback.
211+
if ctxErr := ctx.Err(); ctxErr != nil && (errors.Is(ctxErr, context.DeadlineExceeded) || errors.Is(ctxErr, context.Canceled)) {
212+
sentinel := context.Canceled
213+
if errors.Is(ctxErr, context.DeadlineExceeded) {
214+
sentinel = context.DeadlineExceeded
215+
}
216+
return "", &agent.TextGenerationError{
217+
Err: sentinel,
218+
Stderr: strings.TrimSpace(stderr.String()),
219+
StdoutBytes: stdout.Len(),
220+
}
192221
}
193222
if isExecNotFound(err) {
194223
return "", &ClaudeError{Kind: ClaudeErrorCLIMissing, Cause: err}

0 commit comments

Comments
 (0)