• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / ralphex / 29892218780

22 Jul 2026 04:52AM UTC coverage: 83.811% (+0.09%) from 83.718%
29892218780

push

github

umputun
chore(deps): bump actions/setup-go from 6 to 7

Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6 to 7.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

7833 of 9346 relevant lines covered (83.81%)

226.5 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.0
/pkg/executor/executor.go
1
// Package executor provides CLI execution for Claude and Codex tools.
2
package executor
3

4
import (
5
        "context"
6
        "encoding/json"
7
        "fmt"
8
        "io"
9
        "log"
10
        "os"
11
        "os/exec"
12
        "strings"
13
        "time"
14

15
        "github.com/umputun/ralphex/pkg/status"
16
)
17

18
//go:generate moq -out mocks/command_runner.go -pkg mocks -skip-ensure -fmt goimports . CommandRunner
19

20
// Result holds execution result with output and detected signal.
21
type Result struct {
22
        Output       string // accumulated text output
23
        RecentText   string // last 10 text blocks joined, used for pattern matching to avoid false positives
24
        Signal       string // detected signal (COMPLETED, FAILED, etc.) or empty
25
        Error        error  // execution error if any
26
        IdleTimedOut bool   // true when idle timeout fired (derived context canceled, parent alive)
27
}
28

29
const recentBlockCount = 10 // number of recent text blocks to keep for pattern matching
30

31
// subagentProgressInterval throttles subagent (Task tool) heartbeat lines: at most
32
// one is forwarded per interval so a burst of tool steps across several parallel
33
// review agents does not flood the progress stream. a single "still working" line
34
// every few seconds is enough to show the review is alive. only these synthesized
35
// heartbeat lines are throttled — the model's own text output is never dropped.
36
const subagentProgressInterval = 10 * time.Second
37

38
// PatternMatchError is returned when a configured error pattern is detected in output.
39
type PatternMatchError struct {
40
        Pattern string // the pattern that matched
41
        HelpCmd string // command to run for more information (e.g., "claude /usage")
42
}
43

44
func (e *PatternMatchError) Error() string {
1✔
45
        return fmt.Sprintf("detected error pattern: %q", e.Pattern)
1✔
46
}
1✔
47

48
// LimitPatternError is returned when a configured rate limit pattern is detected in output.
49
// when wait-on-limit is configured, the caller retries instead of exiting.
50
type LimitPatternError struct {
51
        Pattern string // the pattern that matched
52
        HelpCmd string // command to run for more information
53
}
54

55
func (e *LimitPatternError) Error() string {
1✔
56
        return fmt.Sprintf("detected limit pattern: %q", e.Pattern)
1✔
57
}
1✔
58

59
// RetryPatternError is returned when a configured transient retry pattern is detected in output.
60
// The processor maps it to existing timeout-style phase retries instead of rate-limit waiting.
61
type RetryPatternError struct {
62
        Pattern string // the pattern that matched
63
}
64

65
func (e *RetryPatternError) Error() string {
1✔
66
        return fmt.Sprintf("detected retry pattern: %q", e.Pattern)
1✔
67
}
1✔
68

69
// CommandRunner abstracts command execution for testing.
70
// Returns an io.Reader for streaming output and a wait function for completion.
71
type CommandRunner interface {
72
        Run(ctx context.Context, name string, args ...string) (output io.Reader, wait func() error, err error)
73
}
74

75
// execClaudeRunner is the default command runner using os/exec.
76
// when stdin is non-nil, it is connected to the child process's stdin (used to pass
77
// the prompt via pipe instead of a -p CLI argument to avoid Windows 8191-char cmd limit).
78
// preserveAPIKey, when true, leaves ANTHROPIC_API_KEY intact in the child env (for users
79
// who authenticate Claude Code via API key rather than OAuth/keychain).
80
type execClaudeRunner struct {
81
        stdin          io.Reader
82
        preserveAPIKey bool
83
}
84

85
func (r *execClaudeRunner) Run(ctx context.Context, name string, args ...string) (io.Reader, func() error, error) {
5✔
86
        // check context before starting to avoid spawning a process that will be immediately killed
5✔
87
        if err := ctx.Err(); err != nil {
5✔
88
                return nil, nil, fmt.Errorf("context already canceled: %w", err)
×
89
        }
×
90

91
        // use exec.Command (not CommandContext) because we handle cancellation ourselves
92
        // to ensure the entire process group is killed, not just the direct child
93
        cmd := exec.Command(name, args...) //nolint:noctx // intentional: we handle context cancellation via process group kill
5✔
94

5✔
95
        // build child env: always strip CLAUDECODE (prevents nested session errors); strip
5✔
96
        // ANTHROPIC_API_KEY by default so a host-set key cannot silently override OAuth/keychain
5✔
97
        // auth and bill a different account. preserveAPIKey opts into keeping the key for users
5✔
98
        // who authenticate Claude Code via API key.
5✔
99
        cmd.Env = claudeChildEnv(os.Environ(), r.preserveAPIKey)
5✔
100

5✔
101
        // pass prompt via stdin when set (avoids Windows 8191-char command-line limit)
5✔
102
        if r.stdin != nil {
7✔
103
                cmd.Stdin = r.stdin
2✔
104
        }
2✔
105

106
        // create new process group so we can kill all descendants on cleanup
107
        setupProcessGroup(cmd)
5✔
108

5✔
109
        stdout, err := cmd.StdoutPipe()
5✔
110
        if err != nil {
5✔
111
                return nil, nil, fmt.Errorf("create stdout pipe: %w", err)
×
112
        }
×
113
        // merge stderr into stdout like python's stderr=subprocess.STDOUT
114
        cmd.Stderr = cmd.Stdout
5✔
115
        if err := cmd.Start(); err != nil {
5✔
116
                return nil, nil, fmt.Errorf("start command: %w", err)
×
117
        }
×
118

119
        // setup process group cleanup with graceful shutdown on context cancellation
120
        cleanup := newProcessGroupCleanup(cmd, ctx.Done())
5✔
121

5✔
122
        return stdout, cleanup.Wait, nil
5✔
123
}
124

125
// splitArgs splits a space-separated argument string into a slice.
126
// handles quoted strings (both single and double quotes).
127
func splitArgs(s string) []string {
17✔
128
        var args []string
17✔
129
        var current strings.Builder
17✔
130
        var inQuote rune
17✔
131
        var escaped bool
17✔
132

17✔
133
        for _, r := range s {
482✔
134
                if escaped {
467✔
135
                        current.WriteRune(r)
2✔
136
                        escaped = false
2✔
137
                        continue
2✔
138
                }
139

140
                if r == '\\' {
465✔
141
                        escaped = true
2✔
142
                        continue
2✔
143
                }
144

145
                if r == '"' || r == '\'' {
469✔
146
                        switch { //nolint:staticcheck // cannot use tagged switch because we compare with both inQuote and r
8✔
147
                        case inQuote == 0:
4✔
148
                                inQuote = r
4✔
149
                        case inQuote == r:
4✔
150
                                inQuote = 0
4✔
151
                        default:
×
152
                                current.WriteRune(r)
×
153
                        }
154
                        continue
8✔
155
                }
156

157
                if r == ' ' && inQuote == 0 {
486✔
158
                        if current.Len() > 0 {
61✔
159
                                args = append(args, current.String())
28✔
160
                                current.Reset()
28✔
161
                        }
28✔
162
                        continue
33✔
163
                }
164

165
                current.WriteRune(r)
420✔
166
        }
167

168
        if current.Len() > 0 {
31✔
169
                args = append(args, current.String())
14✔
170
        }
14✔
171

172
        return args
17✔
173
}
174

175
// stripFlag removes all occurrences of a flag and its value from args. Handles three forms:
176
// "--flag value" (space-separated, skips next element only if it doesn't look like another flag),
177
// "--flag=value" (single token), and a bare "--flag" with no following value. Returns a new slice.
178
// The "looks like another flag" heuristic (starts with "-") preserves unrelated flags that happen
179
// to follow a malformed bare "--flag" in the middle of args.
180
func stripFlag(args []string, flag string) []string {
18✔
181
        prefix := flag + "="
18✔
182
        result := make([]string, 0, len(args))
18✔
183
        for i := 0; i < len(args); i++ {
74✔
184
                if args[i] == flag {
64✔
185
                        // space form: skip the next token only if it's an actual value, not another flag
8✔
186
                        if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
13✔
187
                                i++
5✔
188
                        }
5✔
189
                        continue
8✔
190
                }
191
                if strings.HasPrefix(args[i], prefix) {
52✔
192
                        // equals form: drop the single token "--flag=value"
4✔
193
                        continue
4✔
194
                }
195
                result = append(result, args[i])
44✔
196
        }
197
        return result
18✔
198
}
199

200
// claudeChildEnv builds the environment for a child claude process. CLAUDECODE is always
201
// stripped to prevent nested-session errors. ANTHROPIC_API_KEY is stripped unless
202
// preserveAPIKey is true; preserving it is required for users who authenticate Claude Code
203
// via API key rather than OAuth/keychain.
204
func claudeChildEnv(env []string, preserveAPIKey bool) []string {
10✔
205
        if preserveAPIKey {
13✔
206
                return filterEnv(env, "CLAUDECODE")
3✔
207
        }
3✔
208
        return filterEnv(env, "ANTHROPIC_API_KEY", "CLAUDECODE")
7✔
209
}
210

211
// filterEnv returns a copy of env with specified keys removed.
212
func filterEnv(env []string, keysToRemove ...string) []string {
24✔
213
        result := make([]string, 0, len(env))
24✔
214
        for _, e := range env {
985✔
215
                skip := false
961✔
216
                for _, key := range keysToRemove {
2,512✔
217
                        if strings.HasPrefix(e, key+"=") {
1,569✔
218
                                skip = true
18✔
219
                                break
18✔
220
                        }
221
                }
222
                if !skip {
1,904✔
223
                        result = append(result, e)
943✔
224
                }
943✔
225
        }
226
        return result
24✔
227
}
228

229
// streamEvent represents a JSON event from claude CLI stream output.
230
type streamEvent struct {
231
        Type    string `json:"type"`
232
        Subtype string `json:"subtype"` // for "system" events: init, task_started, task_progress, etc.
233
        Message struct {
234
                Content []struct {
235
                        Type string `json:"type"`
236
                        Text string `json:"text"`
237
                } `json:"content"`
238
        } `json:"message"`
239
        ContentBlock struct {
240
                Type string `json:"type"`
241
                Text string `json:"text"`
242
        } `json:"content_block"`
243
        Delta struct {
244
                Type string `json:"type"`
245
                Text string `json:"text"`
246
        } `json:"delta"`
247
        Result json.RawMessage `json:"result"` // can be string or object with "output" field
248
        // subagent (Task tool) progress: newer Claude Code streams subagent activity as
249
        // system/task_started (the agent's task title) and system/task_progress (per step)
250
        // events whose description names the action; the subagent type is intentionally
251
        // not surfaced (stock config runs every review agent as "general-purpose").
252
        Description string `json:"description"` // task title / current step, e.g. "Running tests"
253
}
254

255
// ClaudeExecutor runs claude CLI commands with streaming JSON parsing.
256
type ClaudeExecutor struct {
257
        Command        string            // command to execute, defaults to "claude"
258
        Args           string            // additional arguments (space-separated), defaults to standard args
259
        ArgsSet        bool              // true when Args was explicitly set, including an empty value
260
        Model          string            // model override (e.g., "fable", "opus", "sonnet", "haiku"); empty = CLI default
261
        Effort         string            // reasoning effort override (e.g., "low", "medium", "high", "xhigh", "max"); empty = CLI default
262
        OutputHandler  func(text string) // called for each text chunk, can be nil
263
        Debug          bool              // enable debug output
264
        ErrorPatterns  []string          // patterns to detect in output (e.g., rate limit messages)
265
        LimitPatterns  []string          // patterns to detect rate limits (checked before error patterns)
266
        RetryPatterns  []string          // patterns to detect transient errors that should retry like timeouts
267
        IdleTimeout    time.Duration     // kill session after this duration of no output, zero = disabled
268
        PreserveAPIKey bool              // when true, ANTHROPIC_API_KEY is passed through to the child; default false strips it
269
        cmdRunner      CommandRunner     // for testing, nil uses default
270
        nowFn          func() time.Time  // for testing throttle timing, nil uses time.Now
271
}
272

273
// now returns the current time, using the injected nowFn when set (tests) or
274
// time.Now otherwise.
275
func (e *ClaudeExecutor) now() time.Time {
7✔
276
        if e.nowFn != nil {
14✔
277
                return e.nowFn()
7✔
278
        }
7✔
279
        return time.Now()
×
280
}
281

282
// Run executes claude CLI with the given prompt and parses streaming JSON output.
283
func (e *ClaudeExecutor) Run(ctx context.Context, prompt string) Result {
50✔
284
        cmd := e.Command
50✔
285
        if cmd == "" {
97✔
286
                cmd = "claude"
47✔
287
        }
47✔
288

289
        // build args from configured string or use defaults
290
        var args []string
50✔
291
        switch {
50✔
292
        case e.ArgsSet:
1✔
293
                args = splitArgs(e.Args)
1✔
294
        case e.Args != "":
7✔
295
                args = splitArgs(e.Args)
7✔
296
        default:
42✔
297
                args = []string{
42✔
298
                        "--dangerously-skip-permissions",
42✔
299
                        "--output-format", "stream-json",
42✔
300
                        "--verbose",
42✔
301
                }
42✔
302
        }
303
        // inject --model flag if a model override is configured;
304
        // strip any existing --model from args to avoid duplicate/conflicting flags
305
        if e.Model != "" {
53✔
306
                args = stripFlag(args, "--model")
3✔
307
                args = append(args, "--model", e.Model)
3✔
308
        }
3✔
309
        // inject --effort flag if an effort override is configured;
310
        // strip any existing --effort from args to avoid duplicate/conflicting flags
311
        if e.Effort != "" {
54✔
312
                args = stripFlag(args, "--effort")
4✔
313
                args = append(args, "--effort", e.Effort)
4✔
314
        }
4✔
315
        // always append --print to enable non-interactive mode; mirrors old -p flag that was
316
        // always appended. wrapper scripts ignore unknown flags via '*) shift ;;' catch-all.
317
        args = append(args, "--print")
50✔
318
        // pass prompt via stdin to avoid Windows 8191-char command-line limit;
50✔
319
        // if cmdRunner is set (test injection), use it; otherwise use real runner
50✔
320
        stdinReader := strings.NewReader(prompt)
50✔
321
        var runner CommandRunner
50✔
322
        if e.cmdRunner != nil {
99✔
323
                runner = e.cmdRunner
49✔
324
        } else {
50✔
325
                runner = &execClaudeRunner{stdin: stdinReader, preserveAPIKey: e.PreserveAPIKey}
1✔
326
        }
1✔
327

328
        // set up idle timeout: derive a cancellable context that fires when no output
329
        // is received for IdleTimeout duration. the touch closure resets the timer on
330
        // each line of output and is called from parseStream's readLines handler.
331
        execCtx := ctx
50✔
332
        idleTouch := func() {} // no-op by default
105✔
333
        if e.IdleTimeout > 0 {
57✔
334
                var idleCancel context.CancelFunc
7✔
335
                execCtx, idleCancel = context.WithCancel(ctx)
7✔
336
                defer idleCancel()
7✔
337
                timer := time.AfterFunc(e.IdleTimeout, idleCancel)
7✔
338
                defer timer.Stop()
7✔
339
                idleTouch = func() { timer.Reset(e.IdleTimeout) }
18✔
340
        }
341

342
        stdout, wait, err := runner.Run(execCtx, cmd, args...)
50✔
343
        if err != nil {
51✔
344
                return Result{Error: err}
1✔
345
        }
1✔
346

347
        result := e.parseStream(execCtx, stdout, idleTouch)
49✔
348
        waitErr := wait()
49✔
349

49✔
350
        // idle timeout: derived context canceled but parent is alive — not an error.
49✔
351
        // return accumulated output and signal as-is, clearing any context-cancellation errors.
49✔
352
        // set IdleTimedOut so the runner can distinguish idle timeout from normal completion
49✔
353
        // and avoid false "no changes detected" exits in review loops.
49✔
354
        if e.IdleTimeout > 0 && execCtx.Err() != nil && ctx.Err() == nil {
54✔
355
                if patternErr := e.patternError(result.RecentText, result.Signal); patternErr != nil {
8✔
356
                        return Result{Output: result.Output, RecentText: result.RecentText, Signal: result.Signal, Error: patternErr}
3✔
357
                }
3✔
358
                result.Error = nil
2✔
359
                result.IdleTimedOut = true
2✔
360
                return result
2✔
361
        }
362

363
        if waitErr != nil {
51✔
364
                // check if it was context cancellation
7✔
365
                if ctx.Err() != nil {
8✔
366
                        return Result{Output: result.Output, RecentText: result.RecentText, Signal: result.Signal, Error: ctx.Err()}
1✔
367
                }
1✔
368
                if result.Output == "" {
7✔
369
                        return Result{Error: fmt.Errorf("claude exited with error: %w", waitErr)}
1✔
370
                }
1✔
371
                // non-zero exit with output but no signal means claude failed without doing useful work.
372
                // if there IS a signal, work was done — ignore exit code (some tasks exit non-zero after completion).
373
                if result.Signal == "" {
8✔
374
                        result.Error = fmt.Errorf("claude exited with error: %w", waitErr)
3✔
375
                }
3✔
376
        }
377

378
        if patternErr := e.patternError(result.RecentText, result.Signal); patternErr != nil {
57✔
379
                return Result{Output: result.Output, RecentText: result.RecentText, Signal: result.Signal, Error: patternErr}
15✔
380
        }
15✔
381

382
        return result
27✔
383
}
384

385
func (e *ClaudeExecutor) patternError(recentText, signal string) error {
47✔
386
        // a non-empty signal means claude reported a structured outcome (completion, review-done,
47✔
387
        // etc). a stray retry marker in the output must not discard that by forcing a session
47✔
388
        // re-run, so retry detection is skipped when a signal is present. limit and error patterns
47✔
389
        // still fire — they surface loudly instead of silently re-running, so they cannot drop work.
47✔
390
        if signal == "" {
89✔
391
                if pattern := matchPattern(recentText, e.RetryPatterns); pattern != "" {
45✔
392
                        return &RetryPatternError{Pattern: pattern}
3✔
393
                }
3✔
394
        }
395
        if pattern := matchPattern(recentText, e.LimitPatterns); pattern != "" {
49✔
396
                return &LimitPatternError{Pattern: pattern, HelpCmd: "claude /usage"}
5✔
397
        }
5✔
398
        if pattern := matchPattern(recentText, e.ErrorPatterns); pattern != "" {
49✔
399
                return &PatternMatchError{Pattern: pattern, HelpCmd: "claude /usage"}
10✔
400
        }
10✔
401
        return nil
29✔
402
}
403

404
// parseStream reads and parses the JSON stream from claude CLI.
405
// uses readLines internally, so there is no line length limit.
406
// checks ctx.Done() between reads so cancellation is not blocked by slow pipe reads.
407
// idleTouch resets the idle timer on each line of output; pass no-op when idle timeout is disabled.
408
func (e *ClaudeExecutor) parseStream(ctx context.Context, r io.Reader, idleTouch func()) Result {
72✔
409
        var output strings.Builder
72✔
410
        var signal string
72✔
411
        var recentBlocks [recentBlockCount]string
72✔
412
        var blockIdx int
72✔
413
        var lastProgress time.Time // throttle window for subagent heartbeat lines
72✔
414

72✔
415
        err := readLines(ctx, r, func(line string) {
181✔
416
                idleTouch() // reset idle timer on every line of pipe activity
109✔
417
                if line == "" {
112✔
418
                        return
3✔
419
                }
3✔
420

421
                var event streamEvent
106✔
422
                if jsonErr := json.Unmarshal([]byte(line), &event); jsonErr != nil {
111✔
423
                        // print non-JSON lines as-is
5✔
424
                        if e.Debug {
6✔
425
                                log.Printf("[debug] non-JSON line: %s", line)
1✔
426
                        }
1✔
427
                        output.WriteString(line)
5✔
428
                        output.WriteString("\n")
5✔
429
                        recentBlocks[blockIdx%recentBlockCount] = line
5✔
430
                        blockIdx++
5✔
431
                        if e.OutputHandler != nil {
5✔
432
                                e.OutputHandler(line + "\n")
×
433
                        }
×
434
                        return
5✔
435
                }
436

437
                // surface subagent (Task tool) progress. newer Claude Code streams subagent
438
                // activity as system/task_* events that carry no text block, so extractText
439
                // drops them; without this the parent session appears silent for the whole
440
                // duration of a multi-agent review. forwarded to OutputHandler only — not
441
                // accumulated into output/recentBlocks/signal, which track the model's own text.
442
                // task_started (title) is unthrottled; per-step task_progress is throttled so
443
                // parallel agents don't flood.
444
                if hb, throttle := e.subagentLine(&event); hb != "" {
111✔
445
                        if e.OutputHandler == nil {
10✔
446
                                return
×
447
                        }
×
448
                        if throttle {
17✔
449
                                if now := e.now(); now.Sub(lastProgress) >= subagentProgressInterval {
11✔
450
                                        lastProgress = now
4✔
451
                                        e.OutputHandler(hb)
4✔
452
                                }
4✔
453
                                return
7✔
454
                        }
455
                        e.OutputHandler(hb)
3✔
456
                        return
3✔
457
                }
458

459
                text := e.extractText(&event)
91✔
460
                if text != "" {
180✔
461
                        output.WriteString(text)
89✔
462
                        if e.OutputHandler != nil {
95✔
463
                                e.OutputHandler(text)
6✔
464
                        }
6✔
465

466
                        // track recent blocks for pattern matching (avoids false positives on full output)
467
                        recentBlocks[blockIdx%recentBlockCount] = text
89✔
468
                        blockIdx++
89✔
469

89✔
470
                        // check for signals in text
89✔
471
                        if sig := detectSignal(text); sig != "" {
99✔
472
                                signal = sig
10✔
473
                        }
10✔
474
                }
475
        })
476

477
        // join recent blocks in chronological order for pattern matching.
478
        // iterate from the oldest slot forward to preserve order after wrap-around.
479
        var recent strings.Builder
72✔
480
        start := blockIdx % recentBlockCount
72✔
481
        for i := range recentBlockCount {
792✔
482
                b := recentBlocks[(start+i)%recentBlockCount]
720✔
483
                if b != "" {
812✔
484
                        recent.WriteString(b)
92✔
485
                        recent.WriteString("\n")
92✔
486
                }
92✔
487
        }
488

489
        if err != nil {
78✔
490
                return Result{Output: output.String(), RecentText: recent.String(), Signal: signal,
6✔
491
                        Error: fmt.Errorf("stream read: %w", err)}
6✔
492
        }
6✔
493

494
        return Result{Output: output.String(), RecentText: recent.String(), Signal: signal}
66✔
495
}
496

497
// subagentLine formats a one-line heartbeat for a subagent (Task tool) system
498
// event and reports whether the line should be throttled, or "" for events with
499
// no surfaced progress. newer Claude Code streams subagent activity as system
500
// task_* events whose payload carries no text block; surfacing the description
501
// keeps the parent session from appearing silent while a multi-agent review runs.
502
// task_started (the agent's task title) is unthrottled; task_progress (per step)
503
// is throttled by the caller. the subagent type and tool name are intentionally
504
// omitted — the description already names the action, and stock config runs every
505
// review agent as "general-purpose" so a "[general-purpose]" prefix is just noise.
506
func (e *ClaudeExecutor) subagentLine(event *streamEvent) (line string, throttle bool) {
108✔
507
        if event.Type != "system" || event.Description == "" {
204✔
508
                return "", false
96✔
509
        }
96✔
510
        switch event.Subtype {
12✔
511
        case "task_started":
4✔
512
                return "  " + event.Description + "\n", false
4✔
513
        case "task_progress":
8✔
514
                return "  " + event.Description + "\n", true
8✔
515
        default:
×
516
                return "", false
×
517
        }
518
}
519

520
// extractText extracts text content from various event types.
521
func (e *ClaudeExecutor) extractText(event *streamEvent) string {
102✔
522
        switch event.Type {
102✔
523
        case "assistant":
6✔
524
                // assistant events contain message.content array with text blocks
6✔
525
                var texts []string
6✔
526
                for _, c := range event.Message.Content {
12✔
527
                        if c.Type == "text" && c.Text != "" {
12✔
528
                                texts = append(texts, c.Text)
6✔
529
                        }
6✔
530
                }
531
                return strings.Join(texts, "")
6✔
532
        case "content_block_delta":
87✔
533
                if event.Delta.Type == "text_delta" {
173✔
534
                        return event.Delta.Text
86✔
535
                }
86✔
536
        case "message_stop":
3✔
537
                // check final message content
3✔
538
                for _, c := range event.Message.Content {
5✔
539
                        if c.Type == "text" {
3✔
540
                                return c.Text
1✔
541
                        }
1✔
542
                }
543
        case "result":
4✔
544
                // result can be a string or object with "output" field
4✔
545
                if len(event.Result) == 0 {
4✔
546
                        return ""
×
547
                }
×
548
                // try as string first (session summary format)
549
                var resultStr string
4✔
550
                if err := json.Unmarshal(event.Result, &resultStr); err == nil {
6✔
551
                        return "" // skip session summary - content already streamed
2✔
552
                }
2✔
553
                // try as object with output field
554
                var resultObj struct {
2✔
555
                        Output string `json:"output"`
2✔
556
                }
2✔
557
                if err := json.Unmarshal(event.Result, &resultObj); err == nil {
4✔
558
                        return resultObj.Output
2✔
559
                }
2✔
560
        }
561
        return ""
5✔
562
}
563

564
// detectSignal checks text for completion status.
565
// looks for <<<RALPHEX:...>>> format status.
566
func detectSignal(text string) string {
189✔
567
        knownSignals := []string{
189✔
568
                status.Completed,
189✔
569
                status.Failed,
189✔
570
                status.ReviewDone,
189✔
571
                status.CodexDone,
189✔
572
                status.PlanReady,
189✔
573
        }
189✔
574
        for _, sig := range knownSignals {
1,066✔
575
                if strings.Contains(text, sig) {
910✔
576
                        return sig
33✔
577
                }
33✔
578
        }
579
        return ""
156✔
580
}
581

582
// matchPattern checks output for configured patterns.
583
// Returns the first matching pattern or empty string if none match.
584
// Matching is case-insensitive substring search.
585
func matchPattern(output string, patterns []string) string {
217✔
586
        if len(patterns) == 0 {
352✔
587
                return ""
135✔
588
        }
135✔
589
        outputLower := strings.ToLower(output)
82✔
590
        for _, pattern := range patterns {
170✔
591
                trimmed := strings.TrimSpace(pattern)
88✔
592
                if trimmed == "" {
89✔
593
                        continue
1✔
594
                }
595
                if strings.Contains(outputLower, strings.ToLower(trimmed)) {
143✔
596
                        return trimmed
56✔
597
                }
56✔
598
        }
599
        return ""
26✔
600
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc