Skip to content
Merged
50 changes: 50 additions & 0 deletions docs/adr/45858-consolidate-duplicated-parsing-helpers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# ADR-45858: Consolidate Duplicated Parsing Helpers into Canonical Shared Functions

**Date**: 2026-07-16
**Status**: Draft
**Deciders**: Unknown

---

### Context

The `pkg/workflow` and `pkg/cli` packages contained multiple near-identical inline implementations of the same low-level parsing operations: positive-integer parsing from frontmatter values, integer-or-expression parsing for GitHub Actions template strings (`${{ ... }}`), workflow-ID normalization from file paths, and timeline row construction. Because each call site copied the logic independently, a bug fix or behavioral change (e.g., accepting an additional expression format) required updates in N places. The duplication also introduced subtle inconsistencies: some sites logged field-specific error messages, others logged generic ones, and some omitted logging entirely.

### Decision

We will extract each repeated parsing operation into a single, parameterized, unexported helper within the package that owns the concept:
- `parsePositiveIntValue(raw any, fieldName string) int` and `parseIntOrExpressionValue(raw any, minValue int, fieldName string) string` in `pkg/workflow`
- `isExpression(s string) bool` as a shared predicate replacing all `strings.HasPrefix(…, "${{") && strings.HasSuffix(…, "}}")` checks in `pkg/workflow`
- `normalizeWorkflowID(filename string) string` as the single call site for `.md` basename stripping in `pkg/cli`
- `renderMessageContentTimelineRow(kind TimelineEventKind, evt UnifiedTimelineEvent) []string` replacing duplicated timeline row construction in `pkg/cli`

All existing public-facing functions become thin wrappers that delegate to the shared helper.

### Alternatives Considered

#### Alternative 1: Maintain Inline Copies at Each Call Site (Status Quo)

Keep the parsing logic repeated at each of the five or more call sites. This requires no refactoring and avoids any indirection. We rejected it because a logic change (e.g., updating expression detection) requires touching every site individually, and omissions create behavioral inconsistencies that are hard to catch in code review.

#### Alternative 2: Introduce a Dedicated Utility Package (e.g., `pkg/parseutil`)

Move the helpers to a new package so they are importable across `pkg/workflow`, `pkg/cli`, and any future packages. This would allow cross-package reuse without creating import cycles. We rejected it for this change because all current call sites are within their own package, the helpers are implementation details that should not be part of the public API surface, and the extra package indirection adds maintenance overhead that is not yet justified.

### Consequences

#### Positive
- Parsing behavior is now consistent at all call sites; a fix to `isExpression` or `parseIntOrExpressionValue` propagates automatically to every consumer.
- Error log messages are uniformly parameterized with the field name, making diagnostics more actionable.
- New tests for the shared helpers provide regression coverage that did not previously exist for many of the duplicated call sites.

#### Negative
- Stack traces for parsing failures are now one frame deeper; developers reading a crash log must look through the wrapper to find the shared helper.
- The helpers are unexported, so they cannot be reused across package boundaries without further refactoring (e.g., if `pkg/cli` ever needs `isExpression`, it must either re-implement it or create an import dependency).

#### Neutral
- Existing public-facing parser functions (`parseMaxRunsValue`, `parseMaxTurnsValue`, etc.) retain their signatures — callers are unaffected.
- The `normalizeWorkflowID` and `renderMessageContentTimelineRow` helpers follow the same package-local pattern; their behavior is behavior-preserving and the change is transparent to external packages.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
9 changes: 7 additions & 2 deletions pkg/cli/experiments_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -460,10 +460,15 @@ func findRemoteWorkflowFilenameForExperiment(repoOverride, experimentName string
// matchWorkflowFilenameByExperiment returns the basename (without .md) of the first file in
// filenames whose sanitized name matches experimentName. Returns "" when no match is found.
// Logs a warning when more than one file maps to the same sanitized name.
//
// Note: normalizeWorkflowID calls filepath.Base internally, so any path prefix in filenames
// is stripped before matching. Callers that supply bare filenames (e.g. "my-flow.md") are
// unaffected; callers supplying full paths (e.g. ".github/workflows/my-flow.md") will have
// the directory component removed — only the basename is returned and compared.
func matchWorkflowFilenameByExperiment(filenames []string, experimentName string) string {
var matches []string
for _, filename := range filenames {
base := strings.TrimSuffix(filename, ".md")
base := normalizeWorkflowID(filename)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] matchWorkflowFilenameByExperiment receives plain filenames (no path prefix), but normalizeWorkflowID calls filepath.Base before stripping the extension. Calling filepath.Base on a plain filename like my-flow.md is harmless, but this is an implicit coupling — the function's behaviour depends on an internal detail of normalizeWorkflowID. A brief comment or the existing function-level doc should note this assumption.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a doc-comment paragraph to matchWorkflowFilenameByExperiment explicitly noting that normalizeWorkflowID calls filepath.Base internally, so any path prefix is stripped before matching and only the basename is returned. Commit: d1b5b8e.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

matchWorkflowFilenameByExperiment now applies filepath.Base to input filenames via normalizeWorkflowID; the old code did not: the change silently alters what gets appended to matches and returned to the caller.

💡 Analysis

Old code: strings.TrimSuffix(filename, ".md") — strips the extension but preserves any path prefix in the returned value.

New code: normalizeWorkflowID(filename) → calls filepath.Base first, so any path component is stripped before the result is stored in matches.

If the GitHub API returns bare filenames like my-workflow.md, filepath.Base is a no-op and behaviour is identical. But if the API ever returns paths (e.g. .github/workflows/my-workflow.md), the old code would append .github/workflows/my-workflow to matches while the new code appends just my-workflow. Since the function is documented as returning the basename, the new behaviour is arguably more correct — but the change is implicit and undocumented, and callers relying on the old path-preserving behaviour would silently break.

Recommend adding a test that verifies the function handles path-prefixed filenames as expected, or a comment confirming that callers always receive bare basenames.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a doc-comment to matchWorkflowFilenameByExperiment documenting the filepath.Base call inside normalizeWorkflowID, clarifying the behavior for bare filenames vs. path-prefixed ones and confirming callers receive only the basename. Commit: d1b5b8e.

if workflow.SanitizeWorkflowIDForCacheKey(base) == experimentName {
matches = append(matches, base)
}
Expand All @@ -486,7 +491,7 @@ func findWorkflowFileForExperiment(experimentName string) string {
return ""
}
for _, f := range mdFiles {
base := strings.TrimSuffix(filepath.Base(f), ".md")
base := normalizeWorkflowID(f)
if workflow.SanitizeWorkflowIDForCacheKey(base) == experimentName {
return f
}
Expand Down
14 changes: 7 additions & 7 deletions pkg/cli/gateway_logs_timeline_render.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,11 +323,15 @@ func renderAgentToolDoneRow(evt UnifiedTimelineEvent) []string {
//
// Detail shows a truncated preview of the message content. Status is left empty.
func renderAgentAssistantMessageRow(evt UnifiedTimelineEvent) []string {
return renderMessageContentTimelineRow(TimelineKindAssistantMessage, evt)
}

func renderMessageContentTimelineRow(kind TimelineEventKind, evt UnifiedTimelineEvent) []string {
ts := formatTimelineTime(evt)
src := timelineSourceLabel(evt.Source)
kind := timelineEventIcon(TimelineKindAssistantMessage) + " " + timelineEventKindLabel(TimelineKindAssistantMessage)
kindLabel := timelineEventIcon(kind) + " " + timelineEventKindLabel(kind)
detail := stringutil.Truncate(evt.MessageContent, 48)
return []string{ts, src, kind, detail, ""}
return []string{ts, src, kindLabel, detail, ""}
}

// renderAgentReasoningRow renders a TimelineKindReasoning event as a table row.
Expand All @@ -336,11 +340,7 @@ func renderAgentAssistantMessageRow(evt UnifiedTimelineEvent) []string {
//
// Detail shows a truncated preview of the reasoning content. Status is left empty.
func renderAgentReasoningRow(evt UnifiedTimelineEvent) []string {
ts := formatTimelineTime(evt)
src := timelineSourceLabel(evt.Source)
kind := timelineEventIcon(TimelineKindReasoning) + " " + timelineEventKindLabel(TimelineKindReasoning)
detail := stringutil.Truncate(evt.MessageContent, 48)
return []string{ts, src, kind, detail, ""}
return renderMessageContentTimelineRow(TimelineKindReasoning, evt)
}

// renderSteeringRow renders a TimelineKindSteering event as a table row.
Expand Down
32 changes: 32 additions & 0 deletions pkg/cli/gateway_logs_timeline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,38 @@ func TestRenderAgentToolDoneRow_StatusFromSuccessFlag(t *testing.T) {
}
}

func TestRenderAgentAssistantMessageRow(t *testing.T) {
evt := UnifiedTimelineEvent{
Time: time.Date(2024, 1, 15, 10, 0, 3, 0, time.UTC),
Source: TimelineSourceAgent,
Kind: TimelineKindAssistantMessage,
MessageContent: "assistant response",
}
row := renderAgentAssistantMessageRow(evt)
if !strings.Contains(row[2], string(TimelineKindAssistantMessage)) {
t.Errorf("Kind = %q; want assistant_message label", row[2])
}
if row[3] != "assistant response" {
t.Errorf("Detail = %q; want assistant response", row[3])
}
}

func TestRenderAgentReasoningRow(t *testing.T) {
evt := UnifiedTimelineEvent{
Time: time.Date(2024, 1, 15, 10, 0, 4, 0, time.UTC),
Source: TimelineSourceAgent,
Kind: TimelineKindReasoning,
MessageContent: "reasoning summary",
}
row := renderAgentReasoningRow(evt)
if !strings.Contains(row[2], string(TimelineKindReasoning)) {
t.Errorf("Kind = %q; want reasoning label", row[2])
}
if row[3] != "reasoning summary" {
t.Errorf("Detail = %q; want reasoning summary", row[3])
}
}

// ─── timelineEventIcon / timelineEventKindLabel / timelineSourceLabel ─────────

func TestTimelineEventIcon_AllKinds(t *testing.T) {
Expand Down
4 changes: 2 additions & 2 deletions pkg/cli/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ func parseWorkflowSpec(spec string) (*WorkflowSpec, error) {
Version: version,
},
WorkflowPath: workflowPath,
WorkflowName: strings.TrimSuffix(filepath.Base(workflowPath), ".md"),
WorkflowName: normalizeWorkflowID(workflowPath),
Host: explicitHost,
}, nil
}
Expand All @@ -439,7 +439,7 @@ func parseLocalWorkflowSpec(spec string) (*WorkflowSpec, error) {
Version: "", // Local workflows have no version
},
WorkflowPath: spec, // Keep the "./" prefix in WorkflowPath
WorkflowName: strings.TrimSuffix(filepath.Base(spec), ".md"),
WorkflowName: normalizeWorkflowID(spec),
}, nil
}

Expand Down
6 changes: 3 additions & 3 deletions pkg/cli/workflows.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ func getWorkflowStatus(workflowIdOrName string, repoOverride string, verbose boo
workflowsLog.Printf("Getting workflow status: workflow=%s", workflowIdOrName)

// Extract workflow name for lookup
filename := strings.TrimSuffix(filepath.Base(workflowIdOrName), ".md")
filename := normalizeWorkflowID(workflowIdOrName)

// Get all GitHub workflows
githubWorkflows, err := fetchGitHubWorkflows(repoOverride, verbose)
Expand Down Expand Up @@ -243,7 +243,7 @@ func getAvailableWorkflowNames() []string {
}

return sliceutil.Map(mdFiles, func(file string) string {
return strings.TrimSuffix(filepath.Base(file), ".md")
return normalizeWorkflowID(file)
})
}

Expand All @@ -256,7 +256,7 @@ func suggestWorkflowNames(target string) []string {
}

// Normalize target: strip .md extension and get basename if it's a path
normalizedTarget := strings.TrimSuffix(filepath.Base(target), ".md")
normalizedTarget := normalizeWorkflowID(target)

workflowsLog.Printf("Suggesting workflow names for %q (available: %d)", normalizedTarget, len(availableNames))
// Use the existing FindClosestMatches function from parser package
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ func (c *Compiler) ExtractEngineConfig(frontmatter map[string]any) (string, *Eng
config.HarnessScript = use
}
if v, ok := h["max-retries"]; ok {
config.HarnessMaxRetries = parseNonNegativeIntOrExpressionValue(v)
config.HarnessMaxRetries = parseHarnessMaxRetriesValue(v)
}
if v, ok := h["initial-delay-ms"]; ok {
config.HarnessInitialDelayMs = parseMaxTurnsValue(v)
Expand Down
98 changes: 34 additions & 64 deletions pkg/workflow/engine_config_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,98 +27,68 @@ func parseMaxAICreditsValue(raw any) int64 {
// parseMaxRunsValue parses max-runs from either integer or numeric-string
// frontmatter values.
func parseMaxRunsValue(raw any) int {
if val, ok := typeutil.ParseIntValue(raw); ok && val > 0 {
return val
}
if rawStr, ok := raw.(string); ok {
if parsed, err := strconv.Atoi(rawStr); err == nil && parsed > 0 {
return parsed
}
engineLog.Printf("Ignoring invalid max-runs value: %q", rawStr)
}
return 0
return parsePositiveIntValue(raw, "max-runs")
}

// parseMaxTurnCacheMissesValue parses max-turn-cache-misses from either integer or
// numeric-string frontmatter values.
func parseMaxTurnCacheMissesValue(raw any) int {
if val, ok := typeutil.ParseIntValue(raw); ok && val > 0 {
return val
}
if rawStr, ok := raw.(string); ok {
if parsed, err := strconv.Atoi(rawStr); err == nil && parsed > 0 {
return parsed
}
engineLog.Printf("Ignoring invalid max-turn-cache-misses value: %q", rawStr)
}
return 0
return parsePositiveIntValue(raw, "max-turn-cache-misses")
}

// parsePositiveIntValue parses a strictly-positive integer from raw.
// Delegates to parseIntOrExpressionValue for a single int-validation path.
// GitHub Actions expression strings (e.g. "${{ inputs.value }}") are silently
// treated as 0 (not configured) because these fields are integer-only.
func parsePositiveIntValue(raw any, fieldName string) int {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] parsePositiveIntValue and parseIntOrExpressionValue have overlapping logic (both parse int-or-string), but the former returns int and the latter returns string. This creates two parallel parsing paths that can diverge. Consider whether parsePositiveIntValue could internally call parseIntOrExpressionValue (ignoring the expression branch) to reduce the chance of subtle future drift.

💡 Example
func parsePositiveIntValue(raw any, fieldName string) int {
    s := parseIntOrExpressionValue(raw, 1, fieldName)
    if s == "" {
        return 0
    }
    val, _ := strconv.Atoi(s)
    return val
}

This keeps a single source of truth for the int-validation logic.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored parsePositiveIntValue to delegate to parseIntOrExpressionValue, making it the single source of truth for int-validation logic. GitHub Actions expression strings are documented to silently produce 0 (not configured) since these fields are integer-only. Commit: d1b5b8e.

s := parseIntOrExpressionValue(raw, 1, fieldName)
if s == "" || isExpression(s) {
return 0
}
val, err := strconv.Atoi(s)
if err != nil {
return 0
}
return val
}

func parseMaxTurnsValue(raw any) string {
if val, ok := typeutil.ParseIntValue(raw); ok && val > 0 {
return strconv.Itoa(val)
}
if rawStr, ok := raw.(string); ok {
trimmed := strings.TrimSpace(rawStr)
if trimmed == "" {
return ""
}
if parsed, err := strconv.Atoi(trimmed); err == nil && parsed > 0 {
return strconv.Itoa(parsed)
}
// Match the same GitHub Actions expression wrapper accepted by the schema.
// The schema and GitHub Actions runtime are responsible for validating the
// expression body itself; this helper only needs to preserve templated values.
if strings.HasPrefix(trimmed, "${{") && strings.HasSuffix(trimmed, "}}") {
return trimmed
}
engineLog.Printf("Ignoring invalid max-turns value: %q", rawStr)
}
return ""
return parseIntOrExpressionValue(raw, 1, "max-turns")
}

// parseNonNegativeIntOrExpressionValue parses a raw frontmatter value that must be a
// non-negative integer (≥ 0) or a GitHub Actions expression template (${{ ... }}).
// parseHarnessMaxRetriesValue parses harness.max-retries from a raw frontmatter value
// that must be a non-negative integer (≥ 0) or a GitHub Actions expression template (${{ ... }}).
// It is intentionally distinct from parseMaxTurnsValue which rejects zero.
// Returns the canonical string representation, or "" when the value is absent/invalid.
func parseNonNegativeIntOrExpressionValue(raw any) string {
if val, ok := typeutil.ParseIntValue(raw); ok && val >= 0 {
func parseHarnessMaxRetriesValue(raw any) string {
return parseIntOrExpressionValue(raw, 0, "harness.max-retries")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] parseNonNegativeIntOrExpressionValue hardcodes "harness.max-retries" as the field name inside the generic parseIntOrExpressionValue helper — any future reuse of this wrapper for a different field would emit misleading log messages.

💡 Suggested fix

Either pass the field name through as a parameter, or rename this wrapper to parseHarnessMaxRetriesValue to make it clear it is single-purpose:

// Option A: rename to be explicit
func parseHarnessMaxRetriesValue(raw any) string {
    return parseIntOrExpressionValue(raw, 0, "harness.max-retries")
}

// Option B: accept a field name param and update callers
func parseNonNegativeIntOrExpressionValue(raw any, fieldName string) string {
    return parseIntOrExpressionValue(raw, 0, fieldName)
}

The generic name with a hard-coded label is the worst of both worlds — it looks reusable but logs the wrong field name if reused.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to parseHarnessMaxRetriesValue (Option A) — the function is only called in one place so naming it after the field makes the coupling explicit and prevents misleading log output on reuse. Commit: d1b5b8e.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseNonNegativeIntOrExpressionValue hardcodes an unrelated field name in the generic helper: calling parseIntOrExpressionValue(raw, 0, "harness.max-retries") bakes a specific caller's field name into the reusable function.

💡 Why this matters / suggested fix

The fieldName argument is used only for logging. Currently accurate because this function is only called from the harness max-retries code path, but if parseNonNegativeIntOrExpressionValue is ever reused for a second field, every invalid-value log line will incorrectly say harness.max-retries — silent, confusing, and hard to catch in production.

Consider accepting the field name as a parameter:

func parseNonNegativeIntOrExpressionValue(raw any, fieldName string) string {
    return parseIntOrExpressionValue(raw, 0, fieldName)
}

or, since the function is only called in one place, calling the generic helper directly at that call site to keep the label accurate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to parseHarnessMaxRetriesValue — the name now accurately reflects its single purpose and the fieldName label in parseIntOrExpressionValue will always be correct. Commit: d1b5b8e.

}

func parseIntOrExpressionValue(raw any, minValue int, fieldName string) string {
if val, ok := typeutil.ParseIntValue(raw); ok && val >= minValue {
return strconv.Itoa(val)
}
if rawStr, ok := raw.(string); ok {
trimmed := strings.TrimSpace(rawStr)
if trimmed == "" {
return ""
}
if parsed, err := strconv.Atoi(trimmed); err == nil && parsed >= 0 {
if parsed, err := strconv.Atoi(trimmed); err == nil && parsed >= minValue {
return strconv.Itoa(parsed)
}
if strings.HasPrefix(trimmed, "${{") && strings.HasSuffix(trimmed, "}}") {
// Match the same GitHub Actions expression wrapper accepted by the schema.
// The schema and GitHub Actions runtime are responsible for validating the
// expression body itself; this helper only needs to preserve templated values.
if isExpression(trimmed) {
return trimmed
}
engineLog.Printf("Ignoring invalid harness.max-retries value: %q", rawStr)
engineLog.Printf("Ignoring invalid %s value: %q", fieldName, rawStr)
}
return ""
}

func parseMaxToolDenialsValue(raw any) string {
if val, ok := typeutil.ParseIntValue(raw); ok && val > 0 {
return strconv.Itoa(val)
}
if rawStr, ok := raw.(string); ok {
trimmed := strings.TrimSpace(rawStr)
if trimmed == "" {
return ""
}
if parsed, err := strconv.Atoi(trimmed); err == nil && parsed > 0 {
return strconv.Itoa(parsed)
}
if strings.HasPrefix(trimmed, "${{") && strings.HasSuffix(trimmed, "}}") {
return trimmed
}
engineLog.Printf("Ignoring invalid max-tool-denials value: %q", rawStr)
}
return ""
return parseIntOrExpressionValue(raw, 1, "max-tool-denials")
}

// parseAuthDefinition converts a raw auth config map (from engine.provider.auth) into
Expand Down
64 changes: 64 additions & 0 deletions pkg/workflow/engine_config_parser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//go:build !integration

package workflow

import "testing"

func TestParsePositiveIntValues(t *testing.T) {
t.Parallel()

tests := []struct {
name string
parse func(any) int
raw any
expected int
}{
{name: "max-runs int", parse: parseMaxRunsValue, raw: 3, expected: 3},
{name: "max-runs string", parse: parseMaxRunsValue, raw: "5", expected: 5},
{name: "max-runs invalid", parse: parseMaxRunsValue, raw: "oops", expected: 0},
{name: "max-runs zero", parse: parseMaxRunsValue, raw: 0, expected: 0},
{name: "max-runs negative", parse: parseMaxRunsValue, raw: -1, expected: 0},
{name: "max-runs nil", parse: parseMaxRunsValue, raw: nil, expected: 0},
{name: "max-turn-cache-misses int", parse: parseMaxTurnCacheMissesValue, raw: 2, expected: 2},
{name: "max-turn-cache-misses string", parse: parseMaxTurnCacheMissesValue, raw: "7", expected: 7},
{name: "max-turn-cache-misses invalid", parse: parseMaxTurnCacheMissesValue, raw: "bad", expected: 0},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The test for max-runs invalid only covers a non-numeric string. Consider adding boundary cases: 0 (should return 0 since positive means > 0), negative integers, and nil. These edge cases are the most common sources of regressions when a shared helper like parsePositiveIntValue is changed.

💡 Suggested additions
{name: "max-runs zero", parse: parseMaxRunsValue, raw: 0, expected: 0},
{name: "max-runs negative", parse: parseMaxRunsValue, raw: -1, expected: 0},
{name: "max-runs nil", parse: parseMaxRunsValue, raw: nil, expected: 0},

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added max-runs zero, max-runs negative, and max-runs nil test cases to TestParsePositiveIntValues. All three return 0 as expected. Commit: d1b5b8e.

}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.parse(tt.raw); got != tt.expected {
t.Fatalf("got %d, want %d", got, tt.expected)
}
})
}
}

func TestParseIntOrExpressionValues(t *testing.T) {
t.Parallel()

tests := []struct {
name string
parse func(any) string
raw any
expected string
}{
{name: "max-turns rejects zero", parse: parseMaxTurnsValue, raw: 0, expected: ""},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] TestParseIntOrExpressionValues does not test the truncation/trimming behaviour for the string path (e.g. " 5 " with surrounding whitespace should parse to "5"). The previous inline implementation had an explicit strings.TrimSpace call — a test documenting this would guard against accidental removal.

💡 Suggested addition
{name: "max-turns trims whitespace", parse: parseMaxTurnsValue, raw: " 3 ", expected: "3"},

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added max-turns trims whitespace (raw: " 3 ", expected: "3") and a companion non-negative trims whitespace case for parseHarnessMaxRetriesValue. Both pass. Commit: d1b5b8e / fa53a73.

{name: "max-turns accepts expression", parse: parseMaxTurnsValue, raw: " ${{ inputs.max_turns }} ", expected: "${{ inputs.max_turns }}"},
{name: "max-turns trims whitespace", parse: parseMaxTurnsValue, raw: " 3 ", expected: "3"},
{name: "non-negative accepts zero", parse: parseHarnessMaxRetriesValue, raw: 0, expected: "0"},
{name: "non-negative accepts expression", parse: parseHarnessMaxRetriesValue, raw: "${{ inputs.max_retries }}", expected: "${{ inputs.max_retries }}"},
{name: "non-negative trims whitespace", parse: parseHarnessMaxRetriesValue, raw: " 2 ", expected: "2"},
{name: "max-tool-denials rejects zero", parse: parseMaxToolDenialsValue, raw: "0", expected: ""},
{name: "max-tool-denials accepts expression", parse: parseMaxToolDenialsValue, raw: "${{ inputs.max_tool_denials }}", expected: "${{ inputs.max_tool_denials }}"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.parse(tt.raw); got != tt.expected {
t.Fatalf("got %q, want %q", got, tt.expected)
}
})
}
}
Loading
Loading