Skip to content

feat: use acp instead of stream raw json - #34

Merged
pedronauck merged 7 commits into
mainfrom
pn/acp
Apr 3, 2026
Merged

pedronauck merged 7 commits into
mainfrom
pn/acp

Conversation

@pedronauck

@pedronauck pedronauck commented Apr 3, 2026 •

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Added support for Gemini IDE with gemini-2.5-pro as default model
    • Integrated ACP runtime agent execution with streaming session updates and lifecycle management
  • UI/UX Improvements

    • Updated terminal interface layout with timeline-based transcript view
    • Refreshed CLI flag help text for runtime selection and configuration options
  • Chores

    • Optimized skills bundling to use embedded filesystem for distribution

@pedronauck pedronauck self-assigned this Apr 3, 2026
@coderabbitai

coderabbitai Bot commented Apr 3, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Review was skipped due to path filters

⛔ Files ignored due to path filters (2)
  • README.md is excluded by !**/*.md
  • imgs/how-it-works-flow.png is excluded by !**/*.png

CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including **/dist/** will override the default block on the dist directory, by removing the pattern from both the lists.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d85510a6-8058-4002-a3f7-1c30a477317d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

This change refactors the IDE agent execution model from subprocess-based commands to an ACP (Agent-based Command Protocol) client/session architecture, adds support for Gemini IDE, redesigns the UI with a timeline transcript pane, and migrates bundled skills to an embedded filesystem.

Changes

Cohort / File(s) Summary
CLI Flags & Gemini Support
internal/cli/form.go, internal/cli/root.go, internal/core/api.go, internal/core/model/model.go
Added Gemini IDE option with model default gemini-2.5-pro. Updated flag help text for --ide, --model, --add-dir, and --reasoning-effort to reflect ACP runtime language and new supported IDEs.
ACP Agent Client Implementation
internal/core/agent/client.go, internal/core/agent/client_test.go, internal/core/agent/session.go, internal/core/agent/session_helpers_test.go
Introduced ACP-backed agent client for subprocess lifecycle management, session creation, and model resolution. Implements streaming session updates with tool call/content block handling, graceful/forced shutdown, and comprehensive integration tests.
ACP Runtime Registry & Discovery
internal/core/agent/registry.go, internal/core/agent/registry_test.go, internal/core/agent/ide.go, internal/core/agent/ide_test.go
Replaced IDE-specific subprocess command builder with a unified ACP runtime specification registry supporting multiple launchers per IDE, bootstrap argument generation, fallback resolution, and availability verification. Removed legacy 452-line IDE dispatch file and its 194-line test suite.
Process Management
internal/core/agent/process_unix.go, internal/core/agent/process_windows.go
Added platform-specific subprocess process group and termination helpers; Unix uses SIGKILL to process groups, Windows falls back to Process.Kill().
Content Blocks & Session Model
internal/core/model/content.go, internal/core/model/content_test.go
Introduced typed content block system (text, tool use/result, diff, terminal output, image) with JSON round-tripping, status/update kind enums, and usage tracking with token aggregation.
Execution Infrastructure Refactor
internal/core/run/command_io.go, internal/core/run/execution.go, internal/core/run/logging.go, internal/core/run/runtime_logger.go
Replaced process/exec-based command setup with ACP client/session workflow. Refactored executor shutdown into drain/force phases with active client tracking. Updated logging to stream session updates with content block rendering instead of process output parsing.
Session Tracking & Transcripts
internal/core/run/session_view_model.go, internal/core/run/acp_transcript_test.go
Added session transcript view model accumulating updates into snapshots with message merging, tool call state tracking, plan execution counters, and timeline entry management.
UI Layout & Panes
internal/core/run/types.go, internal/core/run/ui_layout.go, internal/core/run/ui_model.go, internal/core/run/ui_styles.go
Refactored UI from single viewport to dual panes (jobs sidebar + timeline transcript). Added shutdown phase modeling, updated message types for ACP updates, simplified layout computation with clamping, replaced fixed sidebar with ratio-based sizing.
UI Interaction & Rendering
internal/core/run/ui_update.go, internal/core/run/ui_view.go, internal/core/run/ui_update_test.go, internal/core/run/ui_view_test.go
Implemented pane-focused navigation (Tab/Shift+Tab cycling), entry expansion, shutdown request escalation (drain→force), timeline viewport management with cached line offsets, and transcript detail rendering from content blocks. Updated rendering from log headers to session timeline with ACP cockpit title.
UI Model & Testing
internal/core/run/ui_model.go, internal/core/run/ui_model_test.go, internal/core/run/execution_ui_test.go
Updated UI controller to handle uiQuitRequest parameters, disable signal handling for Bubble Tea, and added comprehensive test coverage for quit semantics, shutdown transitions, and UI lifecycle.
Execution Tests
internal/core/run/execution_acp_test.go, internal/core/run/execution_acp_integration_test.go, internal/core/model/model_test.go, internal/core/run/logging_test.go
Added extensive ACP execution tests covering prompt composition, dry-run, retry behavior, cancellation, timeouts, and interactive mode. Updated model tests for new type shapes (e.g., model.Usage). Rewrote logging tests for session update streaming and content block rendering.
Bundled Skills Migration
skills/embed.go, internal/setup/bundle.go, internal/setup/cmd/generatebundle/main.go, test/skills_bundle_test.go, .gitignore
Moved skills bundling from runtime mirror generation to compile-time embedded filesystem. Removed 80-line generator, replaced mirror test with embedded filesystem comparison, added ignore patterns for autoresearch artifacts.
Diagram Documentation
imgs/how-it-works.drawio
Added workflow diagram visualizing complete job pipeline from PRD creation through task execution, syncing, normalization, and archival with artifact markdown outputs at each stage.
Dependency
go.mod
Added indirect dependency github.com/coder/acp-go-sdk v0.6.3 for ACP protocol support.

Sequence Diagram

sequenceDiagram
    participant User
    participant Executor
    participant ACPClient
    participant ACPRuntime
    participant OutputHandler
    
    User->>Executor: Execute Job
    Executor->>ACPClient: NewClient(cfg)
    ACPClient->>ACPRuntime: Boot subprocess with IDE
    ACPRuntime-->>ACPClient: Ready
    
    Executor->>ACPClient: CreateSession(prompt)
    ACPClient->>ACPRuntime: New session
    ACPRuntime-->>ACPClient: session ID
    ACPClient->>ACPRuntime: Send prompt
    
    ACPRuntime->>ACPRuntime: Process command
    ACPRuntime-->>ACPClient: Stream updates
    ACPClient->>OutputHandler: Publish update (blocks)
    OutputHandler->>OutputHandler: Render content blocks
    OutputHandler-->>User: Display in timeline
    
    ACPRuntime-->>ACPClient: Session complete
    ACPClient->>OutputHandler: HandleCompletion(status)
    OutputHandler-->>Executor: Report result
    Executor->>ACPClient: Close()
    ACPClient->>ACPRuntime: Shutdown
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main architectural change: replacing direct JSON streaming with ACP (Agent Control Protocol) for agent execution.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pn/acp

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 19

🧹 Nitpick comments (22)
internal/setup/bundle.go (1)

27-29: Consider dropping the unused error return from bundledSkillsRoot.

The function only has one call site (in bundle_test.go) and never actually returns an error, so the (fs.FS, error) signature is unnecessary. Simplifying to (fs.FS) would eliminate redundant error handling at the call site.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/setup/bundle.go` around lines 27 - 29, The function
bundledSkillsRoot currently returns (fs.FS, error) but never returns an error;
change its signature to return only fs.FS (func bundledSkillsRoot() fs.FS) and
update the body to return skills.FS directly; then update all call sites
(notably bundle_test.go) to stop expecting an error return and remove the
redundant error handling/unwrapping code. Ensure any references to the old
two-value return are adjusted to a single-value use.
internal/core/run/execution_ui_test.go (1)

60-117: Please fold these shutdown checks into t.Run("Should...") cases.

The three added tests all build the same fake controller/UI harness and only vary the trigger plus expected shutdown effects. A table-driven subtest suite will match the repo test pattern and remove most of the duplicated setup here.

As per coding guidelines, "Use table-driven tests with subtests (t.Run) as the default pattern" and "MUST use t.Run("Should...") pattern for ALL test cases."

Also applies to: 119-183, 185-240

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/run/execution_ui_test.go` around lines 60 - 117, The test
TestExecutorControllerUIQuitEntersDrainingPath duplicates setup across multiple
similar cases; refactor it into a table-driven subtest using t.Run("Should...")
where each entry varies the trigger (calls to executorController.requestShutdown
with uiQuitRequestDrain or others) and the expected effects (cancelCalls,
ui.shutdownCalls, observed shutdownPhase via ui.eventsCh); keep the shared
harness creation (fakeLifecycleUISession, jobExecutionContext,
executorController, done channel, resultCh and goroutine invoking
controller.awaitCompletion) and move per-case actions/assertions into each
subtest, using identifiers like TestExecutorControllerUIQuitEntersDrainingPath,
executorController.requestShutdown, uiQuitRequestDrain, fakeLifecycleUISession,
awaitCompletion, shutdownStatusMsg and shutdownPhaseDraining to locate the code
to change. Ensure each subtest asserts its specific expectations and preserves
timeouts used on ui.eventsCh and resultCh.
internal/core/run/ui_model_test.go (2)

76-97: Table-driven test loop should use t.Run subtests.

The loop iterates over job states but doesn't wrap each case in t.Run. This prevents individual test case identification in output and skips t.Parallel() benefits for subtests.

♻️ Suggested refactor
 	for _, tc := range []struct {
 		state jobState
 		label string
 	}{
 		{jobPending, "PENDING"},
 		{jobRunning, "RUNNING"},
 		{jobSuccess, "SUCCESS"},
 		{jobFailed, "FAILED"},
 	} {
+		t.Run(fmt.Sprintf("Should return correct helpers for %s state", tc.label), func(t *testing.T) {
+			t.Parallel()
 			if got := m.getStateLabel(tc.state); got != tc.label {
 				t.Fatalf("unexpected state label for %v: %q", tc.state, got)
 			}
 			if got := m.jobStateIcon(tc.state); got == "" {
 				t.Fatalf("expected icon for state %v", tc.state)
 			}
 			if m.jobStateColor(tc.state) == nil {
 				t.Fatalf("expected color for state %v", tc.state)
 			}
 			if m.jobBorderColor(&uiJob{state: tc.state}) == nil {
 				t.Fatalf("expected border color for state %v", tc.state)
 			}
+		})
 	}

As per coding guidelines: "MUST use t.Run("Should...") pattern for ALL test cases".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/run/ui_model_test.go` around lines 76 - 97, The table-driven
loop in ui_model_test.go should be converted to t.Run subtests so each case is
identifiable and can run in parallel; for each test case (enumerated states
jobPending, jobRunning, jobSuccess, jobFailed) wrap the assertions in t.Run with
a descriptive name (e.g. the label) and call t.Parallel() at the start of the
subtest, then perform the same checks against m.getStateLabel, m.jobStateIcon,
m.jobStateColor, and m.jobBorderColor(&uiJob{state: tc.state}); ensure failures
use t.Fatalf (or t.Errorf) inside the subtest so failures are reported per-case.

61-114: Consider splitting this test into focused subtests.

This test covers multiple unrelated concerns: number formatting, duration formatting, state labels, icons, colors, elapsed strings, and view rendering. Splitting into separate t.Run blocks would improve readability and make failures easier to diagnose.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/run/ui_model_test.go` around lines 61 - 114, The test
TestFormattingAndStateHelpersCoverBranches is exercising many concerns; split it
into focused subtests using t.Run for each area: one subtest for formatNumber
and formatDuration (call formatNumber and formatDuration and assert results),
one for state helpers (create newUIModel(1) and separately assert getStateLabel,
jobStateIcon, jobStateColor, jobBorderColor for each jobState), one for elapsed
rendering (use uiJob instances and assert elapsedStr renders), and one for view
rendering (set m.jobs, m.total, m.currentView and assert m.View().Content). Keep
the same assertions and helper values but move them into these named t.Run
blocks so failures point to the specific function under test (referencing
functions: formatNumber, formatDuration, newUIModel, getStateLabel,
jobStateIcon, jobStateColor, jobBorderColor, elapsedStr, View).
internal/core/agent/session.go (1)

80-95: Unlock before channel operations to avoid holding lock during potential blocking.

The mutex is held while performing channel operations (send and close). If the channel send in the select were to block (it won't here due to default), or if close panics on an already-closed channel, the lock would be held. Consider unlocking before channel operations.

♻️ Suggested refactor
 func (s *sessionImpl) finish(status model.SessionStatus, err error) {
 	s.mu.Lock()
 	if s.finished {
 		s.mu.Unlock()
 		return
 	}
 	s.finished = true
 	s.err = err
+	s.mu.Unlock()
+
 	select {
 	case s.updates <- model.SessionUpdate{Status: status}:
 	default:
 	}
 	close(s.updates)
 	close(s.done)
-	s.mu.Unlock()
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/agent/session.go` around lines 80 - 95, In sessionImpl.finish,
avoid holding s.mu while doing channel operations: perform the mutex-protected
checks and state updates (check s.finished, set s.finished = true, assign s.err)
while locked, then unlock before doing the non-atomic actions (sending to
s.updates and closing s.updates and s.done). Concretely, move the select and the
close(s.updates)/close(s.done) calls to after s.mu.Unlock() so the lock is not
held during potential blocking or panic-prone channel operations.
internal/core/run/acp_transcript_test.go (1)

200-208: Consider consolidating duplicate test helpers.

This helper is nearly identical to mustContentBlockLoggingTest in logging_test.go. Consider extracting a shared test helper to reduce duplication.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/run/acp_transcript_test.go` around lines 200 - 208, The two
test helpers mustContentBlockTranscriptTest (in acp_transcript_test.go) and
mustContentBlockLoggingTest (in logging_test.go) are duplicate; extract a single
shared helper (e.g., mustContentBlock or mustContentBlockTest) into a common
test helper file in the same package (e.g., test_helpers_test.go) that calls
model.NewContentBlock, handles the error with t.Fatalf, and returns
model.ContentBlock, then update call sites to use the new helper and remove the
duplicated functions.
internal/core/run/ui_update.go (1)

329-344: Magic number 3 should be a named constant.

The value 3 appears to represent the height of a sidebar item. Extract this to a named constant for clarity and maintainability.

♻️ Suggested improvement
+const sidebarItemHeight = 3
+
 func (m *uiModel) refreshSidebarContent() {
 	var items []string
 	for i := range m.jobs {
 		items = append(items, m.renderSidebarItem(&m.jobs[i], i == m.selectedJob))
 	}
 	m.sidebarViewport.SetContent(strings.Join(items, "\n"))

-	lineOffset := m.selectedJob * 3
+	lineOffset := m.selectedJob * sidebarItemHeight
 	sidebarOffset := m.sidebarViewport.YOffset()
 	sidebarHeight := m.sidebarViewport.Height()
-	if lineOffset > sidebarOffset+sidebarHeight-3 {
-		m.sidebarViewport.SetYOffset(lineOffset - sidebarHeight + 3)
+	if lineOffset > sidebarOffset+sidebarHeight-sidebarItemHeight {
+		m.sidebarViewport.SetYOffset(lineOffset - sidebarHeight + sidebarItemHeight)
 	} else if lineOffset < sidebarOffset {
 		m.sidebarViewport.SetYOffset(lineOffset)
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/run/ui_update.go` around lines 329 - 344, Replace the magic
number 3 in refreshSidebarContent with a named constant representing the sidebar
item height: declare a package-level constant (e.g. sidebarItemHeight) and use
it instead of the literal in the calculations for lineOffset and the YOffset
adjustments inside uiModel.refreshSidebarContent; update both occurrences
(lineOffset calculation and SetYOffset adjustments) so the intent is clear and
maintainable while leaving renderSidebarItem, m.selectedJob, m.sidebarViewport
and related logic unchanged.
internal/core/agent/registry_test.go (1)

87-105: Remove unnecessary loop variable capture pattern—Go 1.26.1 supports improved loop semantics.

The tc := tc pattern at lines 88 and 257 is no longer required. Go 1.22+ fixed loop variable closure issues, making this redundant for this project.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/agent/registry_test.go` around lines 87 - 105, The test
contains an unnecessary inner loop-variable capture "tc := tc" before t.Run;
remove that redundant reassignment so the subtest uses the loop variable
directly. Update the table-driven loop where tc is used with lookupAgentSpec,
spec.launchCommand(resolveModel(...)), and spec.probeCommand() by deleting the
"tc := tc" line(s) to rely on Go's corrected loop variable semantics.
internal/core/run/ui_view.go (1)

193-206: Cache metric labels could be more descriptive.

The labels "CACHER" and "CACHEW" are abbreviated. Consider using "CACHE_R" / "CACHE_W" or full words for clarity in the UI.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/run/ui_view.go` around lines 193 - 206, Update the cache metric
labels to be more descriptive: replace the abbreviated strings "CACHER " and
"CACHEW " passed into renderStyledOnBackground with clearer labels such as
"CACHE_R " and "CACHE_W " or full words like "CACHE READ " and "CACHE WRITE "
where the calls occur inside the two renderOwnedLine invocations that render
u.CacheReads and u.CacheWrites (these use innerW, bg, renderStyledOnBackground,
renderGap, value, label, and formatNumber). Ensure spacing/formatting remains
consistent with the surrounding UI text.
internal/core/agent/client_test.go (3)

401-444: Test modifies global PATH environment - ensure isolation.

This test uses t.Setenv to modify PATH which is properly scoped to the test. However, the test doesn't call t.Parallel() which is correct given the environment modification. Consider adding a comment explaining why parallelism is disabled.

📝 Add clarifying comment
 func TestClientCreateSessionSurfacesStartupCommandAndStderr(t *testing.T) {
+	// Not parallel: modifies PATH environment variable
 	tmpDir := t.TempDir()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/agent/client_test.go` around lines 401 - 444, The test
TestClientCreateSessionSurfacesStartupCommandAndStderr intentionally calls
t.Setenv to modify PATH and therefore must not run in parallel; add a short
clarifying comment above the test (or directly above the t.Setenv call) stating
that t.Parallel is omitted because the test mutates global environment (PATH)
and relies on isolation, so running in parallel could interfere with other
tests; reference the test name and the t.Setenv usage in the comment so future
maintainers understand why parallelism is disabled.

184-192: Time-based waits may cause flaky tests in slow CI environments.

The 2-second timeouts at lines 186 and 248 could cause intermittent failures on slow CI runners. Consider using a longer timeout or making it configurable via environment variable for CI.

♻️ Consider longer timeout
+const testSessionTimeout = 5 * time.Second
+
 	select {
 	case <-session.Done():
-	case <-time.After(2 * time.Second):
+	case <-time.After(testSessionTimeout):
 		t.Fatal("timed out waiting for session cancellation")
 	}

Also applies to: 246-254

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/agent/client_test.go` around lines 184 - 192, The test uses a
hardcoded 2-second time.After window when waiting on session.Done() which can
flake on slow CI; replace the literal timeout with a configurable duration (e.g.
read from an env var like TEST_TIMEOUT or use a package-level default constant)
and apply it to both uses of time.After(...) in this test so the waits are
longer or adjustable in CI; keep the logic that checks <-session.Done() and
errors.Is(session.Err(), context.Canceled) unchanged, only swap the
2*time.Second literal for the configurable duration used by the test.

675-678: Use direct field access instead of reflection to check outcome variant.

The outcomeHasVariant function uses reflection to check for a field named "Cancelled", but direct field access (outcome.Cancelled != nil) is already used elsewhere in the same test file for outcome.Selected. The string concatenation "Cancel"+"led" is also unnecessarily obfuscated.

Replace with direct field access:

-func outcomeHasVariant(outcome acp.RequestPermissionOutcome, variant string) bool {
-	field := reflect.ValueOf(outcome).FieldByName(variant)
-	return field.IsValid() && field.Kind() == reflect.Pointer && !field.IsNil()
+func outcomeCancelled(outcome acp.RequestPermissionOutcome) bool {
+	return outcome.Cancelled != nil
 }

Then update the call site:

-	if !outcomeHasVariant(cancelledResp.Outcome, "Cancel"+"led") {
+	if !outcomeCancelled(cancelledResp.Outcome) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/agent/client_test.go` around lines 675 - 678, The current
outcomeHasVariant uses reflection to detect variants; replace it with direct
field access to use concrete checks (e.g., outcome.Cancelled != nil) to match
the rest of the test file. Modify the outcomeHasVariant function to switch on
the known variant names (such as "Cancelled" and "Selected") and return the
corresponding direct nil-checks (outcome.Cancelled != nil, outcome.Selected !=
nil) instead of reflect.ValueOf; then update any call sites that rely on
outcomeHasVariant to pass those explicit variant names or, better, replace their
calls with the direct nil-checks directly where used.
internal/core/agent/session_helpers_test.go (2)

170-185: Wrap loop iterations in t.Run subtests for proper test isolation and reporting.

The loop iterates over test cases without using t.Run, which means failures won't identify which specific block type failed. As per coding guidelines, use table-driven tests with subtests (t.Run) as the default pattern.

♻️ Proposed refactor
 func TestConvertACPContentBlockFallbacks(t *testing.T) {
 	t.Parallel()

-	cases := []acp.ContentBlock{
-		acp.AudioBlock("U29tZUF1ZGlv", "audio/mpeg"),
-		acp.ResourceLinkBlock("docs", "https://example.com"),
+	cases := []struct {
+		name  string
+		block acp.ContentBlock
+	}{
+		{"audio block", acp.AudioBlock("U29tZUF1ZGlv", "audio/mpeg")},
+		{"resource link block", acp.ResourceLinkBlock("docs", "https://example.com")},
 	}

-	for _, block := range cases {
-		converted, err := convertACPContentBlock(block)
-		if err != nil {
-			t.Fatalf("convert content block fallback: %v", err)
-		}
-		assertBlockTypes(t, converted, model.BlockText)
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			converted, err := convertACPContentBlock(tc.block)
+			if err != nil {
+				t.Fatalf("convert content block fallback: %v", err)
+			}
+			assertBlockTypes(t, converted, model.BlockText)
+		})
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/agent/session_helpers_test.go` around lines 170 - 185, Wrap
each iteration in TestConvertACPContentBlockFallbacks with a t.Run subtest to
isolate failures: inside the for loop create a local copy (e.g., tc := block)
and call t.Run with a descriptive name (like fmt.Sprintf("%T", tc) or a string
identifying the block), then run the test body in the closure (optionally
calling t.Parallel() inside the subtest), invoking convertACPContentBlock(tc)
and using assertBlockTypes(t, converted, model.BlockText); this ensures the loop
variable is captured correctly and failures report which specific block type
failed.

187-203: Consider splitting into focused subtests for distinct helper functions.

This test validates multiple unrelated helpers (marshalRawJSON, stringifyValue, renderDiffText) in a single test function. Using t.Run subtests would improve failure isolation and test clarity.

♻️ Suggested structure
 func TestSessionConversionHelpers(t *testing.T) {
 	t.Parallel()

-	raw := marshalRawJSON(map[string]string{"path": "main.go"})
-	if string(raw) != `{"path":"main.go"}` {
-		t.Fatalf("unexpected raw json: %s", string(raw))
-	}
-	if got := stringifyValue(map[string]string{"status": "ok"}); !strings.Contains(got, `"status":"ok"`) {
-		t.Fatalf("unexpected stringified value: %s", got)
-	}
-	if got := stringifyValue("plain"); got != "plain" {
-		t.Fatalf("unexpected plain string: %q", got)
-	}
-	if got := renderDiffText("main.go", "new", nil); !strings.Contains(got, "+++ main.go") {
-		t.Fatalf("unexpected rendered diff: %q", got)
-	}
+	t.Run("marshalRawJSON produces valid JSON", func(t *testing.T) {
+		t.Parallel()
+		raw := marshalRawJSON(map[string]string{"path": "main.go"})
+		if string(raw) != `{"path":"main.go"}` {
+			t.Fatalf("unexpected raw json: %s", string(raw))
+		}
+	})
+
+	t.Run("stringifyValue handles maps and strings", func(t *testing.T) {
+		t.Parallel()
+		if got := stringifyValue(map[string]string{"status": "ok"}); !strings.Contains(got, `"status":"ok"`) {
+			t.Fatalf("unexpected stringified value: %s", got)
+		}
+		if got := stringifyValue("plain"); got != "plain" {
+			t.Fatalf("unexpected plain string: %q", got)
+		}
+	})
+
+	t.Run("renderDiffText includes file header", func(t *testing.T) {
+		t.Parallel()
+		if got := renderDiffText("main.go", "new", nil); !strings.Contains(got, "+++ main.go") {
+			t.Fatalf("unexpected rendered diff: %q", got)
+		}
+	})
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/agent/session_helpers_test.go` around lines 187 - 203, Split
TestSessionConversionHelpers into focused t.Run subtests so failures isolate to
the specific helper: create subtests for marshalRawJSON (assert exact JSON
output), stringifyValue (one subtest for map value containing `"status":"ok"`
and one for plain string returning "plain"), and renderDiffText (assert it
contains "+++ main.go"); keep t.Parallel() either at the top-level test and/or
inside each t.Run closure, and move the existing assertions into their
corresponding subtest bodies referencing the same helper functions
marshalRawJSON, stringifyValue, and renderDiffText.
internal/core/run/execution.go (1)

633-640: Goroutine in waitChannel lacks explicit ownership tracking.

Per coding guidelines, every goroutine must have explicit ownership and shutdown. This monitoring goroutine is acceptable as it naturally terminates when wg.Wait() completes, but consider adding a brief comment clarifying its lifecycle.

📝 Add clarifying comment
 func (j *jobExecutionContext) waitChannel() <-chan struct{} {
 	done := make(chan struct{})
+	// Monitoring goroutine - terminates when all jobs complete (wg.Wait returns)
 	go func() {
 		j.wg.Wait()
 		close(done)
 	}()
 	return done
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/run/execution.go` around lines 633 - 640, The goroutine started
in jobExecutionContext.waitChannel currently has no explicit ownership note; add
a brief comment above the goroutine in waitChannel explaining that the goroutine
is owned by the jobExecutionContext, its sole purpose is to close the returned
done channel after j.wg.Wait() completes, and that it will always exit when the
wait group finishes (so no explicit shutdown is required). Locate the
waitChannel method and insert the explanatory comment referencing the goroutine,
j.wg.Wait(), and the done channel to satisfy the ownership/shutdown guideline.
internal/core/run/session_view_model.go (1)

289-302: Use constants for plan entry status strings instead of magic strings.

The status matching uses hardcoded strings "completed" and "in_progress". These should reference constants (likely from acp package or define local ones) to prevent typos and improve maintainability.

♻️ Suggested improvement
+const (
+	planStatusCompleted  = "completed"
+	planStatusInProgress = "in_progress"
+)
+
 func buildPlanState(entries []model.SessionPlanEntry) SessionPlanState {
 	state := SessionPlanState{Entries: clonePlanEntries(entries)}
 	for _, entry := range entries {
 		switch entry.Status {
-		case "completed":
+		case planStatusCompleted:
 			state.DoneCount++
-		case "in_progress":
+		case planStatusInProgress:
 			state.RunningCount++
 		default:
 			state.PendingCount++
 		}
 	}
 	return state
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/run/session_view_model.go` around lines 289 - 302, The
buildPlanState function currently compares entry.Status to magic strings
("completed", "in_progress"); replace those literals with named constants to
avoid typos and improve maintainability. Update the switch in buildPlanState
(and any related uses of SessionPlanEntry/SessionPlanState) to compare against
the canonical constants from the acp package (e.g., acp.PlanEntryStatusCompleted
and acp.PlanEntryStatusInProgress) and treat all other values as pending; if
those acp constants don’t exist, declare local consts (e.g.,
planEntryStatusCompleted, planEntryStatusInProgress) at the top of the file and
use them instead.
internal/core/agent/registry.go (1)

574-593: Joining multiple availability errors loses structured information.

When multiple launchers fail, joinAvailabilityErrors concatenates error strings with |, losing the structured *AvailabilityError type. Consider returning an errors.Join or a custom aggregate error that preserves the structure.

♻️ Preserve error structure
 func joinAvailabilityErrors(spec Spec, errs []error) error {
 	if len(errs) == 0 {
 		return &AvailabilityError{
 			IDE:         spec.ID,
 			DisplayName: spec.DisplayName,
 			DocsURL:     spec.DocsURL,
 			InstallHint: spec.InstallHint,
 			Cause:       errors.New("no ACP launch candidates configured"),
 		}
 	}
 	if len(errs) == 1 {
 		return errs[0]
 	}

-	parts := make([]string, 0, len(errs))
-	for _, err := range errs {
-		parts = append(parts, err.Error())
-	}
-	return errors.New(strings.Join(parts, " | "))
+	// Return the first AvailabilityError with all attempts as cause chain
+	return &AvailabilityError{
+		IDE:         spec.ID,
+		DisplayName: spec.DisplayName,
+		DocsURL:     spec.DocsURL,
+		InstallHint: spec.InstallHint,
+		Cause:       errors.Join(errs...),
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/agent/registry.go` around lines 574 - 593, The
joinAvailabilityErrors function currently flattens multiple errors into a joined
string, losing the structured AvailabilityError information; modify
joinAvailabilityErrors to preserve error types by returning either
errors.Join(errs...) or constructing a custom aggregate error that embeds the
original []error (and any AvailabilityError instances) so callers can
unwrap/type-assert individual AvailabilityError values; update the function to
collect the original error values (not just Error() strings) and return
errors.Join(errs...) (or a new AggregateAvailabilityError type that implements
Error() and Unwrap()/Errors() to expose the slice) while keeping the existing
single-error and zero-error behaviors (return the AvailabilityError for
zero-candidate case and the single error for len==1).
internal/core/run/command_io.go (2)

27-42: File close errors are silently ignored; consider logging.

The close() method ignores errors from outFile.Close() and errFile.Close() (lines 32, 35). While these rarely fail in practice, logging them would help diagnose file system issues.

♻️ Log file close errors
 func (s *sessionExecution) close() {
 	if s.releaseClient != nil {
 		defer s.releaseClient()
 	}
 	if s.outFile != nil {
-		_ = s.outFile.Close()
+		if err := s.outFile.Close(); err != nil {
+			s.logger.Warn("failed to close stdout log file", "error", err)
+		}
 	}
 	if s.errFile != nil {
-		_ = s.errFile.Close()
+		if err := s.errFile.Close(); err != nil {
+			s.logger.Warn("failed to close stderr log file", "error", err)
+		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/run/command_io.go` around lines 27 - 42, The close() method on
sessionExecution currently ignores errors returned by s.outFile.Close() and
s.errFile.Close(); update sessionExecution.close to capture those errors and log
them (e.g., using s.logger.Warn or s.logger.Error) similarly to how
s.client.Close() is handled, producing descriptive messages like "failed to
close outFile" / "failed to close errFile" and include the error value so file
close failures are visible; keep the existing defer for s.releaseClient and the
existing s.client close behavior.

201-207: Environment variables for color support are hardcoded.

Consider documenting why these specific environment variables are set, or making them configurable for environments where color output should be disabled.

📝 Add documentation comment
+// buildSessionEnvironment returns environment variables that enable color output
+// in terminal-aware tools running within the ACP session. These ensure consistent
+// rendering of styled output from the underlying agent process.
 func buildSessionEnvironment() map[string]string {
 	return map[string]string{
 		"FORCE_COLOR":    "1",
 		"CLICOLOR_FORCE": "1",
 		"TERM":           "xterm-256color",
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/run/command_io.go` around lines 201 - 207, The
buildSessionEnvironment function hardcodes color-related env vars which may be
inappropriate in some runtimes; update buildSessionEnvironment to either read
and respect existing user environment (e.g., NO_COLOR or TERM), or accept a
configuration flag to enable/disable forcing color, and add a clear doc comment
above buildSessionEnvironment explaining why FORCE_COLOR, CLICOLOR_FORCE and
TERM are set and when they can be overridden; reference the
buildSessionEnvironment function name so reviewers can find and change the
behavior and add the documentation.
internal/core/run/ui_layout.go (2)

10-33: Consider extracting magic numbers as named constants for maintainability.

The minimum terminal dimensions (80x24) are hardcoded. Extracting these as named constants would improve readability and make future adjustments easier.

♻️ Suggested constants
+const (
+	minTerminalWidth  = 80
+	minTerminalHeight = 24
+)
+
 func (m *uiModel) computeLayout(totalWidth, totalHeight int) uiLayoutState {
-	if totalWidth < 80 || totalHeight < 24 {
+	if totalWidth < minTerminalWidth || totalHeight < minTerminalHeight {
 		return uiLayoutState{
 			mode:          uiLayoutResizeBlocked,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/run/ui_layout.go` around lines 10 - 33, Extract the hardcoded
minimum terminal dimensions and any other inline "magic" numbers in
computeLayout into named constants to improve readability and maintainability:
replace the literal 80 and 24 with e.g. minTerminalWidth and minTerminalHeight,
and consider naming the 0.28 sidebar ratio (e.g. defaultSidebarRatio) and
chromeHeight/minContentHeight if not already constants; update the
uiModel.computeLayout method and any references to use these new constants so
uiLayoutState construction still behaves identically.

45-53: Consider using built-in min/max functions available in Go 1.21+.

The clamp helper can be simplified using the built-in min and max functions if the project targets Go 1.21 or later.

♻️ Simplified clamp
 func clamp(value, minValue, maxValue int) int {
-	if value < minValue {
-		return minValue
-	}
-	if value > maxValue {
-		return maxValue
-	}
-	return value
+	return max(minValue, min(value, maxValue))
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/run/ui_layout.go` around lines 45 - 53, Replace the manual
comparisons in the clamp function with the Go 1.21 built-in min/max helpers: in
function clamp(value, minValue, maxValue int) return min(max(value, minValue),
maxValue) so the logic is expressed using the built-in min and max; ensure the
codebase is targeting Go 1.21+ before making this change and update any tests if
necessary.
internal/core/agent/client.go (1)

479-511: Consider documenting the magic idle wait duration.

Line 509 uses a hardcoded 15*time.Millisecond for waitForIdle. This appears to be a grace period for final updates to arrive before marking the session complete. Consider adding a comment explaining the rationale or making it configurable.

Add explanatory comment
-	session.waitForIdle(ctx, 15*time.Millisecond)
+	// Allow a brief grace period for any final streamed updates to arrive
+	// before marking the session complete.
+	session.waitForIdle(ctx, 15*time.Millisecond)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/agent/client.go` around lines 479 - 511, The hardcoded
15*time.Millisecond grace period in runPrompt when calling session.waitForIdle
is unclear; update runPrompt to either add a concise comment explaining why 15ms
was chosen (what it guards against and any tradeoffs) or make the wait duration
configurable (e.g., plumb a configurable idleWait duration into clientImpl or
sessionImpl and use that instead) and replace the literal with the named
variable; reference runPrompt, session.waitForIdle, and the 15*time.Millisecond
literal when applying the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/core/agent/client.go`:
- Around line 196-229: ReadTextFile and WriteTextFile accept unvalidated
agent-supplied paths, enabling path traversal; fix by resolving params.Path
against the session working directory (use the stored SessionRequest.WorkingDir
from session creation), compute an absolute path (filepath.Abs or
filepath.Join(workingDir, params.Path)), then ensure the resolved path is
contained within the workingDir (e.g., filepath.Rel and reject if it begins with
".."); for WriteTextFile also preserve existing file permissions when
overwriting by calling os.Stat on the target (if it exists) to get FileMode and
use that mode instead of hardcoded 0o600 when calling os.WriteFile; return a
clear error if validation fails.

In `@internal/core/agent/process_unix.go`:
- Around line 5-10: Replace direct error comparisons (e.g., killErr ==
syscall.ESRCH and err != os.ErrProcessDone) with errors.Is checks
(errors.Is(killErr, syscall.ESRCH) and !errors.Is(err, os.ErrProcessDone));
import the "errors" package. Also wrap the fallback error when
returning/propagating (use fmt.Errorf("context: %w", err)) so the original error
is preserved. Look for the process-termination logic that sets killErr and err
(e.g., in the terminate/kill routine in this file) and update those comparisons
and return statements accordingly.

In `@internal/core/agent/process_windows.go`:
- Around line 22-23: The current check compares the kill error directly to
os.ErrProcessDone; change the conditional to use errors.Is(err,
os.ErrProcessDone) when evaluating the result of cmd.Process.Kill(), and when
returning a non-nil, non-ErrProcessDone error wrap it with context using
fmt.Errorf (e.g., "killing process: %w") so the returned error includes both the
original error and descriptive context; update the import list to include the
errors and fmt packages if not already imported.

In `@internal/core/agent/registry.go`:
- Around line 549-572: The verifyLauncher function currently calls
exec.CommandContext with context.Background(); change verifyLauncher to accept a
context.Context parameter (e.g., func verifyLauncher(ctx context.Context, spec
Spec, launcher Launcher) error), replace context.Background() with the passed
ctx when creating the command, and update all callers (notably
resolveLaunchCommand) to forward their context through to verifyLauncher; ensure
related tests and any other references are updated to pass a context and
preserve existing behavior (including error construction using output and
Cause).

In `@internal/core/agent/session.go`:
- Around line 441-454: The function marshalRawJSON currently swallows
json.Marshal errors by returning nil; change marshalRawJSON to return
(json.RawMessage, error) so callers can handle serialization errors instead of
silently losing context, update all call sites of marshalRawJSON to handle the
returned error, and ensure any special-case behavior for json.RawMessage is
preserved while propagating the underlying json.Marshal error (or logging it)
rather than returning nil.
- Around line 64-78: The publish method on sessionImpl silently drops updates
when s.updates is full; modify sessionImpl.publish to record when an update is
dropped instead of discarding silently by detecting the default case of the
select and emitting an observable signal—e.g., increment a dropped counter or
call a logger—so replace the empty default with a call to a metrics counter or
processLogger (or s.logger) noting the dropped update along with identifying
fields (status/session id) while keeping the non-blocking behavior and existing
s.updatesSeen increment.

In `@internal/core/model/content_test.go`:
- Around line 289-305: The negative tests currently only check for non-nil
errors, which is too broad; update the assertions to pin the failure mode by
asserting on specific error content or types (use testing helpers like
require.ErrorContains/require.ErrorIs or t.Fatalf with strings). For calls to
model.NewContentBlock((*model.TextBlock)(nil)) and
model.NewContentBlock(struct{}{}), assert the returned error message or error
type matches the expected "nil pointer" and "unsupported payload" errors
respectively (use ErrorContains or ErrorIs). In TestContentBlockMarshalErrors,
replace the generic checks for json.Marshal(model.ContentBlock{}) and
json.Marshal(model.ContentBlock{Type: model.BlockText}) with assertions that the
error message contains the expected "missing type" and "missing data" texts (or
match the concrete error types if exported). Locate these checks in the test
function names TestContentBlockMarshalErrors and the NewContentBlock calls to
make the targeted changes.

In `@internal/core/model/model_test.go`:
- Around line 11-128: Convert the direct assertions into t.Run("Should ...")
subtests and make existing subtests follow the "Should..." naming: for
TestRuntimeConfigApplyDefaults, wrap each expectation (concurrent, batch size,
IDE, reasoning effort, mode, timeout, retry multiplier) as individual t.Run
subtests (call cfg.ApplyDefaults() once in a parent t.Run or setup and run
assertions in subtests) and call t.Parallel() inside each subtest; for
TestPathHelpers, create t.Run subtests for TasksBaseDir, TaskDirectory, and
ArchivedTasksDir checks; for TestJobIssueCount, wrap the IssueCount assertion in
a t.Run subtest; for TestUsageTotalUsesExplicitTotalWhenPresent, create two
t.Run subtests for explicit TotalTokens present and absent cases; for
TestRuntimeConfigApplyDefaultsPreservesExplicitValues, wrap the preservation
check in a t.Run subtest; and in TestIsActiveWorkflowDirName rename each case’s
t.Run to the "Should..." format (e.g., "Should return true for regular workflow
names") and keep t.Parallel() in each subtest. Use the existing symbols
(RuntimeConfig.ApplyDefaults, TasksBaseDir, TaskDirectory, ArchivedTasksDir,
IsActiveWorkflowDirName, Job.IssueCount, Usage.Total) to locate and modify the
tests.

In `@internal/core/run/execution_acp_integration_test.go`:
- Around line 489-526: The loadRunACPHelperScenario function reads and updates
counterFile without synchronization causing a race when multiple helper
processes run; wrap the read-modify-write sequence for counterFile in an
exclusive file lock (create the file if missing), e.g., open counterFile with
os.OpenFile and obtain an advisory flock (or use a small locking library) before
reading, parsing (strconv.Atoi), computing next, and writing the new value,
release the lock after write, and handle timeouts/errors so concurrent helpers
serialize access to counterFile.

In `@internal/core/run/execution_acp_test.go`:
- Around line 66-70: The test is calling t.Errorf inside a goroutine (around
model.NewContentBlock/model.TextBlock usage), which can miss failures; change
the goroutine to send any error to a buffered channel (e.g., errCh := make(chan
error, 1) or use an errgroup/WaitGroup) instead of calling t.Errorf directly,
then in the main test goroutine receive from that channel and call
t.Fatalf/t.Errorf based on the received error; apply the same pattern to the
other occurrence referenced (lines around 294-298) so all goroutine errors are
reported deterministically from the main test goroutine.
- Around line 569-577: The publish method can race with finish because it
releases s.mu before sending to s.updates, allowing finish to close the channel;
change publish (fakeACPSession.publish) to hold the mutex while checking and
performing the send (e.g., acquire write lock or keep the read lock and promote
to write) or otherwise coordinate with finish (e.g., use a sync.Once/closed flag
checked under lock) so that the send to s.updates happens only while the session
is known-open; ensure the lock is released after the send to avoid the race and
avoid sending on a closed channel.

In `@internal/core/run/execution.go`:
- Around line 567-580: The executeJob function runs jobs even when
acquireWorkerSlot returns false (likely due to context cancellation), which
bypasses semaphore concurrency control; change executeJob to early-return when
acquireWorkerSlot(jobCtx) returns false (so newJobRunner(...).run is not called)
or, if running jobs during shutdown is intentional, add a clear comment in
executeJob explaining that behavior and why it bypasses acquireWorkerSlot;
reference the acquireWorkerSlot, releaseWorkerSlot, executeJob and
newJobRunner(...).run symbols so the change is made in this function and ensure
releaseWorkerSlot is only deferred when a slot was successfully acquired.
- Around line 178-184: The non-blocking send in
executorController.requestShutdown can silently drop shutdownRequests when the
shutdownRequests channel is full; change the implementation in requestShutdown
to attempt to send but not silently discard: perform a send to
c.shutdownRequests with a bounded timeout (e.g., use time.After) and if the
timeout elapses log a warning including the request details (shutdownRequest,
uiQuitRequest and shutdownSourceUI) so dropped requests are visible; ensure the
timeout branch uses c.log or the existing logger to record the drop and do not
remove the original shutdownRequest type or channel.

In `@internal/core/run/logging.go`:
- Around line 136-155: The error path in sessionUpdateHandler.HandleCompletion
returns early if writeRenderedLines fails, so markDone (which signals Done()) is
never called and callers can block; ensure markDone is always invoked on
completion errors by calling h.markDone(err, true) before returning from the
writeRenderedLines error branch (or install a defer at the top of
HandleCompletion that calls h.markDone with the appropriate error/flag) so that
Done() is always finalized even when writeRenderedLines fails.
- Around line 89-103: The UI channel sends in HandleUpdate are synchronous and
can block; change the direct h.uiCh <- jobUpdateMsg{...} and h.uiCh <-
usageUpdateMsg{...} to non-blocking sends so a full UI buffer won’t stall the
ACP read path. Replace each direct send with a select { case h.uiCh <- msg:
default: } pattern for the jobUpdateMsg (after sessionView.Apply(update)) and
for the usageUpdateMsg (inside the hasUsage block), leaving the aggregateUsage
lock/use logic unchanged.

In `@internal/core/run/ui_view_test.go`:
- Around line 171-179: The test stopped actually asserting backgrounds because
assertRenderedCellsUseBackground was reduced to a tautology (calling
sameColor(want, want) and just checking non-empty output); restore the helper to
iterate the rendered cells returned by renderTitleBar/renderHelp/renderSidebar
and assert each cell's background equals the expected color (e.g., colorBgBase
for renderTitleBar and renderHelp, colorBgSurface for renderSidebar) instead of
the current no-op check; update assertRenderedCellsUseBackground to compare each
cell.Background (or the equivalent field in the cell type) against the provided
want color using sameColor(cellBg, want) and return a failing t.Errorf when
mismatched; apply the same fix to the duplicate helper usage referenced around
lines 295-303 so both tests validate background ownership properly.

In `@internal/core/run/ui_view.go`:
- Around line 642-669: The access offsets[job.selectedEntry] in
restoreTranscriptViewport can panic if job.selectedEntry is out of range; before
using it, validate and clamp job.selectedEntry (or fall back to 0) against
len(offsets) (e.g. if job.selectedEntry < 0 or >= len(offsets) set selectedIndex
:= 0 or selectedIndex := min(max(job.selectedEntry,0), len(offsets)-1)) and then
use offsets[selectedIndex] to compute selectedLine; update job.selectedEntry if
you choose to clamp so future calls remain safe. Ensure this check sits just
before computing selectedLine in the restoreTranscriptViewport method of uiModel
and references uiJob and offsets.

In `@test/skills_bundle_test.go`:
- Around line 56-106: Wrap the body of the TestEmbeddedSkillsFSMatchesOnDisk
test in a named subtest using t.Run with the "Should..." naming convention
(e.g., t.Run("Should match embedded skills FS with on-disk source", func(t
*testing.T) { ... })), moving t.Parallel() and the existing logic into that
closure so the top-level TestEmbeddedSkillsFSMatchesOnDisk only calls t.Run and
the assertions remain unchanged.

---

Nitpick comments:
In `@internal/core/agent/client_test.go`:
- Around line 401-444: The test
TestClientCreateSessionSurfacesStartupCommandAndStderr intentionally calls
t.Setenv to modify PATH and therefore must not run in parallel; add a short
clarifying comment above the test (or directly above the t.Setenv call) stating
that t.Parallel is omitted because the test mutates global environment (PATH)
and relies on isolation, so running in parallel could interfere with other
tests; reference the test name and the t.Setenv usage in the comment so future
maintainers understand why parallelism is disabled.
- Around line 184-192: The test uses a hardcoded 2-second time.After window when
waiting on session.Done() which can flake on slow CI; replace the literal
timeout with a configurable duration (e.g. read from an env var like
TEST_TIMEOUT or use a package-level default constant) and apply it to both uses
of time.After(...) in this test so the waits are longer or adjustable in CI;
keep the logic that checks <-session.Done() and errors.Is(session.Err(),
context.Canceled) unchanged, only swap the 2*time.Second literal for the
configurable duration used by the test.
- Around line 675-678: The current outcomeHasVariant uses reflection to detect
variants; replace it with direct field access to use concrete checks (e.g.,
outcome.Cancelled != nil) to match the rest of the test file. Modify the
outcomeHasVariant function to switch on the known variant names (such as
"Cancelled" and "Selected") and return the corresponding direct nil-checks
(outcome.Cancelled != nil, outcome.Selected != nil) instead of reflect.ValueOf;
then update any call sites that rely on outcomeHasVariant to pass those explicit
variant names or, better, replace their calls with the direct nil-checks
directly where used.

In `@internal/core/agent/client.go`:
- Around line 479-511: The hardcoded 15*time.Millisecond grace period in
runPrompt when calling session.waitForIdle is unclear; update runPrompt to
either add a concise comment explaining why 15ms was chosen (what it guards
against and any tradeoffs) or make the wait duration configurable (e.g., plumb a
configurable idleWait duration into clientImpl or sessionImpl and use that
instead) and replace the literal with the named variable; reference runPrompt,
session.waitForIdle, and the 15*time.Millisecond literal when applying the
change.

In `@internal/core/agent/registry_test.go`:
- Around line 87-105: The test contains an unnecessary inner loop-variable
capture "tc := tc" before t.Run; remove that redundant reassignment so the
subtest uses the loop variable directly. Update the table-driven loop where tc
is used with lookupAgentSpec, spec.launchCommand(resolveModel(...)), and
spec.probeCommand() by deleting the "tc := tc" line(s) to rely on Go's corrected
loop variable semantics.

In `@internal/core/agent/registry.go`:
- Around line 574-593: The joinAvailabilityErrors function currently flattens
multiple errors into a joined string, losing the structured AvailabilityError
information; modify joinAvailabilityErrors to preserve error types by returning
either errors.Join(errs...) or constructing a custom aggregate error that embeds
the original []error (and any AvailabilityError instances) so callers can
unwrap/type-assert individual AvailabilityError values; update the function to
collect the original error values (not just Error() strings) and return
errors.Join(errs...) (or a new AggregateAvailabilityError type that implements
Error() and Unwrap()/Errors() to expose the slice) while keeping the existing
single-error and zero-error behaviors (return the AvailabilityError for
zero-candidate case and the single error for len==1).

In `@internal/core/agent/session_helpers_test.go`:
- Around line 170-185: Wrap each iteration in
TestConvertACPContentBlockFallbacks with a t.Run subtest to isolate failures:
inside the for loop create a local copy (e.g., tc := block) and call t.Run with
a descriptive name (like fmt.Sprintf("%T", tc) or a string identifying the
block), then run the test body in the closure (optionally calling t.Parallel()
inside the subtest), invoking convertACPContentBlock(tc) and using
assertBlockTypes(t, converted, model.BlockText); this ensures the loop variable
is captured correctly and failures report which specific block type failed.
- Around line 187-203: Split TestSessionConversionHelpers into focused t.Run
subtests so failures isolate to the specific helper: create subtests for
marshalRawJSON (assert exact JSON output), stringifyValue (one subtest for map
value containing `"status":"ok"` and one for plain string returning "plain"),
and renderDiffText (assert it contains "+++ main.go"); keep t.Parallel() either
at the top-level test and/or inside each t.Run closure, and move the existing
assertions into their corresponding subtest bodies referencing the same helper
functions marshalRawJSON, stringifyValue, and renderDiffText.

In `@internal/core/agent/session.go`:
- Around line 80-95: In sessionImpl.finish, avoid holding s.mu while doing
channel operations: perform the mutex-protected checks and state updates (check
s.finished, set s.finished = true, assign s.err) while locked, then unlock
before doing the non-atomic actions (sending to s.updates and closing s.updates
and s.done). Concretely, move the select and the close(s.updates)/close(s.done)
calls to after s.mu.Unlock() so the lock is not held during potential blocking
or panic-prone channel operations.

In `@internal/core/run/acp_transcript_test.go`:
- Around line 200-208: The two test helpers mustContentBlockTranscriptTest (in
acp_transcript_test.go) and mustContentBlockLoggingTest (in logging_test.go) are
duplicate; extract a single shared helper (e.g., mustContentBlock or
mustContentBlockTest) into a common test helper file in the same package (e.g.,
test_helpers_test.go) that calls model.NewContentBlock, handles the error with
t.Fatalf, and returns model.ContentBlock, then update call sites to use the new
helper and remove the duplicated functions.

In `@internal/core/run/command_io.go`:
- Around line 27-42: The close() method on sessionExecution currently ignores
errors returned by s.outFile.Close() and s.errFile.Close(); update
sessionExecution.close to capture those errors and log them (e.g., using
s.logger.Warn or s.logger.Error) similarly to how s.client.Close() is handled,
producing descriptive messages like "failed to close outFile" / "failed to close
errFile" and include the error value so file close failures are visible; keep
the existing defer for s.releaseClient and the existing s.client close behavior.
- Around line 201-207: The buildSessionEnvironment function hardcodes
color-related env vars which may be inappropriate in some runtimes; update
buildSessionEnvironment to either read and respect existing user environment
(e.g., NO_COLOR or TERM), or accept a configuration flag to enable/disable
forcing color, and add a clear doc comment above buildSessionEnvironment
explaining why FORCE_COLOR, CLICOLOR_FORCE and TERM are set and when they can be
overridden; reference the buildSessionEnvironment function name so reviewers can
find and change the behavior and add the documentation.

In `@internal/core/run/execution_ui_test.go`:
- Around line 60-117: The test TestExecutorControllerUIQuitEntersDrainingPath
duplicates setup across multiple similar cases; refactor it into a table-driven
subtest using t.Run("Should...") where each entry varies the trigger (calls to
executorController.requestShutdown with uiQuitRequestDrain or others) and the
expected effects (cancelCalls, ui.shutdownCalls, observed shutdownPhase via
ui.eventsCh); keep the shared harness creation (fakeLifecycleUISession,
jobExecutionContext, executorController, done channel, resultCh and goroutine
invoking controller.awaitCompletion) and move per-case actions/assertions into
each subtest, using identifiers like
TestExecutorControllerUIQuitEntersDrainingPath,
executorController.requestShutdown, uiQuitRequestDrain, fakeLifecycleUISession,
awaitCompletion, shutdownStatusMsg and shutdownPhaseDraining to locate the code
to change. Ensure each subtest asserts its specific expectations and preserves
timeouts used on ui.eventsCh and resultCh.

In `@internal/core/run/execution.go`:
- Around line 633-640: The goroutine started in jobExecutionContext.waitChannel
currently has no explicit ownership note; add a brief comment above the
goroutine in waitChannel explaining that the goroutine is owned by the
jobExecutionContext, its sole purpose is to close the returned done channel
after j.wg.Wait() completes, and that it will always exit when the wait group
finishes (so no explicit shutdown is required). Locate the waitChannel method
and insert the explanatory comment referencing the goroutine, j.wg.Wait(), and
the done channel to satisfy the ownership/shutdown guideline.

In `@internal/core/run/session_view_model.go`:
- Around line 289-302: The buildPlanState function currently compares
entry.Status to magic strings ("completed", "in_progress"); replace those
literals with named constants to avoid typos and improve maintainability. Update
the switch in buildPlanState (and any related uses of
SessionPlanEntry/SessionPlanState) to compare against the canonical constants
from the acp package (e.g., acp.PlanEntryStatusCompleted and
acp.PlanEntryStatusInProgress) and treat all other values as pending; if those
acp constants don’t exist, declare local consts (e.g., planEntryStatusCompleted,
planEntryStatusInProgress) at the top of the file and use them instead.

In `@internal/core/run/ui_layout.go`:
- Around line 10-33: Extract the hardcoded minimum terminal dimensions and any
other inline "magic" numbers in computeLayout into named constants to improve
readability and maintainability: replace the literal 80 and 24 with e.g.
minTerminalWidth and minTerminalHeight, and consider naming the 0.28 sidebar
ratio (e.g. defaultSidebarRatio) and chromeHeight/minContentHeight if not
already constants; update the uiModel.computeLayout method and any references to
use these new constants so uiLayoutState construction still behaves identically.
- Around line 45-53: Replace the manual comparisons in the clamp function with
the Go 1.21 built-in min/max helpers: in function clamp(value, minValue,
maxValue int) return min(max(value, minValue), maxValue) so the logic is
expressed using the built-in min and max; ensure the codebase is targeting Go
1.21+ before making this change and update any tests if necessary.

In `@internal/core/run/ui_model_test.go`:
- Around line 76-97: The table-driven loop in ui_model_test.go should be
converted to t.Run subtests so each case is identifiable and can run in
parallel; for each test case (enumerated states jobPending, jobRunning,
jobSuccess, jobFailed) wrap the assertions in t.Run with a descriptive name
(e.g. the label) and call t.Parallel() at the start of the subtest, then perform
the same checks against m.getStateLabel, m.jobStateIcon, m.jobStateColor, and
m.jobBorderColor(&uiJob{state: tc.state}); ensure failures use t.Fatalf (or
t.Errorf) inside the subtest so failures are reported per-case.
- Around line 61-114: The test TestFormattingAndStateHelpersCoverBranches is
exercising many concerns; split it into focused subtests using t.Run for each
area: one subtest for formatNumber and formatDuration (call formatNumber and
formatDuration and assert results), one for state helpers (create newUIModel(1)
and separately assert getStateLabel, jobStateIcon, jobStateColor, jobBorderColor
for each jobState), one for elapsed rendering (use uiJob instances and assert
elapsedStr renders), and one for view rendering (set m.jobs, m.total,
m.currentView and assert m.View().Content). Keep the same assertions and helper
values but move them into these named t.Run blocks so failures point to the
specific function under test (referencing functions: formatNumber,
formatDuration, newUIModel, getStateLabel, jobStateIcon, jobStateColor,
jobBorderColor, elapsedStr, View).

In `@internal/core/run/ui_update.go`:
- Around line 329-344: Replace the magic number 3 in refreshSidebarContent with
a named constant representing the sidebar item height: declare a package-level
constant (e.g. sidebarItemHeight) and use it instead of the literal in the
calculations for lineOffset and the YOffset adjustments inside
uiModel.refreshSidebarContent; update both occurrences (lineOffset calculation
and SetYOffset adjustments) so the intent is clear and maintainable while
leaving renderSidebarItem, m.selectedJob, m.sidebarViewport and related logic
unchanged.

In `@internal/core/run/ui_view.go`:
- Around line 193-206: Update the cache metric labels to be more descriptive:
replace the abbreviated strings "CACHER " and "CACHEW " passed into
renderStyledOnBackground with clearer labels such as "CACHE_R " and "CACHE_W "
or full words like "CACHE READ " and "CACHE WRITE " where the calls occur inside
the two renderOwnedLine invocations that render u.CacheReads and u.CacheWrites
(these use innerW, bg, renderStyledOnBackground, renderGap, value, label, and
formatNumber). Ensure spacing/formatting remains consistent with the surrounding
UI text.

In `@internal/setup/bundle.go`:
- Around line 27-29: The function bundledSkillsRoot currently returns (fs.FS,
error) but never returns an error; change its signature to return only fs.FS
(func bundledSkillsRoot() fs.FS) and update the body to return skills.FS
directly; then update all call sites (notably bundle_test.go) to stop expecting
an error return and remove the redundant error handling/unwrapping code. Ensure
any references to the old two-value return are adjusted to a single-value use.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

Comment on lines +196 to +229
// ReadTextFile handles ACP file read requests from the agent.
func (c *clientImpl) ReadTextFile(_ context.Context, params acp.ReadTextFileRequest) (acp.ReadTextFileResponse, error) {
content, err := os.ReadFile(params.Path)
if err != nil {
return acp.ReadTextFileResponse{}, err
}
return acp.ReadTextFileResponse{Content: string(content)}, nil
}

// WriteTextFile handles ACP file write requests from the agent.
func (c *clientImpl) WriteTextFile(
_ context.Context,
params acp.WriteTextFileRequest,
) (acp.WriteTextFileResponse, error) {
if err := os.WriteFile(params.Path, []byte(params.Content), 0o600); err != nil {
return acp.WriteTextFileResponse{}, err
}
return acp.WriteTextFileResponse{}, nil
}

// RequestPermission auto-approves the first offered option to match the current non-interactive runtime.
func (c *clientImpl) RequestPermission(
_ context.Context,
params acp.RequestPermissionRequest,
) (acp.RequestPermissionResponse, error) {
if len(params.Options) == 0 {
return acp.RequestPermissionResponse{
Outcome: acp.NewRequestPermissionOutcomeCancelled(),
}, nil
}
return acp.RequestPermissionResponse{
Outcome: acp.NewRequestPermissionOutcomeSelected(params.Options[0].OptionId),
}, nil
}

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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if there's any path validation or sandboxing configured elsewhere
rg -n "ReadTextFile|WriteTextFile" --type=go -C3

Repository: compozy/compozy

Length of output: 3188


🏁 Script executed:

#!/bin/bash
# Understanding the architecture: how is clientImpl initialized and what is the threat model?
rg -n "clientImpl\{" --type=go -B2 -A5

Repository: compozy/compozy

Length of output: 2094


🏁 Script executed:

#!/bin/bash
# Check if there's a working directory or base path concept
rg -n "workdir|workingDir|basePath|sandboxPath|allowedDir" --type=go -i

Repository: compozy/compozy

Length of output: 1472


🏁 Script executed:

#!/bin/bash
# Look at the test file to understand intended usage context
head -100 internal/core/agent/client_test.go | tail -50

Repository: compozy/compozy

Length of output: 1421


🏁 Script executed:

#!/bin/bash
# Search for documentation about the agent and security model
find . -name "*.md" -o -name "*.txt" | xargs grep -l "agent\|sandbox\|security" 2>/dev/null | head -5

Repository: compozy/compozy

Length of output: 263


🏁 Script executed:

#!/bin/bash
# Check how the agent/client is instantiated and used
rg -n "newClientImpl|NewClient|&clientImpl" --type=go -B2 -A3

Repository: compozy/compozy

Length of output: 4407


🏁 Script executed:

#!/bin/bash
# Look at the isPathSafe implementation
sed -n '274,300p' internal/setup/install.go

Repository: compozy/compozy

Length of output: 934


🏁 Script executed:

#!/bin/bash
# Check if clientImpl or session has access to WorkingDir
rg -n "WorkingDir" internal/core/agent/client.go -B2 -A2

Repository: compozy/compozy

Length of output: 640


🏁 Script executed:

#!/bin/bash
# Look at the sessionImpl structure to understand what context is available
rg -n "type sessionImpl struct" --type=go -A15

Repository: compozy/compozy

Length of output: 944


🏁 Script executed:

#!/bin/bash
# Check if ReadTextFile/WriteTextFile should use WorkingDir
rg -n "func.*ReadTextFile|func.*WriteTextFile" internal/core/agent/ --type=go -B3 -A10

Repository: compozy/compozy

Length of output: 1668


🏁 Script executed:

#!/bin/bash
# Search for agent security design or documentation
find . -name "*.go" -exec grep -l "malicious\|untrusted\|sandbox" {} \; 2>/dev/null | head -5

Repository: compozy/compozy

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Check if clientImpl stores sessions and if there's a way to track current session
rg -n "type clientImpl struct" --type=go -A20

Repository: compozy/compozy

Length of output: 1287


🏁 Script executed:

#!/bin/bash
# Look at how ReadTextFile/WriteTextFile are invoked in tests to understand session context
rg -n "ReadTextFile\|WriteTextFile" internal/core/agent/client_test.go -B5 -A5

Repository: compozy/compozy

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Check the ACP protocol spec or imports to understand context passing
rg -n "type.*Request\|ClientSideConnection" internal/core/agent/client.go -B2 -A5

Repository: compozy/compozy

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Search for how the agent process calls these methods - is there a session context?
rg -n "ClientSideConnection\|NewClientSideConnection" internal/core/agent/ --type=go -B5 -A10

Repository: compozy/compozy

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Look at the full client initialization to see how ACP connection is set up
sed -n '95,170p' internal/core/agent/client.go

Repository: compozy/compozy

Length of output: 2080


🏁 Script executed:

#!/bin/bash
# Search for where ReadTextFile is actually called from or how ACP routes these calls
rg -n "ReadTextFile" --type=go -B1 -A1 | head -30

Repository: compozy/compozy

Length of output: 1145


🏁 Script executed:

#!/bin/bash
# Check if there's any context or session tracking in the ACP handler
rg -n "Handler\|client.Read\|client.Write" internal/core/agent/client.go -B2 -A5

Repository: compozy/compozy

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Look at how the test client handles these file operations
sed -n '260,280p' internal/core/agent/client_test.go

Repository: compozy/compozy

Length of output: 751


🏁 Script executed:

#!/bin/bash
# Check if there are any comments or docs about the architecture
grep -r "agent\|sandbox\|security" --include="*.go" internal/core/agent/ | grep -i "comment\|//\|doc\|design" | head -10

Repository: compozy/compozy

Length of output: 1050


🏁 Script executed:

#!/bin/bash
# Check if there's any method to look up session context or working directory during file operations
rg -n "lookupSession\|storeSession" internal/core/agent/client.go -B2 -A8

Repository: compozy/compozy

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Check if there's documentation about the threat model or design of file operations
find . -name "*.md" -exec grep -l "ReadTextFile\|WriteTextFile\|path\|sandbox" {} \; 2>/dev/null

Repository: compozy/compozy

Length of output: 2937


🏁 Script executed:

#!/bin/bash
# Look at the sessionImpl storage to see if there's any way to associate file ops with sessions
sed -n '27,100p' internal/core/agent/session.go

Repository: compozy/compozy

Length of output: 1384


🏁 Script executed:

#!/bin/bash
# Check if the ACP request includes session context that could be used for validation
rg -n "ReadTextFileRequest\|WriteTextFileRequest" --type=go | head -10

Repository: compozy/compozy

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Check ACP protocol/request definitions to understand session context in file requests
find . -name "*.go" -exec grep -l "acp\." {} \; | head -5 | xargs grep -l "ReadTextFileRequest"

Repository: compozy/compozy

Length of output: 181


🏁 Script executed:

#!/bin/bash
# Look for README or architecture docs that explain the threat model
find . -name "README*" -o -name "ARCHITECTURE*" -o -name "DESIGN*" | xargs cat 2>/dev/null | head -100

Repository: compozy/compozy

Length of output: 3585


🏁 Script executed:

#!/bin/bash
# Check if agent subprocess has any sandboxing or capability restrictions
rg -n "exec.Cmd\|SetupAgent\|StartAgent" internal/core/agent/client.go -B5 -A15

Repository: compozy/compozy

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Look at the actual test that uses ReadTextFile/WriteTextFile to understand if paths are scoped
sed -n '255,295p' internal/core/agent/client_test.go

Repository: compozy/compozy

Length of output: 1367


🏁 Script executed:

#!/bin/bash
# Check if there are any other file operation methods that DO validate paths
rg -n "func.*ReadFile|func.*WriteFile|func.*File" internal/core/agent/client.go --type=go

Repository: compozy/compozy

Length of output: 223


🏁 Script executed:

#!/bin/bash
# Check if there's any way the ACP connection or handlers have access to session context
rg -n "ClientSideConnection\|Handler" --type=go -B3 -A10 | grep -i "session\|context\|callback"

Repository: compozy/compozy

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Look for how other file operations in the codebase handle path validation
rg -n "isPathSafe" --type=go -B2 -A10

Repository: compozy/compozy

Length of output: 1970


🏁 Script executed:

#!/bin/bash
# Check if there are other ACP handlers that might have similar concerns
rg -n "func \(c \*clientImpl\)" internal/core/agent/client.go --type=go | head -20

Repository: compozy/compozy

Length of output: 1227


🏁 Script executed:

#!/bin/bash
# Check if there's a way to look up session from within handler context
rg -n "storeSession\|lookupSession\|removeSession" internal/core/agent/client.go -A5

Repository: compozy/compozy

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Check the ACP protocol to understand ReadTextFileRequest structure
find . -type f -name "*.go" -exec grep -l "ReadTextFileRequest\|acp\." {} \; | xargs grep -A5 "type.*ReadTextFileRequest\|ReadTextFileRequest{" 2>/dev/null | head -40

Repository: compozy/compozy

Length of output: 533


🏁 Script executed:

#!/bin/bash
# Look at the ACP connection setup to understand if session context is available to handlers
sed -n '310,350p' internal/core/agent/client.go

Repository: compozy/compozy

Length of output: 980


🏁 Script executed:

#!/bin/bash
# Check if there's any documentation about the threat model or expectations
rg -n "malicious\|untrusted\|security\|sandbox\|restrict" internal/core/agent/client.go --type=go

Repository: compozy/compozy

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Look at how other handlers get session context
sed -n '232,250p' internal/core/agent/client.go

Repository: compozy/compozy

Length of output: 668


🏁 Script executed:

#!/bin/bash
# Check if ReadTextFile/WriteTextFile can determine current session from connection state
rg -n "conn\.|connection\|handler" internal/core/agent/client.go -B2 -A5 | head -50

Repository: compozy/compozy

Length of output: 1376


🏁 Script executed:

#!/bin/bash
# Critical check: see how SessionUpdate gets SessionId and if ReadTextFile can access it
rg -n "func.*ReadTextFile\|func.*SessionUpdate" internal/core/agent/client.go -B2 -A15

Repository: compozy/compozy

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Check if acp.ReadTextFileRequest or connection has session context
find . -path ./vendor -prune -o -type f -name "*.go" -print | xargs grep -l "type.*Request struct" | xargs grep -A10 "type.*Request struct" 2>/dev/null | grep -A10 "ReadTextFile\|WriteTextFile" | head -30

Repository: compozy/compozy

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Look at the complete flow - how does agent call these methods?
rg -n "ClientSideConnection\|NewClientSideConnection" --type=go -B3 -A20

Repository: compozy/compozy

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Check if there's conversation about this in any ADR or design doc
find . -name "*.md" -path "*acp*" -o -name "*ADR*" -o -name "*DESIGN*" | xargs cat 2>/dev/null | head -200

Repository: compozy/compozy

Length of output: 7667


🏁 Script executed:

#!/bin/bash
# Check if the agent is expected to be trusted or untrusted
rg -n "agent\|trust\|malicious\|security" internal/core/agent/*.go | grep -i "comment\|//" | head -20

Repository: compozy/compozy

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Critical: check if acp.ReadTextFileRequest has SessionId or any context
find . -type f -name "*.go" -exec grep -l "acp\." {} \; 2>/dev/null | head -10 | xargs grep "ReadTextFileRequest\|WriteTextFileRequest" 2>/dev/null | head -20

Repository: compozy/compozy

Length of output: 535


🏁 Script executed:

#!/bin/bash
# Check the vendor or external acp module to understand the request structure
find . -type d -name "acp*" 2>/dev/null

Repository: compozy/compozy

Length of output: 91


🏁 Script executed:

#!/bin/bash
# Look at the full ReadTextFile/WriteTextFile implementations with context
sed -n '196,230p' internal/core/agent/client.go

Repository: compozy/compozy

Length of output: 1293


🏁 Script executed:

#!/bin/bash
# Check if there's any way to map current operation back to a session
rg -n "conn\." internal/core/agent/client.go | head -20

Repository: compozy/compozy

Length of output: 342


🏁 Script executed:

#!/bin/bash
# Verify the agent is indeed a subprocess communicating via ACP
rg -n "exec.Command\|subprocess\|agent.*binary" internal/core/agent/client.go --type=go -B2 -A2

Repository: compozy/compozy

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Look at the acp-integration ADR to understand security assumptions
find . -path "*acp*" -name "*.md" -exec cat {} \;

Repository: compozy/compozy

Length of output: 50373


🏁 Script executed:

#!/bin/bash
# Check if there's any documentation about agent trust model
rg -n "agent.*trust\|third.?party\|sandboxed\|security" .compozy/tasks/acp-integration/ --type=md 2>/dev/null

Repository: compozy/compozy

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Check if sessions have any way to be looked up during RPC calls
rg -n "lookupSession\|getSession\|currentSession" internal/core/agent/client.go --type=go -B3 -A8

Repository: compozy/compozy

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Verify the agent subprocess is indeed external/could be untrusted
sed -n '286,360p' internal/core/agent/client.go

Repository: compozy/compozy

Length of output: 1952


🏁 Script executed:

#!/bin/bash
# Final check: see if there's any path validation or restrictions elsewhere that these handlers should use
rg -n "validatePath\|checkPath\|safePath" internal/core/agent/ --type=go

Repository: compozy/compozy

Length of output: 41


Path traversal vulnerability in file operation handlers.

The ReadTextFile and WriteTextFile handlers accept agent-provided paths without validation. Since these are ACP request handlers called by the agent subprocess, a malicious or buggy agent could request files outside the intended working directory. While the agent is typically trusted (Claude, Codex, etc.), path validation is a standard security practice given that these methods operate on the filesystem.

Additionally, WriteTextFile uses hardcoded 0o600 permissions instead of preserving the original file's permissions when overwriting, which may alter access control on existing files unintentionally.

Consider validating paths against the session's working directory (available from SessionRequest.WorkingDir during session creation) and preserving file permissions when overwriting existing files.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/agent/client.go` around lines 196 - 229, ReadTextFile and
WriteTextFile accept unvalidated agent-supplied paths, enabling path traversal;
fix by resolving params.Path against the session working directory (use the
stored SessionRequest.WorkingDir from session creation), compute an absolute
path (filepath.Abs or filepath.Join(workingDir, params.Path)), then ensure the
resolved path is contained within the workingDir (e.g., filepath.Rel and reject
if it begins with ".."); for WriteTextFile also preserve existing file
permissions when overwriting by calling os.Stat on the target (if it exists) to
get FileMode and use that mode instead of hardcoded 0o600 when calling
os.WriteFile; return a clear error if validation fails.

Comment on lines +5 to +10
import (
"fmt"
"os"
"os/exec"
"syscall"
)

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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

According to the Go standard library docs for os.Process.Kill, os.ErrProcessDone, and syscall.Kill, should callers use errors.Is(err, os.ErrProcessDone)/errors.Is(err, syscall.ESRCH) instead of direct equality when handling benign process-termination races?

💡 Result:

Yes, callers should use errors.Is(err, os.ErrProcessDone) when handling errors from os.Process.Kill (and os.Process.Signal) to detect benign process-termination races where the process has already finished. Direct equality err == os.ErrProcessDone is insufficient because the underlying syscall.Kill may return syscall.ESRCH wrapped in a syscall error, and os.Process.signal explicitly maps syscall.ESRCH to os.ErrProcessDone (or an equivalent sentinel), but errors.Is handles both the sentinel and any wrapped errors properly. For direct syscall.Kill(pid, sig), use errors.Is(err, syscall.ESRCH) since it returns a syscall.Errno, which implements errors.Is to unwrap targets correctly. Direct equality err == syscall.ESRCH works for simple cases but errors.Is is more robust for wrapped errors. This practice is evidenced by official Go source code using errors.Is(err, os.ErrProcessDone) in os/exec (e.g., lines checking interruptErr and killErr) and tools like golang.org/x/tools, confirming it's the recommended approach for handling these races portably across Unix-like systems.

Citations:


Use errors.Is() for process-termination checks.

Direct equality comparisons killErr == syscall.ESRCH and err != os.ErrProcessDone violate the guideline to use errors.Is() for error matching. These comparisons may miss wrapped errors during process-termination races. Additionally, wrap the fallback error with context per "Prefer explicit error returns with wrapped context using fmt.Errorf("context: %w", err)".

Suggested fix
 import (
+	"errors"
 	"fmt"
 	"os"
 	"os/exec"
 	"syscall"
 )
@@
 	pgid, err := syscall.Getpgid(cmd.Process.Pid)
 	if err == nil && pgid > 0 {
-		if killErr := syscall.Kill(-pgid, syscall.SIGKILL); killErr == nil || killErr == syscall.ESRCH {
+		if killErr := syscall.Kill(-pgid, syscall.SIGKILL); killErr == nil || errors.Is(killErr, syscall.ESRCH) {
 			return nil
 		}
 	}
-	if err := cmd.Process.Kill(); err != nil && err != os.ErrProcessDone {
-		return err
+	if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
+		return fmt.Errorf("kill ACP process %d: %w", cmd.Process.Pid, err)
 	}
 	return nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/agent/process_unix.go` around lines 5 - 10, Replace direct
error comparisons (e.g., killErr == syscall.ESRCH and err != os.ErrProcessDone)
with errors.Is checks (errors.Is(killErr, syscall.ESRCH) and !errors.Is(err,
os.ErrProcessDone)); import the "errors" package. Also wrap the fallback error
when returning/propagating (use fmt.Errorf("context: %w", err)) so the original
error is preserved. Look for the process-termination logic that sets killErr and
err (e.g., in the terminate/kill routine in this file) and update those
comparisons and return statements accordingly.

Comment thread internal/core/agent/process_windows.go Outdated
Comment on lines +22 to +23
if err := cmd.Process.Kill(); err != nil && err != os.ErrProcessDone {
return err

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.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify current error matching/wrapping style in platform-specific process helpers.
rg -n 'Process\.Kill\(|os\.ErrProcessDone|errors\.Is\(|return err|fmt\.Errorf\(' \
  internal/core/agent/process_windows.go internal/core/agent/process_unix.go

Repository: compozy/compozy

Length of output: 557


🏁 Script executed:

cat -n internal/core/agent/process_windows.go

Repository: compozy/compozy

Length of output: 644


🏁 Script executed:

cat -n internal/core/agent/process_unix.go | head -35

Repository: compozy/compozy

Length of output: 963


Replace direct sentinel comparison with errors.Is() and wrap error return.

Line 22 should use errors.Is(err, os.ErrProcessDone) instead of direct comparison, and line 23 should wrap the error with context via fmt.Errorf().

Proposed fix
 import (
+	"errors"
 	"fmt"
 	"os"
 	"os/exec"
 )
@@
-	if err := cmd.Process.Kill(); err != nil && err != os.ErrProcessDone {
-		return err
+	if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
+		return fmt.Errorf("kill ACP process: %w", err)
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if err := cmd.Process.Kill(); err != nil && err != os.ErrProcessDone {
return err
if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
return fmt.Errorf("kill ACP process: %w", err)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/agent/process_windows.go` around lines 22 - 23, The current
check compares the kill error directly to os.ErrProcessDone; change the
conditional to use errors.Is(err, os.ErrProcessDone) when evaluating the result
of cmd.Process.Kill(), and when returning a non-nil, non-ErrProcessDone error
wrap it with context using fmt.Errorf (e.g., "killing process: %w") so the
returned error includes both the original error and descriptive context; update
the import list to include the errors and fmt packages if not already imported.

Comment on lines +549 to +572
func verifyLauncher(spec Spec, launcher Launcher) error {
command := launcher.probeCommand()
if err := assertCommandExists(spec, command); err != nil {
return err
}

cmd := exec.CommandContext(context.Background(), command[0], command[1:]...)
cmd.Env = mergeEnvironment(spec.EnvVars, nil)
var output bytes.Buffer
cmd.Stdout = &output
cmd.Stderr = &output
if err := cmd.Run(); err != nil {
return &AvailabilityError{
IDE: spec.ID,
DisplayName: spec.DisplayName,
Command: command,
DocsURL: spec.DocsURL,
InstallHint: spec.InstallHint,
Output: output.String(),
Cause: err,
}
}
return nil
}

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.

⚠️ Potential issue | 🟡 Minor

verifyLauncher uses context.Background() instead of accepting a context parameter.

Per coding guidelines, avoid context.Background() outside main and focused tests. The function should accept a context.Context parameter to allow proper cancellation and timeout propagation.

🛡️ Proposed fix
-func verifyLauncher(spec Spec, launcher Launcher) error {
+func verifyLauncher(ctx context.Context, spec Spec, launcher Launcher) error {
 	command := launcher.probeCommand()
 	if err := assertCommandExists(spec, command); err != nil {
 		return err
 	}

-	cmd := exec.CommandContext(context.Background(), command[0], command[1:]...)
+	cmd := exec.CommandContext(ctx, command[0], command[1:]...)

Also update the call site in resolveLaunchCommand:

-func resolveLaunchCommand(
+func resolveLaunchCommand(
+	ctx context.Context,
 	spec Spec,
 	modelName string,
 ...
 		if verify {
-			if err := verifyLauncher(spec, launcher); err != nil {
+			if err := verifyLauncher(ctx, spec, launcher); err != nil {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/agent/registry.go` around lines 549 - 572, The verifyLauncher
function currently calls exec.CommandContext with context.Background(); change
verifyLauncher to accept a context.Context parameter (e.g., func
verifyLauncher(ctx context.Context, spec Spec, launcher Launcher) error),
replace context.Background() with the passed ctx when creating the command, and
update all callers (notably resolveLaunchCommand) to forward their context
through to verifyLauncher; ensure related tests and any other references are
updated to pass a context and preserve existing behavior (including error
construction using output and Cause).

Comment on lines +64 to +78
func (s *sessionImpl) publish(update model.SessionUpdate) {
s.mu.Lock()
defer s.mu.Unlock()
if s.finished {
return
}
if update.Status == "" {
update.Status = model.StatusRunning
}
s.updatesSeen++
select {
case s.updates <- update:
default:
}
}

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.

⚠️ Potential issue | 🟡 Minor

Silent update dropping may mask backpressure issues.

When the updates channel is full, publish silently discards the update. While this prevents blocking, it could lead to lost data without any indication. Consider logging when updates are dropped or using a metrics counter for observability.

🔧 Suggested observability improvement
 func (s *sessionImpl) publish(update model.SessionUpdate) {
 	s.mu.Lock()
 	defer s.mu.Unlock()
 	if s.finished {
 		return
 	}
 	if update.Status == "" {
 		update.Status = model.StatusRunning
 	}
 	s.updatesSeen++
 	select {
 	case s.updates <- update:
 	default:
+		// Consider adding observability here:
+		// slog.Debug("session update dropped due to backpressure", "session_id", s.id)
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/agent/session.go` around lines 64 - 78, The publish method on
sessionImpl silently drops updates when s.updates is full; modify
sessionImpl.publish to record when an update is dropped instead of discarding
silently by detecting the default case of the select and emitting an observable
signal—e.g., increment a dropped counter or call a logger—so replace the empty
default with a call to a metrics counter or processLogger (or s.logger) noting
the dropped update along with identifying fields (status/session id) while
keeping the non-blocking behavior and existing s.updatesSeen increment.

Comment on lines +136 to +155
func (h *sessionUpdateHandler) HandleCompletion(err error) error {
if err != nil {
if writeErr := writeRenderedLines(h.errWriter, []string{"ACP session error: " + err.Error()}); writeErr != nil {
return fmt.Errorf("write ACP session completion error: %w", writeErr)
}
h.logger.Error(
"acp session error",
"agent_id",
h.agentID,
"session_id",
h.sessionID,
"duration",
time.Since(h.startedAt),
"error",
err,
"block_counts",
h.snapshotBlockCounts(),
)
h.markDone(err, true)
return nil

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.

⚠️ Potential issue | 🟠 Major

Always finalize done on completion errors.

If writeRenderedLines fails in this branch, the function returns before markDone, so any caller waiting on Done() can block forever in the error path. Finalization should happen before returning, or in a defer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/run/logging.go` around lines 136 - 155, The error path in
sessionUpdateHandler.HandleCompletion returns early if writeRenderedLines fails,
so markDone (which signals Done()) is never called and callers can block; ensure
markDone is always invoked on completion errors by calling h.markDone(err, true)
before returning from the writeRenderedLines error branch (or install a defer at
the top of HandleCompletion that calls h.markDone with the appropriate
error/flag) so that Done() is always finalized even when writeRenderedLines
fails.

Comment thread internal/core/run/logging.go
Comment thread internal/core/run/ui_view_test.go Outdated
Comment on lines 171 to 179
func TestTitleBarHelpAndSidebarOwnTheirBackgrounds(t *testing.T) {
t.Parallel()

m := newPopulatedUIModelForTest(t)
m := newPopulatedUIModelForTest(t, tea.WindowSizeMsg{Width: 120, Height: 30})

assertRenderedCellsUseBackground(t, m.renderTitleBar(), colorBgBase)
}

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

m := newPopulatedUIModelForTest(t)

assertRenderedCellsUseBackground(t, m.renderHelp(), colorBgBase)
assertRenderedCellsUseBackground(t, m.renderSidebar(), colorBgSurface)
}

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.

⚠️ Potential issue | 🟠 Major

This helper stopped asserting background ownership.

assertRenderedCellsUseBackground now only checks for non-empty output and sameColor(want, want), which is a tautology. That means TestTitleBarHelpAndSidebarOwnTheirBackgrounds can no longer fail on the regression it is supposed to catch.

As per coding guidelines, "Ensure tests verify behavior outcomes, not just function calls."

Also applies to: 295-303

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/run/ui_view_test.go` around lines 171 - 179, The test stopped
actually asserting backgrounds because assertRenderedCellsUseBackground was
reduced to a tautology (calling sameColor(want, want) and just checking
non-empty output); restore the helper to iterate the rendered cells returned by
renderTitleBar/renderHelp/renderSidebar and assert each cell's background equals
the expected color (e.g., colorBgBase for renderTitleBar and renderHelp,
colorBgSurface for renderSidebar) instead of the current no-op check; update
assertRenderedCellsUseBackground to compare each cell.Background (or the
equivalent field in the cell type) against the provided want color using
sameColor(cellBg, want) and return a failing t.Errorf when mismatched; apply the
same fix to the duplicate helper usage referenced around lines 295-303 so both
tests validate background ownership properly.

Comment on lines +642 to +669
func (m *uiModel) restoreTranscriptViewport(job *uiJob, offsets []int) {
if len(offsets) == 0 {
m.transcriptViewport.GotoTop()
job.transcriptYOffset = 0
job.transcriptXOffset = 0
job.transcriptFollowTail = true
return
}
return renderOwnedLine(
width,
bg,
title+
renderGap(bg, 1)+
renderStyledOnBackground(styleSeparator, bg, strings.Repeat("─", rightLen)),
)
if job.transcriptFollowTail {
m.transcriptViewport.GotoBottom()
} else {
m.transcriptViewport.SetYOffset(job.transcriptYOffset)
m.transcriptViewport.SetXOffset(job.transcriptXOffset)
}

selectedLine := offsets[job.selectedEntry]
yOffset := m.transcriptViewport.YOffset()
height := max(m.transcriptViewport.Height(), 1)
if selectedLine < yOffset {
m.transcriptViewport.SetYOffset(selectedLine)
} else if selectedLine >= yOffset+height {
m.transcriptViewport.SetYOffset(max(selectedLine-height+1, 0))
}

job.transcriptYOffset = m.transcriptViewport.YOffset()
job.transcriptXOffset = m.transcriptViewport.XOffset()
job.transcriptFollowTail = m.transcriptViewport.AtBottom()
}

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.

⚠️ Potential issue | 🟡 Minor

Potential index out of bounds when accessing offsets[job.selectedEntry].

At line 657, offsets[job.selectedEntry] is accessed without verifying that job.selectedEntry is within bounds of offsets. If selectedEntry is modified concurrently or set to an invalid value, this could panic.

🛡️ Add bounds check
 func (m *uiModel) restoreTranscriptViewport(job *uiJob, offsets []int) {
 	if len(offsets) == 0 {
 		m.transcriptViewport.GotoTop()
 		job.transcriptYOffset = 0
 		job.transcriptXOffset = 0
 		job.transcriptFollowTail = true
 		return
 	}
 	if job.transcriptFollowTail {
 		m.transcriptViewport.GotoBottom()
 	} else {
 		m.transcriptViewport.SetYOffset(job.transcriptYOffset)
 		m.transcriptViewport.SetXOffset(job.transcriptXOffset)
 	}

+	if job.selectedEntry < 0 || job.selectedEntry >= len(offsets) {
+		job.transcriptYOffset = m.transcriptViewport.YOffset()
+		job.transcriptXOffset = m.transcriptViewport.XOffset()
+		job.transcriptFollowTail = m.transcriptViewport.AtBottom()
+		return
+	}
+
 	selectedLine := offsets[job.selectedEntry]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/run/ui_view.go` around lines 642 - 669, The access
offsets[job.selectedEntry] in restoreTranscriptViewport can panic if
job.selectedEntry is out of range; before using it, validate and clamp
job.selectedEntry (or fall back to 0) against len(offsets) (e.g. if
job.selectedEntry < 0 or >= len(offsets) set selectedIndex := 0 or selectedIndex
:= min(max(job.selectedEntry,0), len(offsets)-1)) and then use
offsets[selectedIndex] to compute selectedLine; update job.selectedEntry if you
choose to clamp so future calls remain safe. Ensure this check sits just before
computing selectedLine in the restoreTranscriptViewport method of uiModel and
references uiJob and offsets.

Comment on lines +56 to 106
func TestEmbeddedSkillsFSMatchesOnDisk(t *testing.T) {
t.Parallel()

root := repoRoot(t)
source := filepath.Join(root, "skills")
mirror := filepath.Join(root, "internal", "setup", "assets", "skills")

sourceTree := snapshotTree(t, source)
mirrorTree := snapshotTree(t, mirror)

if len(sourceTree) != len(mirrorTree) {
t.Fatalf("expected bundled mirror to contain %d files, got %d", len(sourceTree), len(mirrorTree))
// Filter out non-skill files (embed.go, autoresearch artifacts, etc.)
wantTree := make(map[string]string, len(sourceTree))
for p, content := range sourceTree {
if strings.HasSuffix(p, ".go") {
continue
}
if strings.Contains(p, "autoresearch-") {
continue
}
wantTree[p] = content
}

embeddedTree := make(map[string]string)
err := fs.WalkDir(skills.FS, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
data, readErr := fs.ReadFile(skills.FS, p)
if readErr != nil {
return readErr
}
embeddedTree[p] = string(data)
return nil
})
if err != nil {
t.Fatalf("walk embedded FS: %v", err)
}

if len(embeddedTree) != len(wantTree) {
t.Fatalf("expected embedded FS to contain %d files, got %d", len(wantTree), len(embeddedTree))
}
for path, sourceContent := range sourceTree {
mirrorContent, ok := mirrorTree[path]
for p, wantContent := range wantTree {
gotContent, ok := embeddedTree[p]
if !ok {
t.Fatalf("expected bundled mirror to contain %s", path)
t.Fatalf("expected embedded FS to contain %s", p)
}
if sourceContent != mirrorContent {
t.Fatalf("expected bundled mirror content for %s to match source skills directory", path)
if gotContent != wantContent {
t.Fatalf("expected embedded content for %s to match on-disk source", p)
}
}
}

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.

⚠️ Potential issue | 🟠 Major

Wrap the new test case in t.Run("Should...") to satisfy test conventions.

This test is currently a single top-level block; please wrap it in a named subtest using the required Should... style.

Suggested adjustment
 func TestEmbeddedSkillsFSMatchesOnDisk(t *testing.T) {
 	t.Parallel()
+	t.Run("Should match embedded skills filesystem with on-disk filtered skills tree", func(t *testing.T) {
+		t.Parallel()
 
-	root := repoRoot(t)
-	source := filepath.Join(root, "skills")
-	sourceTree := snapshotTree(t, source)
+		root := repoRoot(t)
+		source := filepath.Join(root, "skills")
+		sourceTree := snapshotTree(t, source)
 
-	// Filter out non-skill files (embed.go, autoresearch artifacts, etc.)
-	wantTree := make(map[string]string, len(sourceTree))
-	for p, content := range sourceTree {
-		if strings.HasSuffix(p, ".go") {
-			continue
-		}
-		if strings.Contains(p, "autoresearch-") {
-			continue
-		}
-		wantTree[p] = content
-	}
+		// existing assertions...
+	})
 }

As per coding guidelines, **/*_test.go: "MUST use t.Run("Should...") pattern for ALL test cases".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func TestEmbeddedSkillsFSMatchesOnDisk(t *testing.T) {
t.Parallel()
root := repoRoot(t)
source := filepath.Join(root, "skills")
mirror := filepath.Join(root, "internal", "setup", "assets", "skills")
sourceTree := snapshotTree(t, source)
mirrorTree := snapshotTree(t, mirror)
if len(sourceTree) != len(mirrorTree) {
t.Fatalf("expected bundled mirror to contain %d files, got %d", len(sourceTree), len(mirrorTree))
// Filter out non-skill files (embed.go, autoresearch artifacts, etc.)
wantTree := make(map[string]string, len(sourceTree))
for p, content := range sourceTree {
if strings.HasSuffix(p, ".go") {
continue
}
if strings.Contains(p, "autoresearch-") {
continue
}
wantTree[p] = content
}
embeddedTree := make(map[string]string)
err := fs.WalkDir(skills.FS, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
data, readErr := fs.ReadFile(skills.FS, p)
if readErr != nil {
return readErr
}
embeddedTree[p] = string(data)
return nil
})
if err != nil {
t.Fatalf("walk embedded FS: %v", err)
}
if len(embeddedTree) != len(wantTree) {
t.Fatalf("expected embedded FS to contain %d files, got %d", len(wantTree), len(embeddedTree))
}
for path, sourceContent := range sourceTree {
mirrorContent, ok := mirrorTree[path]
for p, wantContent := range wantTree {
gotContent, ok := embeddedTree[p]
if !ok {
t.Fatalf("expected bundled mirror to contain %s", path)
t.Fatalf("expected embedded FS to contain %s", p)
}
if sourceContent != mirrorContent {
t.Fatalf("expected bundled mirror content for %s to match source skills directory", path)
if gotContent != wantContent {
t.Fatalf("expected embedded content for %s to match on-disk source", p)
}
}
}
func TestEmbeddedSkillsFSMatchesOnDisk(t *testing.T) {
t.Parallel()
t.Run("Should match embedded skills filesystem with on-disk filtered skills tree", func(t *testing.T) {
t.Parallel()
root := repoRoot(t)
source := filepath.Join(root, "skills")
sourceTree := snapshotTree(t, source)
// Filter out non-skill files (embed.go, autoresearch artifacts, etc.)
wantTree := make(map[string]string, len(sourceTree))
for p, content := range sourceTree {
if strings.HasSuffix(p, ".go") {
continue
}
if strings.Contains(p, "autoresearch-") {
continue
}
wantTree[p] = content
}
embeddedTree := make(map[string]string)
err := fs.WalkDir(skills.FS, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
data, readErr := fs.ReadFile(skills.FS, p)
if readErr != nil {
return readErr
}
embeddedTree[p] = string(data)
return nil
})
if err != nil {
t.Fatalf("walk embedded FS: %v", err)
}
if len(embeddedTree) != len(wantTree) {
t.Fatalf("expected embedded FS to contain %d files, got %d", len(wantTree), len(embeddedTree))
}
for p, wantContent := range wantTree {
gotContent, ok := embeddedTree[p]
if !ok {
t.Fatalf("expected embedded FS to contain %s", p)
}
if gotContent != wantContent {
t.Fatalf("expected embedded content for %s to match on-disk source", p)
}
}
})
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/skills_bundle_test.go` around lines 56 - 106, Wrap the body of the
TestEmbeddedSkillsFSMatchesOnDisk test in a named subtest using t.Run with the
"Should..." naming convention (e.g., t.Run("Should match embedded skills FS with
on-disk source", func(t *testing.T) { ... })), moving t.Parallel() and the
existing logic into that closure so the top-level
TestEmbeddedSkillsFSMatchesOnDisk only calls t.Run and the assertions remain
unchanged.

This was referenced Apr 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant