diff --git a/internal/app/app.go b/internal/app/app.go index c15d2433..0179ca2e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -78,6 +78,13 @@ type App struct { // (~1 frame) so new updates are always let through once the TUI has had a // chance to process the pending event. widgetUpdatePending atomic.Bool + + // steerDrainFn is the test seam used by releaseBusyAfterCompact to pull + // any steer messages that arrived during compaction. In production it is + // nil and the helper falls back to a.opts.Kit.DrainSteer(); tests that + // need to exercise the steer-drain path without standing up a full + // *kit.Kit can set this field directly to inject fake items. + steerDrainFn func() []queueItem } // New creates a new App with the provided options and pre-loaded messages. @@ -503,9 +510,14 @@ func (a *App) CompactAsync(customInstructions string, onComplete func(), onError // fresh drainQueue goroutine to deliver them as a single batched turn. func (a *App) releaseBusyAfterCompact() { // Pull steer messages outside the app mutex; DrainSteer takes its own - // internal lock and we don't want to nest the two. + // internal lock and we don't want to nest the two. The test seam + // (a.steerDrainFn) takes precedence so unit tests can inject fake + // steer items without a real *kit.Kit. var steerItems []queueItem - if a.opts.Kit != nil { + switch { + case a.steerDrainFn != nil: + steerItems = a.steerDrainFn() + case a.opts.Kit != nil: if leftover := a.opts.Kit.DrainSteer(); len(leftover) > 0 { steerItems = make([]queueItem, len(leftover)) for i, sm := range leftover { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index f6582e95..645dfe7b 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -850,6 +850,95 @@ func TestReleaseBusyAfterCompact_idleWhenQueueEmpty(t *testing.T) { } } +// TestReleaseBusyAfterCompact_splicesSteerAheadOfQueue exercises the SDK +// steer-drain branch of releaseBusyAfterCompact (issue #27 follow-up). +// +// Production wires a.opts.Kit.DrainSteer() to pull messages that arrived via +// Steer/SteerWithFiles during compaction, but Options.Kit is *kit.Kit (a +// concrete struct) so unit tests cannot stand up a real instance without a +// full LLM backend. The test uses the unexported steerDrainFn seam to inject +// fake steer items, then asserts that: +// +// - Steer items are dispatched ahead of any prompts that piled up in +// a.queue (steer retains "act now" priority over ordinary queued +// prompts), and +// - the helper still hands off to drainQueue so the steer item actually +// fires (the previous behaviour left them stranded — see #27). +func TestReleaseBusyAfterCompact_splicesSteerAheadOfQueue(t *testing.T) { + var pmu sync.Mutex + var firstPrompt string + stub := newStubWithFuncs( + func(ctx context.Context) (*kit.TurnResult, error) { + return turnResult("steer dispatched"), nil + }, + ) + // Wrap PromptFunc so we can capture the prompt text the stub receives + // (newStubWithFuncs's fns ignore prompt; we need it to verify ordering). + capturingPrompt := func(ctx context.Context, prompt string) (*kit.TurnResult, error) { + pmu.Lock() + if firstPrompt == "" { + firstPrompt = prompt + } + pmu.Unlock() + return stub.fn(ctx, prompt) + } + app := New(Options{PromptFunc: capturingPrompt}, nil) + defer app.Close() + + // Inject fake steer items via the test seam. In production the same + // items would have been delivered through Kit.InjectSteerWithFiles + // during /compact and pulled by DrainSteer here. + app.steerDrainFn = func() []queueItem { + return []queueItem{ + {Prompt: "steer-1"}, + {Prompt: "steer-2"}, + } + } + + // Simulate the state at the end of compaction: busy is set and a couple + // of regular Run() prompts have piled up after the steer messages. + app.mu.Lock() + app.busy = true + app.queue = append(app.queue, + queueItem{Prompt: "queued-1"}, + queueItem{Prompt: "queued-2"}, + ) + app.mu.Unlock() + + app.releaseBusyAfterCompact() + + // Wait for the dispatched batch to complete. + ok := waitForCondition(2*time.Second, func() bool { + app.mu.Lock() + defer app.mu.Unlock() + return !app.busy + }) + if !ok { + t.Fatal("app did not become idle after steer-spliced releaseBusyAfterCompact") + } + app.wg.Wait() + + // drainQueue picks up `first` directly and batches the rest. With + // PromptFunc set, executeBatch invokes us with items[0] only — that + // item must be the first steer message, proving steer items were + // spliced ahead of the previously queued prompts. + pmu.Lock() + got := firstPrompt + pmu.Unlock() + if got != "steer-1" { + t.Fatalf("expected first dispatched prompt to be steer item %q (steer items must come before queued prompts), got %q", + "steer-1", got) + } + + // Queue should be fully drained and PromptFunc must have actually fired. + if n := app.QueueLength(); n != 0 { + t.Fatalf("expected empty queue after drain, got %d entries", n) + } + if n := stub.callCount(); n == 0 { + t.Fatal("expected stub PromptFunc to fire at least once after splice") + } +} + // TestReleaseBusyAfterCompact_dropsQueueWhenClosed verifies that if the app // was closed during compaction the helper discards any pending items rather // than spawning drainQueue against a torn-down App.