Repository navigation
feat: add optional sound notifications on run lifecycle events - #96
Conversation
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 46 minutes and 48 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (6)
WalkthroughAdds an opt-in run-lifecycle sound notification system: new sound config fields throughout CLI, core, SDK, workspace config parsing/validation, a new internal/core/sound package (players, presets, runner, notifier), tests, and hooks that trigger sounds on run completion/failure during finalization. Changes
Sequence DiagramsequenceDiagram
participant CLI as CLI State
participant Exec as Execution Engine
participant Final as FinalizeExecution
participant Notif as Sound.Notifier
participant Player as Sound.Player
participant OS as OS Command
CLI->>Exec: pass runtime config (SoundEnabled, OnCompleted, OnFailed)
Exec->>Final: finalizeExecution(result)
Final->>Final: emit terminal event
Final->>Notif: notifySoundForKind(ctx, kind, cfg)
Notif->>Notif: pickSound(kind, cfg)
Notif->>Player: Player.Play(ctx, sound)
Player->>Player: ResolvePath / map preset
Player->>OS: run platform command (afplay/paplay/powershell)
OS-->>Player: return success|error
Player-->>Notif: return error|nil
Notif->>Notif: log debug on error
Final->>Exec: continue post-shutdown hooks
Estimated Code Review Effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
e06edb0 to
6bb6543
Compare
|
this is a nice feature, there's a reason it's still in draft mode? |
Yes, I haven't had the opportuity to fully test in other OS |
6bb6543 to
ded7f4a
Compare
|
if you tested on macOS is fine for this first version @brunomguimaraes |
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (7)
internal/core/workspace/config_test.go (1)
649-710: Please fold the new sound cases into table-driven subtests.These assertions are fine, but this file already uses table-driven
t.Run(...)coverage for config variants. Grouping the sound scenarios the same way will make it easier to extend coverage for both presets and validation branches without adding more one-off top-level tests. As per coding guidelines,**/*_test.go: Table-driven tests with subtests (t.Run) as the default patternandMUST 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/workspace/config_test.go` around lines 649 - 710, Replace the three separate top-level tests (TestLoadConfigParsesSoundSection, TestLoadConfigLeavesSoundDisabledByDefault, TestLoadConfigRejectsEmptySoundPreset) with a single table-driven test (e.g., TestLoadConfig_SoundCases) that defines a slice of cases containing a descriptive name, the workspace config string, expected error flag, and expected Sound fields; iterate cases and call t.Run("Should ...", func(t *testing.T) { ... }) for each, create temp dir, writeWorkspaceConfig, call LoadConfig, and assert expectations (cfg.Sound.Enabled, OnCompleted, OnFailed or error contents like "sound.on_completed") inside each subtest so all scenarios are covered uniformly and follow the required t.Run("Should...") pattern.internal/core/sound/player.go (1)
61-64: Simplify redundant error check.The error check wrapping
runner.Runcan be simplified since you're just returning the error directly.♻️ Simplified return
- if err := p.runner.Run(ctx, name, args...); err != nil { - return err - } - return nil + return p.runner.Run(ctx, name, args...)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/core/sound/player.go` around lines 61 - 64, The current pattern checks the error from p.runner.Run then returns it, which is redundant; replace the if-block with a direct return of the call — i.e., return p.runner.Run(ctx, name, args...) — to simplify the code in the method that calls p.runner.Run.internal/core/sound/presets.go (2)
18-18: Consider using full name for consistency:PresetSubmarine.The constant name
PresetSubmarappears truncated compared to other presets (PresetGlass,PresetBasso, etc.). While the value is correctly"submarine", the constant name inconsistency could cause confusion when developers reference it in code.♻️ Suggested rename
- PresetSubmar = "submarine" + PresetSubmarine = "submarine"Then update references in
KnownPresets()and all preset maps.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/core/sound/presets.go` at line 18, Rename the truncated constant PresetSubmar to PresetSubmarine for consistency; update its declaration and every reference (including in KnownPresets() and all preset maps/variables that use PresetSubmar) to the new identifier PresetSubmarine, ensuring the string value remains "submarine" and run/rebuild to catch any missed usages.
115-125: Consider resolving Windows presets using theWINDIRenvironment variable at runtime.The hardcoded
C:\Windowspath will fail on non-standard Windows installations whereWINDIRpoints to a different drive. While the comment documents this limitation and users can provide absolute paths as a workaround, usingos.Getenv("WINDIR")would make the feature more robust for non-standard setups. This is a low-priority enhancement given the documented workaround exists.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/core/sound/presets.go` around lines 115 - 125, The windowsPresets map currently uses hardcoded "C:\Windows\Media\..." paths which can break on machines where WINDIR is different; update the code so windowsPresets is built at runtime using os.Getenv("WINDIR") (fallback to "C:\\Windows" if empty) to join with "Media" and the filenames (e.g., build the map in an init function or a helper like newWindowsPresets) and keep the same keys (PresetGlass, PresetBasso, PresetPing, PresetHero, PresetFunk, PresetTink, PresetSubmar) so callers are unchanged. Ensure path construction uses filepath.Join on Windows.internal/core/sound/player_test.go (2)
37-45: Consider using table-driven subtests for consistency.This test covers two distinct cases (empty string and preset name) inline. While the test is simple, using
t.Runsubtests would align with coding guidelines and improve failure diagnostics.♻️ Optional refactor to use t.Run
func TestNoop_Play_AlwaysNil(t *testing.T) { t.Parallel() - if err := (Noop{}).Play(context.Background(), ""); err != nil { - t.Fatalf("expected nil error from Noop, got %v", err) - } - if err := (Noop{}).Play(context.Background(), "glass"); err != nil { - t.Fatalf("expected nil error from Noop with preset, got %v", err) - } + t.Run("Should return nil for empty sound", func(t *testing.T) { + t.Parallel() + if err := (Noop{}).Play(context.Background(), ""); err != nil { + t.Fatalf("expected nil error from Noop, got %v", err) + } + }) + t.Run("Should return nil for preset name", func(t *testing.T) { + t.Parallel() + if err := (Noop{}).Play(context.Background(), "glass"); err != nil { + t.Fatalf("expected nil error from Noop with preset, got %v", err) + } + }) }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/sound/player_test.go` around lines 37 - 45, Update TestNoop_Play_AlwaysNil to use table-driven subtests: create a slice of test cases (with Name like "Should return nil for empty" and "Should return nil for preset" or similar) containing the preset string values, call t.Run for each case and run the (Noop{}).Play(context.Background(), preset) assertion inside the subtest (optionally calling t.Parallel() inside each subtest), and keep the original error checks but replace the inline checks with t.Fatalf inside each t.Run; reference the existing test function TestNoop_Play_AlwaysNil and the Noop.Play method when locating the change.
116-121: Consider removing or expanding this trivial constructor test.Per coding guidelines, "Constructor tests that only check != nil" is listed as an anti-pattern to reject. This test doesn't verify any meaningful business logic.
If you want to keep coverage for
New(), consider testing that the returned player actually behaves correctly (e.g., verify it's anosPlayerwith proper wiring by callingPlaywith a known preset and checking the command resolution).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/core/sound/player_test.go` around lines 116 - 121, The TestNew_ReturnsNonNilPlayer test is trivial and should be removed or replaced: either delete the TestNew_ReturnsNonNilPlayer test entirely, or expand it to assert behavior by calling New() and verifying the concrete type and wiring (e.g., assert the returned value from New() is an *osPlayer or implements expected methods) and exercise a method such as Play with a deterministic preset to confirm command resolution and dependencies are set up correctly; update the test to call New(), cast/inspect the returned player, and invoke Play with a known input to assert expected outputs or mock interactions instead of only checking for nil.internal/core/sound/subscriber_test.go (1)
55-56: Remove unnecessary loop variable capture in table-driven tests.The
tc := tcpattern at lines 56 and 90 is no longer needed. Go 1.26.1 (the project's target version) captures loop variables per-iteration by default since Go 1.22. These lines can be safely removed.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/core/sound/subscriber_test.go` around lines 55 - 56, Remove the redundant per-iteration variable capture statement "tc := tc" inside the table-driven test loops; locate the "for _, tc := range cases {" loops in the test and delete the inner "tc := tc" lines (they are unnecessary as Go 1.26.1 already captures loop variables per iteration), leaving the loop body to reference tc directly.
🤖 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/model/runtime_config.go`:
- Around line 88-95: ApplyDefaults() currently only checks cfg.SoundOnCompleted
== "" and cfg.SoundOnFailed == "", so whitespace-only values bypass defaults;
update ApplyDefaults (the block gated by cfg.SoundEnabled) to treat
whitespace-only as unset by trimming with strings.TrimSpace (or equivalent)
before the emptiness check and then set
DefaultSoundOnCompleted/DefaultSoundOnFailed when trimmed value is empty; ensure
you reference cfg.SoundOnCompleted and cfg.SoundOnFailed and leave ErrEmptySound
behavior unchanged (it will still catch truly empty values later).
In `@internal/core/run/exec/exec.go`:
- Around line 946-978: The cancellation of the run context prevents sounds from
playing; in notifySoundForKind detach playback from the run cancellation by
creating a non-cancellable context (use context.WithoutCancel(ctx)) and then
wrap that in a short timeout (context.WithTimeout(..., e.g. 3*time.Second)) so
playback is bounded; pass the timeout context into sound.Notify. Update
notifySoundForKind (referenced from submitRuntimeEvent) to construct ctxNoCancel
and ctxTimeout, defer cancel(), and call sound.Notify(ctxTimeout, ...) and
ensure time is imported.
In `@internal/core/run/executor/execution.go`:
- Around line 282-290: notifySoundForKind currently passes the run context into
sound.Notify which may already be canceled; change it to create a short-lived,
non-cancelable derived context with a small timeout so sound playback runs even
if the parent ctx is canceled. In notifySoundForKind, wrap the incoming ctx with
context.WithoutCancel(ctx) and then context.WithTimeout(...) (choose a short
duration, e.g. ~1–3s), defer the timeout cancel, and pass that new ctx to
sound.Notify while keeping the existing cfg.SoundEnabled check and sound.Config
usage.
In `@internal/core/sound/player_windows.go`:
- Around line 15-21: In resolveWindowsCommand, the PowerShell single-quoted path
must escape any apostrophes by doubling them before interpolating into the
script; modify resolveWindowsCommand to call ResolvePath(sound) as before, then
replace every single-quote in the returned path with two single-quotes (e.g.,
strings.ReplaceAll(path, "'", "''")) and use that escapedPath in the
fmt.Sprintf("(New-Object Media.SoundPlayer '%s').PlaySync()", escapedPath) so
the returned command ("powershell", []string{"-NoProfile", "-Command", script},
nil) remains correct even for paths containing apostrophes.
In `@internal/core/sound/presets_test.go`:
- Around line 40-55: Update the test for the case "unknown preset produces
descriptive error" to assert the returned error message contains the unknown
preset string and (optionally) the list of known presets: after calling
ResolvePath(tc.input) in the failing branch, replace the generic nil-check with
an assertion like ErrorContains(err, tc.input) (and/or ErrorContains(err, "known
presets") or a sample preset name) so the test verifies the user-facing message
mentions the unknown value and helps detect regressions in ResolvePath's error
text.
- Around line 12-28: The test uses a hardcoded Unix absolute path in variable
absPath which breaks on Windows; replace the literal "/tmp/custom.aiff" with a
platform-portable path built from t.TempDir() and filepath.Join (e.g., absPath
:= filepath.Join(t.TempDir(), "custom.aiff")), ensure the test imports
"path/filepath" and calls t.TempDir() at test start, and keep the existing cases
(the trimming case and others) using that absPath so ResolvePath-related
assertions remain correct.
---
Nitpick comments:
In `@internal/core/sound/player_test.go`:
- Around line 37-45: Update TestNoop_Play_AlwaysNil to use table-driven
subtests: create a slice of test cases (with Name like "Should return nil for
empty" and "Should return nil for preset" or similar) containing the preset
string values, call t.Run for each case and run the
(Noop{}).Play(context.Background(), preset) assertion inside the subtest
(optionally calling t.Parallel() inside each subtest), and keep the original
error checks but replace the inline checks with t.Fatalf inside each t.Run;
reference the existing test function TestNoop_Play_AlwaysNil and the Noop.Play
method when locating the change.
- Around line 116-121: The TestNew_ReturnsNonNilPlayer test is trivial and
should be removed or replaced: either delete the TestNew_ReturnsNonNilPlayer
test entirely, or expand it to assert behavior by calling New() and verifying
the concrete type and wiring (e.g., assert the returned value from New() is an
*osPlayer or implements expected methods) and exercise a method such as Play
with a deterministic preset to confirm command resolution and dependencies are
set up correctly; update the test to call New(), cast/inspect the returned
player, and invoke Play with a known input to assert expected outputs or mock
interactions instead of only checking for nil.
In `@internal/core/sound/player.go`:
- Around line 61-64: The current pattern checks the error from p.runner.Run then
returns it, which is redundant; replace the if-block with a direct return of the
call — i.e., return p.runner.Run(ctx, name, args...) — to simplify the code in
the method that calls p.runner.Run.
In `@internal/core/sound/presets.go`:
- Line 18: Rename the truncated constant PresetSubmar to PresetSubmarine for
consistency; update its declaration and every reference (including in
KnownPresets() and all preset maps/variables that use PresetSubmar) to the new
identifier PresetSubmarine, ensuring the string value remains "submarine" and
run/rebuild to catch any missed usages.
- Around line 115-125: The windowsPresets map currently uses hardcoded
"C:\Windows\Media\..." paths which can break on machines where WINDIR is
different; update the code so windowsPresets is built at runtime using
os.Getenv("WINDIR") (fallback to "C:\\Windows" if empty) to join with "Media"
and the filenames (e.g., build the map in an init function or a helper like
newWindowsPresets) and keep the same keys (PresetGlass, PresetBasso, PresetPing,
PresetHero, PresetFunk, PresetTink, PresetSubmar) so callers are unchanged.
Ensure path construction uses filepath.Join on Windows.
In `@internal/core/sound/subscriber_test.go`:
- Around line 55-56: Remove the redundant per-iteration variable capture
statement "tc := tc" inside the table-driven test loops; locate the "for _, tc
:= range cases {" loops in the test and delete the inner "tc := tc" lines (they
are unnecessary as Go 1.26.1 already captures loop variables per iteration),
leaving the loop body to reference tc directly.
In `@internal/core/workspace/config_test.go`:
- Around line 649-710: Replace the three separate top-level tests
(TestLoadConfigParsesSoundSection, TestLoadConfigLeavesSoundDisabledByDefault,
TestLoadConfigRejectsEmptySoundPreset) with a single table-driven test (e.g.,
TestLoadConfig_SoundCases) that defines a slice of cases containing a
descriptive name, the workspace config string, expected error flag, and expected
Sound fields; iterate cases and call t.Run("Should ...", func(t *testing.T) {
... }) for each, create temp dir, writeWorkspaceConfig, call LoadConfig, and
assert expectations (cfg.Sound.Enabled, OnCompleted, OnFailed or error contents
like "sound.on_completed") inside each subtest so all scenarios are covered
uniformly and follow the required t.Run("Should...") pattern.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 16915609-7a9b-4869-89ef-73852560fc86
⛔ Files ignored due to path filters (1)
skills/compozy/references/config-reference.mdis excluded by!**/*.md
📒 Files selected for processing (21)
internal/cli/state.gointernal/cli/workspace_config.gointernal/core/api.gointernal/core/model/model_test.gointernal/core/model/runtime_config.gointernal/core/run/exec/exec.gointernal/core/run/executor/execution.gointernal/core/run/internal/runshared/config.gointernal/core/sound/player.gointernal/core/sound/player_test.gointernal/core/sound/player_unix.gointernal/core/sound/player_windows.gointernal/core/sound/presets.gointernal/core/sound/presets_test.gointernal/core/sound/runner.gointernal/core/sound/subscriber.gointernal/core/sound/subscriber_test.gointernal/core/workspace/config_test.gointernal/core/workspace/config_types.gointernal/core/workspace/config_validate.gosdk/extension/types.go
| func (s *execRunState) submitRuntimeEvent(kind eventspkg.EventKind, payload any) error { | ||
| if s == nil || s.journal == nil { | ||
| if s == nil { | ||
| return nil | ||
| } | ||
| ctx := s.ctx | ||
| if ctx == nil { | ||
| ctx = context.Background() | ||
| } | ||
| event, err := newRuntimeEvent(s.runArtifacts.RunID, kind, payload) | ||
| if err != nil { | ||
| return err | ||
| if s.journal != nil { | ||
| event, err := newRuntimeEvent(s.runArtifacts.RunID, kind, payload) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if err := s.journal.Submit(ctx, event); err != nil { | ||
| return err | ||
| } | ||
| } | ||
| s.notifySoundForKind(ctx, kind) | ||
| return nil | ||
| } | ||
|
|
||
| // notifySoundForKind plays the configured sound for a terminal lifecycle | ||
| // event kind. It runs synchronously so the audio finishes before the exec | ||
| // run state is closed. When the [sound] feature flag is off this is a no-op. | ||
| func (s *execRunState) notifySoundForKind(ctx context.Context, kind eventspkg.EventKind) { | ||
| if s == nil || s.cfg == nil || !s.cfg.SoundEnabled { | ||
| return | ||
| } | ||
| return s.journal.Submit(ctx, event) | ||
| sound.Notify(ctx, sound.Config{ | ||
| Player: sound.New(), | ||
| OnCompleted: s.cfg.SoundOnCompleted, | ||
| OnFailed: s.cfg.SoundOnFailed, | ||
| }, kind, slog.Default()) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's locate and examine the sound package implementation
find . -path "*/internal/core/sound/*.go" -type f | head -20Repository: compozy/compozy
Length of output: 384
🏁 Script executed:
# Also search for the submitRuntimeEvent function to understand the full context
rg "submitRuntimeEvent" -A 5 -B 5 --type goRepository: compozy/compozy
Length of output: 14616
🏁 Script executed:
# Check the exec.go file around the flagged lines
wc -l internal/core/run/exec/exec.goRepository: compozy/compozy
Length of output: 94
🏁 Script executed:
# Examine the sound/runner.go to see how context is used with exec.CommandContext
cat -n ./internal/core/sound/runner.goRepository: compozy/compozy
Length of output: 868
🏁 Script executed:
# Check the exact implementation of submitRuntimeEvent in exec.go around lines 946-978
sed -n '945,980p' internal/core/run/exec/exec.goRepository: compozy/compozy
Length of output: 1043
🏁 Script executed:
# Look at the sound.Notify function signature and implementation
cat -n ./internal/core/sound/player.go | head -100Repository: compozy/compozy
Length of output: 2884
🏁 Script executed:
# Search for where context cancellation happens and when submitRuntimeEvent is called
rg "run.cancelled\|EventKindRunCancelled" -B 5 -A 5 --type goRepository: compozy/compozy
Length of output: 41
🏁 Script executed:
# Look for context.WithoutCancel usage in the codebase to see if it's being used elsewhere
rg "WithoutCancel\|WithCancel" --type goRepository: compozy/compozy
Length of output: 41
🏁 Script executed:
# Check what Go version is being targeted in go.mod
head -5 go.modRepository: compozy/compozy
Length of output: 114
🏁 Script executed:
# Find where emitTurnResult is called to understand the cancellation timing
rg "emitTurnResult" -B 3 -A 3 --type goRepository: compozy/compozy
Length of output: 954
🏁 Script executed:
# Search for where s.ctx is set and how/when it might be cancelled
rg "\.ctx\s*=" --type go -A 2 | grep -A 2 "execRunState"Repository: compozy/compozy
Length of output: 41
🏁 Script executed:
# Look for the context cancellation flow in the exec.go file
rg "cancel\|Done\|Context" internal/core/run/exec/exec.go | head -30Repository: compozy/compozy
Length of output: 41
🏁 Script executed:
# Check how the state is created and context initialized
rg "execRunState\{" -A 10 --type go | head -40Repository: compozy/compozy
Length of output: 2427
🏁 Script executed:
# Look at the complete flow of how terminal events are emitted
sed -n '800,900p' internal/core/run/exec/exec.goRepository: compozy/compozy
Length of output: 2933
🏁 Script executed:
# Check when context cancellation occurs during the run execution
rg "ctx.Done\|select.*Done" internal/core/run/exec/exec.go -B 2 -A 2Repository: compozy/compozy
Length of output: 41
🏁 Script executed:
# Look for where the run gets canceled and how the context relates to it
sed -n '1,100p' internal/core/run/exec/exec.goRepository: compozy/compozy
Length of output: 3930
🏁 Script executed:
# Check the RunExec function to see how the context flows
rg "func RunExec" -A 30 internal/core/run/exec/exec.goRepository: compozy/compozy
Length of output: 41
🏁 Script executed:
# Verify context.WithoutCancel is available in Go 1.26
web_search
Go context.WithoutCancel availability versionRepository: compozy/compozy
Length of output: 150
🌐 Web query:
Go context.WithoutCancel availability version 1.21 1.26
💡 Result:
context.WithoutCancel was added in Go 1.21 and is available in Go 1.26. It returns a derived context from the parent that ignores the parent's cancellation: the returned context has no Deadline, no Err, nil Done channel, and context.Cause returns nil. Prior to Go 1.21, it was not in the standard library; users implemented workarounds or waited for the official addition.
Citations:
- 1: context: add WithoutCancel golang/go#40221
- 2: https://boldlygo.tech/archive/2025-07-14-context.withoutcancel/
- 3: https://pkg.go.dev/[email protected]
- 4: https://stackoverflow.com/questions/54128834/context-without-cancel-propagation
- 5: https://pkg.go.dev/context
- 6: https://pkg.go.dev/[email protected]
- 7: https://pkg.go.dev/[email protected]
🏁 Script executed:
# Search for how RunExec is called and understand the context lifecycle
rg "RunExec" -B 5 -A 10 --type go | head -50Repository: compozy/compozy
Length of output: 41
🏁 Script executed:
# Check if there's any context timeout or cancellation mechanism already in place
rg "WithTimeout\|WithCancel\|WithDeadline" internal/core/run/exec/exec.goRepository: compozy/compozy
Length of output: 41
🏁 Script executed:
# Look at the full context to understand when emitTurnResult is called relative to context cancellation
sed -n '550,700p' internal/core/run/exec/exec.goRepository: compozy/compozy
Length of output: 4486
Detach playback from run cancellation to ensure sounds play on terminal states.
When a run is cancelled, submitRuntimeEvent forwards the already-done run context to sound.Notify, which eventually calls exec.CommandContext. A cancelled context immediately terminates the process, so the cancellation sound never plays. Use context.WithoutCancel to detach sound playback from the run's cancellation lifecycle, then add a short timeout to ensure bounded execution.
Suggested fix
func (s *execRunState) submitRuntimeEvent(kind eventspkg.EventKind, payload any) error {
if s == nil {
return nil
}
ctx := s.ctx
if ctx == nil {
ctx = context.Background()
}
if s.journal != nil {
event, err := newRuntimeEvent(s.runArtifacts.RunID, kind, payload)
if err != nil {
return err
}
if err := s.journal.Submit(ctx, event); err != nil {
return err
}
}
- s.notifySoundForKind(ctx, kind)
+ soundCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second)
+ defer cancel()
+ s.notifySoundForKind(soundCtx, kind)
return nil
}📝 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.
| func (s *execRunState) submitRuntimeEvent(kind eventspkg.EventKind, payload any) error { | |
| if s == nil || s.journal == nil { | |
| if s == nil { | |
| return nil | |
| } | |
| ctx := s.ctx | |
| if ctx == nil { | |
| ctx = context.Background() | |
| } | |
| event, err := newRuntimeEvent(s.runArtifacts.RunID, kind, payload) | |
| if err != nil { | |
| return err | |
| if s.journal != nil { | |
| event, err := newRuntimeEvent(s.runArtifacts.RunID, kind, payload) | |
| if err != nil { | |
| return err | |
| } | |
| if err := s.journal.Submit(ctx, event); err != nil { | |
| return err | |
| } | |
| } | |
| s.notifySoundForKind(ctx, kind) | |
| return nil | |
| } | |
| // notifySoundForKind plays the configured sound for a terminal lifecycle | |
| // event kind. It runs synchronously so the audio finishes before the exec | |
| // run state is closed. When the [sound] feature flag is off this is a no-op. | |
| func (s *execRunState) notifySoundForKind(ctx context.Context, kind eventspkg.EventKind) { | |
| if s == nil || s.cfg == nil || !s.cfg.SoundEnabled { | |
| return | |
| } | |
| return s.journal.Submit(ctx, event) | |
| sound.Notify(ctx, sound.Config{ | |
| Player: sound.New(), | |
| OnCompleted: s.cfg.SoundOnCompleted, | |
| OnFailed: s.cfg.SoundOnFailed, | |
| }, kind, slog.Default()) | |
| func (s *execRunState) submitRuntimeEvent(kind eventspkg.EventKind, payload any) error { | |
| if s == nil { | |
| return nil | |
| } | |
| ctx := s.ctx | |
| if ctx == nil { | |
| ctx = context.Background() | |
| } | |
| if s.journal != nil { | |
| event, err := newRuntimeEvent(s.runArtifacts.RunID, kind, payload) | |
| if err != nil { | |
| return err | |
| } | |
| if err := s.journal.Submit(ctx, event); err != nil { | |
| return err | |
| } | |
| } | |
| soundCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second) | |
| defer cancel() | |
| s.notifySoundForKind(soundCtx, kind) | |
| return nil | |
| } | |
| // notifySoundForKind plays the configured sound for a terminal lifecycle | |
| // event kind. It runs synchronously so the audio finishes before the exec | |
| // run state is closed. When the [sound] feature flag is off this is a no-op. | |
| func (s *execRunState) notifySoundForKind(ctx context.Context, kind eventspkg.EventKind) { | |
| if s == nil || s.cfg == nil || !s.cfg.SoundEnabled { | |
| return | |
| } | |
| sound.Notify(ctx, sound.Config{ | |
| Player: sound.New(), | |
| OnCompleted: s.cfg.SoundOnCompleted, | |
| OnFailed: s.cfg.SoundOnFailed, | |
| }, kind, slog.Default()) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@internal/core/run/exec/exec.go` around lines 946 - 978, The cancellation of
the run context prevents sounds from playing; in notifySoundForKind detach
playback from the run cancellation by creating a non-cancellable context (use
context.WithoutCancel(ctx)) and then wrap that in a short timeout
(context.WithTimeout(..., e.g. 3*time.Second)) so playback is bounded; pass the
timeout context into sound.Notify. Update notifySoundForKind (referenced from
submitRuntimeEvent) to construct ctxNoCancel and ctxTimeout, defer cancel(), and
call sound.Notify(ctxTimeout, ...) and ensure time is imported.
| func notifySoundForKind(ctx context.Context, cfg *config, kind events.EventKind, logger *slog.Logger) { | ||
| if cfg == nil || !cfg.SoundEnabled { | ||
| return | ||
| } | ||
| sound.Notify(ctx, sound.Config{ | ||
| Player: sound.New(), | ||
| OnCompleted: cfg.SoundOnCompleted, | ||
| OnFailed: cfg.SoundOnFailed, | ||
| }, kind, logger) |
There was a problem hiding this comment.
Detach terminal sound playback from the run context.
By the time this helper runs, ctx is often already canceled on failed/cancelled shutdown paths, so the notification can be skipped for the very terminal outcomes this feature is meant to cover. Use a short-lived context.WithoutCancel(ctx) timeout here instead of the run context directly.
Suggested fix
func notifySoundForKind(ctx context.Context, cfg *config, kind events.EventKind, logger *slog.Logger) {
if cfg == nil || !cfg.SoundEnabled {
return
}
- sound.Notify(ctx, sound.Config{
+ notifyCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second)
+ defer cancel()
+ sound.Notify(notifyCtx, sound.Config{
Player: sound.New(),
OnCompleted: cfg.SoundOnCompleted,
OnFailed: cfg.SoundOnFailed,
}, kind, logger)
}📝 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.
| func notifySoundForKind(ctx context.Context, cfg *config, kind events.EventKind, logger *slog.Logger) { | |
| if cfg == nil || !cfg.SoundEnabled { | |
| return | |
| } | |
| sound.Notify(ctx, sound.Config{ | |
| Player: sound.New(), | |
| OnCompleted: cfg.SoundOnCompleted, | |
| OnFailed: cfg.SoundOnFailed, | |
| }, kind, logger) | |
| func notifySoundForKind(ctx context.Context, cfg *config, kind events.EventKind, logger *slog.Logger) { | |
| if cfg == nil || !cfg.SoundEnabled { | |
| return | |
| } | |
| notifyCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second) | |
| defer cancel() | |
| sound.Notify(notifyCtx, sound.Config{ | |
| Player: sound.New(), | |
| OnCompleted: cfg.SoundOnCompleted, | |
| OnFailed: cfg.SoundOnFailed, | |
| }, kind, logger) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@internal/core/run/executor/execution.go` around lines 282 - 290,
notifySoundForKind currently passes the run context into sound.Notify which may
already be canceled; change it to create a short-lived, non-cancelable derived
context with a small timeout so sound playback runs even if the parent ctx is
canceled. In notifySoundForKind, wrap the incoming ctx with
context.WithoutCancel(ctx) and then context.WithTimeout(...) (choose a short
duration, e.g. ~1–3s), defer the timeout cancel, and pass that new ctx to
sound.Notify while keeping the existing cfg.SoundEnabled check and sound.Config
usage.
|
@brunomguimaraes just fix the coderabbit review and we can merge it |
Audio cues let operators step away from a long-running ACP session and still know when the model finishes or fails. The feature is opt-in via a new [sound] section in .compozy/config.toml and ships disabled, so it costs nothing for users who do not want it. Sound playback is synchronous at the emission point in both the workflow executor and the exec pipeline (sound.Notify). A subscriber-goroutine design was rejected because cleanup would race the terminal event and silently drop the very sound it was meant to play. Synchronous playback is single-flight, deterministic, and adds no concurrency surface. Cross-platform commands are isolated behind build tags (player_unix.go, player_windows.go) and the os/exec call is wrapped in a commandRunner seam so tests never shell out. Presets resolve to native file paths per OS (afplay on darwin, paplay on linux, powershell on windows); absolute paths bypass preset lookup so users can ship custom sounds.
ded7f4a to
7a4329e
Compare
Done @pedronauck , FYI the pre-existing TestHostAPITasksCreateRoundTripsMethodAndParams race in upstream sdk/extension deserves a separate fix PR regardless of what CodeRabbit says about this, it's unrelated to the sound work. I went ahead and created #104 |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (3)
internal/core/sound/presets_test.go (1)
130-134: Strengthen Windows preset assertion to exact-path validation.The Windows case currently passes on any path containing
"windows", which can mask regressions. SetwantPathexplicitly (C:\Windows\Media\ding.wav) and assert exact equality, same as the Darwin/Linux cases.Also applies to: 163-165
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/core/sound/presets_test.go` around lines 130 - 134, The Windows preset test case for PresetPing should assert the exact resolved path instead of checking for any "windows" substring: add a wantPath field with the literal "C:\\Windows\\Media\\ding.wav" to the test case (the one using preset PresetPing and goos "windows") and change the test assertion to compare equality against wantPath (same exact equality behavior used by the Darwin/Linux cases); apply the same change to the second Windows case referenced around lines 163-165 so both Windows entries validate exact path equality rather than a substring.internal/core/sound/subscriber_test.go (1)
67-68: Remove redundant loop variable captures.Since Go 1.22+, loop variables are scoped per iteration, making
tc := tcunnecessary. The project uses Go 1.26 pergo.mod.♻️ Suggested cleanup
for _, tc := range cases { - tc := tc t.Run(tc.name, func(t *testing.T) {Apply similarly at line 102.
Also applies to: 101-102
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/core/sound/subscriber_test.go` around lines 67 - 68, The test contains redundant loop-variable shadowing like "tc := tc" inside the for-range over "cases" (seen in the test loops in subscriber_test.go); remove those unnecessary reassignments (both occurrences, the one around the first for _, tc := range cases and the similar second loop noted) so the code relies on Go 1.22+ per-iteration scoping and no longer creates shadowed "tc" variables.internal/core/sound/player.go (1)
61-64: Simplify error return.The runner error handling can be simplified to a single return statement.
♻️ Suggested simplification
- if err := p.runner.Run(ctx, name, args...); err != nil { - return err - } - return nil + return p.runner.Run(ctx, name, args...)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/core/sound/player.go` around lines 61 - 64, The error handling in the method can be simplified by returning the runner call directly instead of using an if-check; replace the current if err := p.runner.Run(ctx, name, args...); err != nil { return err } return nil with a single return p.runner.Run(ctx, name, args...) to simplify code in the method where p.runner.Run is invoked.
🤖 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/sound/presets_test.go`:
- Line 36: Subtest names use tc.name and "input "+input which break the
repository rule requiring t.Run("Should...") pattern; update each t.Run
invocation in internal/core/sound/presets_test.go (the calls using tc.name and
the ones that build names from input) to use descriptive "Should ..." titles
(e.g., t.Run("Should return X when Y", func...) ) so every subtest follows the
t.Run("Should...") naming convention — change the t.Run arguments where they
reference tc.name or concatenate "input "+input (and the other occurrences
noted) to explicit "Should..." strings that describe the expected behavior.
---
Nitpick comments:
In `@internal/core/sound/player.go`:
- Around line 61-64: The error handling in the method can be simplified by
returning the runner call directly instead of using an if-check; replace the
current if err := p.runner.Run(ctx, name, args...); err != nil { return err }
return nil with a single return p.runner.Run(ctx, name, args...) to simplify
code in the method where p.runner.Run is invoked.
In `@internal/core/sound/presets_test.go`:
- Around line 130-134: The Windows preset test case for PresetPing should assert
the exact resolved path instead of checking for any "windows" substring: add a
wantPath field with the literal "C:\\Windows\\Media\\ding.wav" to the test case
(the one using preset PresetPing and goos "windows") and change the test
assertion to compare equality against wantPath (same exact equality behavior
used by the Darwin/Linux cases); apply the same change to the second Windows
case referenced around lines 163-165 so both Windows entries validate exact path
equality rather than a substring.
In `@internal/core/sound/subscriber_test.go`:
- Around line 67-68: The test contains redundant loop-variable shadowing like
"tc := tc" inside the for-range over "cases" (seen in the test loops in
subscriber_test.go); remove those unnecessary reassignments (both occurrences,
the one around the first for _, tc := range cases and the similar second loop
noted) so the code relies on Go 1.22+ per-iteration scoping and no longer
creates shadowed "tc" variables.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: c3d192df-466f-45d6-819d-b77542a51d43
⛔ Files ignored due to path filters (1)
skills/compozy/references/config-reference.mdis excluded by!**/*.md
📒 Files selected for processing (21)
internal/cli/state.gointernal/cli/workspace_config.gointernal/core/api.gointernal/core/model/model_test.gointernal/core/model/runtime_config.gointernal/core/run/exec/exec.gointernal/core/run/executor/execution.gointernal/core/run/internal/runshared/config.gointernal/core/sound/player.gointernal/core/sound/player_test.gointernal/core/sound/player_unix.gointernal/core/sound/player_windows.gointernal/core/sound/presets.gointernal/core/sound/presets_test.gointernal/core/sound/runner.gointernal/core/sound/subscriber.gointernal/core/sound/subscriber_test.gointernal/core/workspace/config_test.gointernal/core/workspace/config_types.gointernal/core/workspace/config_validate.gosdk/extension/types.go
✅ Files skipped from review due to trivial changes (1)
- internal/core/run/internal/runshared/config.go
🚧 Files skipped from review as they are similar to previous changes (13)
- sdk/extension/types.go
- internal/core/workspace/config_validate.go
- internal/core/sound/player_windows.go
- internal/core/workspace/config_types.go
- internal/core/workspace/config_test.go
- internal/core/sound/player_test.go
- internal/core/model/runtime_config.go
- internal/core/sound/runner.go
- internal/core/sound/player_unix.go
- internal/core/model/model_test.go
- internal/core/sound/subscriber.go
- internal/core/api.go
- internal/core/sound/presets.go
|
|
||
| for _, tc := range cases { | ||
| tc := tc | ||
| t.Run(tc.name, func(t *testing.T) { |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Use t.Run("Should...") naming for subtests to match repository test policy.
Current subtest names (tc.name, "input "+input) don’t follow the required Should... pattern. Please rename case titles/subtest names accordingly for consistency.
Suggested update
- name: "absolute path is returned verbatim",
+ name: "ShouldReturnAbsolutePathVerbatim",
- name: "trims surrounding whitespace on absolute path",
+ name: "ShouldTrimSurroundingWhitespaceOnAbsolutePath",
- t.Run("input "+input, func(t *testing.T) {
+ t.Run("ShouldReturnErrEmptySoundForWhitespaceOnlyInput", func(t *testing.T) {
- name: "unknown preset returns false",
+ name: "ShouldReturnFalseForUnknownPreset",As per coding guidelines, "MUST use t.Run("Should...") pattern for ALL test cases".
Also applies to: 54-54, 151-151, 218-218
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@internal/core/sound/presets_test.go` at line 36, Subtest names use tc.name
and "input "+input which break the repository rule requiring t.Run("Should...")
pattern; update each t.Run invocation in internal/core/sound/presets_test.go
(the calls using tc.name and the ones that build names from input) to use
descriptive "Should ..." titles (e.g., t.Run("Should return X when Y", func...)
) so every subtest follows the t.Run("Should...") naming convention — change the
t.Run arguments where they reference tc.name or concatenate "input "+input (and
the other occurrences noted) to explicit "Should..." strings that describe the
expected behavior.
- Rename PresetSubmar to PresetSubmarine for naming consistency with
every other preset constant. Internal identifier only; the TOML value
stays "submarine" so user configs do not break.
- Resolve Windows preset paths via os.Getenv("WINDIR") with a
C:\Windows fallback instead of hardcoding the install directory, so
non-standard Windows layouts work without users having to fall back
to absolute paths. Split into windowsPresetFiles (basenames) and
windowsPresetPath helper so both branches can be unit-tested from
any host OS.
- Simplify osPlayer.Play to return p.runner.Run(...) directly instead
of wrapping it in an err-check that only re-returns.
- Fold the three TestLoadConfig sound tests into a single table-driven
TestLoadConfigSoundSection per coding guidelines, and add a fourth
case covering the whitespace-only on_failed rejection branch.
- Convert TestNoop_Play_AlwaysNil to table-driven with Should-style
subtest names so it follows the same pattern as the rest of the file.
- Replace the trivial TestNew_ReturnsNonNilPlayer constructor
nil-check with TestNew_WiresPlatformPlayer, which exercises the
real resolver and asserts that darwin gets afplay, linux gets
paplay, and unsupported platforms fall back to Noop.
- Drop the now-redundant tc := tc loop variable captures; Go 1.22+
captures per-iteration automatically and this project is on 1.26.
Summary
Adds optional audio cues that play when an ACP run reaches a terminal state — a small ergonomic win for operators who step away from long-running sessions and want to know without checking the terminal whether the model finished cleanly or failed.
The feature is opt-in via a new
[sound]section in.compozy/config.tomland ships disabled by default, so it costs nothing for users who don't want it.sound.mp4
Presets resolve to platform-native files at play time (
afplayon darwin,paplayon linux,powershellon windows). Absolute paths bypass preset lookup so users can ship custom sounds.Why
Design notes
Synchronous playback at the emission point. The first iteration used a goroutine subscribed to the run event bus, but cleanup raced the terminal event and could silently drop the very sound it was meant to play. The final design exposes
sound.Notify(ctx, cfg, kind, logger)which plays on the caller's goroutine right afteremitRunTerminalEvent(executor) andsubmitRuntimeEvent(exec). It blocks for ~500ms on the audio command, then returns. Single-flight, deterministic, no concurrency surface.Cross-platform isolation via build tags.
player_unix.go(//go:build !windows) selectsafplay/paplaybased onruntime.GOOS;player_windows.goshells out topowershell+System.Media.SoundPlayer. Other unix variants gracefully fall back to aNoopplayer.Test seam.
os/execis hidden behind acommandRunnerinterface so unit tests never shell out — they assert the resolved command name and args without producing audio.TOML-only for v1. No CLI flag was added; per-project sound feels like a project preference rather than a per-invocation toggle. Easy to add later if there's demand.
Files touched
internal/core/sound/{player,runner,presets,player_unix,player_windows,subscriber}.go(+ tests)internal/core/workspace/config_types.go,config_validate.go,config_test.gointernal/core/model/runtime_config.go,model_test.gointernal/core/run/executor/execution.go(synchronousnotifySoundForKindafteremitRunTerminalEvent)internal/core/run/exec/exec.go(notifySoundForKindinsidesubmitRuntimeEvent)internal/core/run/internal/runshared/config.go,internal/core/api.go,internal/cli/state.go,internal/cli/workspace_config.gosdk/extension/types.go(mirrorsmodel.RuntimeConfigper the existingcompat_testcontract)Test plan
make verifypasses (1848 tests, 0 lint, build green)Notifyfor every terminal kind, silent kinds, nil player, nil context, playback errors logged not panickedTestRuntimeConfigApplyDefaultsto cover the sound default branch (presets only fill whenSoundEnabledis true; explicit values are preserved)TestLoadConfigcases for[sound]parsing, default-disabled state, and empty-string rejectioncompozy exec --dry-run --tui=false "hello"plays Glass on darwincompozy exec --tui --dry-run "hello"plays Glass with the cockpit visibleenabled = falseor the section is absentpaplay+ freedesktop sounds) — only tested darwinNot included
Summary by CodeRabbit