diff --git a/apps/backend/cmd/mock-agent/background_test.go b/apps/backend/cmd/mock-agent/background_test.go index c2d29b007e..5a37cbce68 100644 --- a/apps/backend/cmd/mock-agent/background_test.go +++ b/apps/backend/cmd/mock-agent/background_test.go @@ -103,3 +103,25 @@ func TestAsyncSubagentForegroundFramesMatchClaudeOrdering(t *testing.T) { t.Fatalf("fifth update must be final same-prompt assistant output, got %#v", got[4]) } } + +func TestBackgroundWorkEmitsForegroundIdleBoundary(t *testing.T) { + e, updates := newTestEmitter() + emitBackgroundWork(e, "/background 1ms") + + got := updates.getUpdates() + idleIndex := -1 + for i, update := range got { + usage := update.notification.Update.UsageUpdate + if usage == nil { + continue + } + origin := usage.Meta[claudeOriginMetaKey].(map[string]any) + if origin["kind"] == claudeOriginHuman { + idleIndex = i + break + } + } + if idleIndex < 0 { + t.Fatal("/background must emit a human-origin foreground-idle boundary") + } +} diff --git a/apps/backend/cmd/mock-agent/handler.go b/apps/backend/cmd/mock-agent/handler.go index 6f113c33bb..b3aac71cba 100644 --- a/apps/backend/cmd/mock-agent/handler.go +++ b/apps/backend/cmd/mock-agent/handler.go @@ -381,6 +381,7 @@ func emitBackgroundWork(e *emitter, cmd string) { "Background exploration", "Explore the codebase while the foreground stays idle", "general-purpose") + e.foregroundIdle() // Hold the turn open with NO foreground output so the session stays in the // background-idle substate for the whole window. diff --git a/apps/backend/internal/backendapp/orchestrator.go b/apps/backend/internal/backendapp/orchestrator.go index 9da8aa7b1b..92d4b3e36d 100644 --- a/apps/backend/internal/backendapp/orchestrator.go +++ b/apps/backend/internal/backendapp/orchestrator.go @@ -72,6 +72,8 @@ func provideOrchestrator( agentManagerClient := newLifecycleAdapter(lifecycleMgr, agentRegistry, log) serviceCfg := orchestrator.DefaultServiceConfig() + serviceCfg.ClaudeBackgroundPromptHandoff = + cfg != nil && cfg.Features.ClaudeBackgroundPromptHandoff namespace := resolveEventNamespace(cfg) serviceCfg.QueueGroup = "orchestrator." + namespace busMode := "memory" diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index 14c7e8b35e..c484cf1347 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -397,6 +397,12 @@ type FeaturesConfig struct { // Toggles). Off by default so kandev stays single-user until a deployment // opts in. Auth bool `mapstructure:"auth" json:"auth"` + + // ClaudeBackgroundPromptHandoff gates the high-risk experiment that lets a + // claude-acp session accept a successor prompt after an adapter-attested + // foreground handoff while background work remains live. It is off in every + // embedded profile and must fail closed for every other provider. + ClaudeBackgroundPromptHandoff bool `mapstructure:"claude_background_prompt_handoff" json:"claudeBackgroundPromptHandoff"` } // LoggingConfig holds logging configuration. diff --git a/apps/backend/internal/common/config/config_test.go b/apps/backend/internal/common/config/config_test.go index 29c8a7cb4e..870c8ee6a3 100644 --- a/apps/backend/internal/common/config/config_test.go +++ b/apps/backend/internal/common/config/config_test.go @@ -228,6 +228,18 @@ func TestFeatures_PluginsEnabledByEnv(t *testing.T) { } } +func TestFeatures_ClaudeBackgroundPromptHandoffEnabledByEnv(t *testing.T) { + t.Setenv("KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF", "true") + + cfg, err := LoadWithPath(t.TempDir()) + if err != nil { + t.Fatalf("LoadWithPath: %v", err) + } + if !cfg.Features.ClaudeBackgroundPromptHandoff { + t.Error("Features.ClaudeBackgroundPromptHandoff = false, want true") + } +} + func TestFeatures_AppStatusBarDisabledByEnv(t *testing.T) { t.Setenv("KANDEV_FEATURES_APP_STATUS_BAR", "false") @@ -422,13 +434,18 @@ func TestServerHostsFromConfigWhenHostUnset(t *testing.T) { // without a tag) would surface as a capitalized JSON key and break the // frontend's case-sensitive read in apps/web/app/actions/features.ts. func TestFeaturesConfig_JSONShape(t *testing.T) { - cfg := FeaturesConfig{Office: true, Plugins: true, AppStatusBar: true, Auth: true} + cfg := FeaturesConfig{ + Office: true, + Plugins: true, + AppStatusBar: true, + Auth: true, + } raw, err := json.Marshal(cfg) if err != nil { t.Fatalf("json.Marshal: %v", err) } got := string(raw) - want := `{"office":true,"plugins":true,"appStatusBar":true,"auth":true}` + want := `{"office":true,"plugins":true,"appStatusBar":true,"auth":true,"claudeBackgroundPromptHandoff":false}` if got != want { t.Errorf("FeaturesConfig JSON = %s; want %s — missing or wrong `json:` struct tag", got, want) } diff --git a/apps/backend/internal/orchestrator/background_work_accounting_test.go b/apps/backend/internal/orchestrator/background_work_accounting_test.go index b81a85053e..57ef8b8598 100644 --- a/apps/backend/internal/orchestrator/background_work_accounting_test.go +++ b/apps/backend/internal/orchestrator/background_work_accounting_test.go @@ -33,7 +33,7 @@ func (b *blockingRetirementEventBus) Publish( ) error { if subject == eventtypes.TaskSessionActivityChanged { data, _ := event.Data.(map[string]interface{}) - if value, present := data["foreground_activity"]; present && value == nil { + if count, present := data["active_subagent_count"]; present && count == 0 { b.once.Do(func() { close(b.retirementEntered) }) <-b.releaseRetirement } @@ -99,7 +99,7 @@ func TestActiveSubagentCount_DerivesOnlyLiveSubagentRegistrations(t *testing.T) } } -func TestBackgroundCompletion_IntermediateSubagentPublishesBackground(t *testing.T) { +func TestBackgroundCompletion_IntermediateSubagentPublishesGenerating(t *testing.T) { repo := setupTestRepo(t) const taskID, sessionID, executionID = "task-intermediate", "session-intermediate", "execution-intermediate" seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning) @@ -126,14 +126,86 @@ func TestBackgroundCompletion_IntermediateSubagentPublishesBackground(t *testing t.Fatalf("published events = %d, want 1", len(recorded.events)) } data, _ := recorded.events[0].event.Data.(map[string]interface{}) - if got := data["foreground_activity"]; got != string(v1.ForegroundActivityBackground) { - t.Fatalf("published foreground activity = %#v, want background", got) + if got := data["foreground_activity"]; got != string(v1.ForegroundActivityGenerating) { + t.Fatalf("published foreground activity = %#v, want generating", got) } if got := data["active_subagent_count"]; got != 1 { t.Fatalf("published active subagent count = %#v, want 1", got) } } +func TestBackgroundCompletion_SettledSessionClearsActivityForCountOnlyUpdate(t *testing.T) { + repo := setupTestRepo(t) + const taskID, sessionID, executionID = "task-settled-intermediate", "session-settled-intermediate", "execution-settled-intermediate" + seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateWaitingForInput) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + recorded := &recordingEventBus{} + svc.eventBus = recorded + svc.registerBackgroundWorkKind( + sessionID, "tool-one", executionID, "child-one", streams.BackgroundWorkKindSubagent, + ) + svc.registerBackgroundWorkKind( + sessionID, "tool-two", executionID, "child-two", streams.BackgroundWorkKindSubagent, + ) + svc.markForegroundIdle(sessionID) + + publication, changed := svc.completeBackgroundWorkSnapshot( + sessionID, executionID, "child-one", string(v1.ForegroundActivityBackground), + ) + if !changed { + t.Fatal("intermediate settled completion must publish the count-only transition") + } + svc.publishForegroundActivitySnapshot(t.Context(), taskID, sessionID, publication) + + if len(recorded.events) != 1 { + t.Fatalf("published events = %d, want 1", len(recorded.events)) + } + data, _ := recorded.events[0].event.Data.(map[string]interface{}) + if got := data["foreground_activity"]; got != nil { + t.Fatalf("settled session activity = %#v, want nil", got) + } + if got := data["active_subagent_count"]; got != 1 { + t.Fatalf("settled session active subagent count = %#v, want 1", got) + } +} + +func TestBackgroundCompletion_EnabledClaudeSettledSessionPublishesBackground(t *testing.T) { + repo := setupTestRepo(t) + const taskID, sessionID, executionID = "task-settled-claude", "session-settled-claude", "execution-settled-claude" + seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateWaitingForInput) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + enableClaudeBackgroundPromptHandoffForTest(t, svc) + setSessionAgentNameForTest(t, svc, sessionID, "claude-acp") + recorded := &recordingEventBus{} + svc.eventBus = recorded + svc.registerBackgroundWorkKind( + sessionID, "tool-one", executionID, "child-one", streams.BackgroundWorkKindSubagent, + ) + svc.registerBackgroundWorkKind( + sessionID, "tool-two", executionID, "child-two", streams.BackgroundWorkKindSubagent, + ) + svc.markForegroundIdle(sessionID) + + publication, changed := svc.completeBackgroundWorkSnapshot( + sessionID, executionID, "child-one", string(v1.ForegroundActivityBackground), + ) + if !changed { + t.Fatal("intermediate enabled Claude completion must publish the count-only transition") + } + svc.publishForegroundActivitySnapshot(t.Context(), taskID, sessionID, publication) + + if len(recorded.events) != 1 { + t.Fatalf("published events = %d, want 1", len(recorded.events)) + } + data, _ := recorded.events[0].event.Data.(map[string]interface{}) + if got := data["foreground_activity"]; got != string(v1.ForegroundActivityBackground) { + t.Fatalf("enabled Claude settled activity = %#v, want background", got) + } + if got := data["active_subagent_count"]; got != 1 { + t.Fatalf("enabled Claude settled active subagent count = %#v, want 1", got) + } +} + func TestBackgroundCompletion_IdentifiedRetiresExactWorkAndDuplicateIsHarmless(t *testing.T) { svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo()) const taskID, sessionID, executionID = "task-accounting", "session-accounting", "execution-accounting" @@ -201,7 +273,7 @@ func TestBackgroundCompletion_UnidentifiedRetiresOldestOfMultipleWorkloadsFIFO(t if !svc.hasBackgroundTask(sessionID, "tool-two") { t.Fatal("unidentified completion must leave the remaining workload live") } - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground { t.Fatalf("completion with a live remainder changed visible activity to %q", got) } } @@ -241,7 +313,7 @@ func TestBackgroundCompletion_TwoUnidentifiedCompletionsRetireBothWorkloadsFIFO( if !svc.hasBackgroundTask(sessionID, "tool-second") { t.Fatal("first ID-less completion retired the second-registered workload out of FIFO order") } - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground { t.Fatalf("session left background-idle with a live remainder, got %q", got) } @@ -251,7 +323,7 @@ func TestBackgroundCompletion_TwoUnidentifiedCompletionsRetireBothWorkloadsFIFO( if svc.hasBackgroundTask(sessionID, "tool-second") { t.Fatal("second ID-less completion did not retire the last remaining workload") } - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityGenerating { t.Fatalf("session did not leave background after final completion, got %q", got) } } @@ -279,18 +351,18 @@ func TestBackgroundCompletion_UnidentifiedSuccessorCycleRetiresSoleSessionWork(t t.Fatal("ID-less successor-cycle notification did not retire sole session workload") } - activityClears := 0 + activityUpdates := 0 for _, record := range recorded.events { if record.subject != eventtypes.TaskSessionActivityChanged { continue } data, _ := record.event.Data.(map[string]interface{}) - if value, present := data["foreground_activity"]; present && value == nil { - activityClears++ + if count, present := data["active_subagent_count"]; present && count == 0 { + activityUpdates++ } } - if activityClears != 1 || len(taskEvents.activityTaskIDs) != 2 { - t.Fatalf("sole completion clear cardinality: session=%d task=%v", activityClears, taskEvents.activityTaskIDs) + if activityUpdates != 1 || len(taskEvents.activityTaskIDs) != 2 { + t.Fatalf("sole completion update cardinality: session=%d task=%v", activityUpdates, taskEvents.activityTaskIDs) } // The same ID-less notification re-delivered after retirement is a no-op. @@ -385,7 +457,7 @@ func TestExecutionTeardown_StaleToolOwnershipIsNotVisibleToReusedSuccessorID(t * }, }) - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground { t.Fatalf("stale predecessor tool ownership changed successor activity to %q", got) } } @@ -477,7 +549,7 @@ func TestExecutionStop_RetiresOnlyOwnedBackgroundWorkAndPublishesFinalTransition ); got != toolOwnershipChild { t.Fatalf("successor tool ownership = %d, want child", got) } - if got := svc.ForegroundActivity("session-stop-accounting"); got != v1.ForegroundActivityBackground { + if got := svc.foregroundActivityValue("session-stop-accounting"); got != v1.ForegroundActivityBackground { t.Fatalf("successor background work should remain visible, got %q", got) } @@ -487,7 +559,7 @@ func TestExecutionStop_RetiresOnlyOwnedBackgroundWorkAndPublishesFinalTransition if svc.hasBackgroundTask("session-stop-accounting", "tool-new") { t.Fatal("final stopped execution left its background registration behind") } - if got := svc.ForegroundActivity("session-stop-accounting"); got != v1.ForegroundActivityGenerating { + if got := svc.foregroundActivityValue("session-stop-accounting"); got != v1.ForegroundActivityGenerating { t.Fatalf("final execution cleanup left stale background activity: %q", got) } if _, ok := turnActivityRecord(t, svc, "session-stop-accounting"); ok { @@ -507,7 +579,7 @@ func TestExecutionStop_RetiresOnlyOwnedBackgroundWorkAndPublishesFinalTransition activityValues = append(activityValues, data["foreground_activity"]) subagentCounts = append(subagentCounts, data["active_subagent_count"].(int)) } - wantValues := []interface{}{"generating", "generating", "background", nil} + wantValues := []interface{}{"generating", "generating", "generating", nil} if !slices.Equal(activityValues, wantValues) { t.Fatalf("execution cleanup activity values = %#v, want %#v", activityValues, wantValues) } @@ -596,8 +668,8 @@ func TestExecutionCleanup_DelayedPublicationCannotOverwriteSuccessorActivity(t * data, _ := record.event.Data.(map[string]interface{}) values = append(values, data["foreground_activity"]) } - if len(values) != 1 || values[0] != string(v1.ForegroundActivityBackground) { - t.Fatalf("activity publications = %#v, want only successor background", values) + if len(values) != 1 || values[0] != nil { + t.Fatalf("activity publications = %#v, want only successor clear", values) } if len(taskEvents.activityTaskIDs) != 1 { t.Fatalf("task aggregate publications = %v, want successor only", taskEvents.activityTaskIDs) @@ -666,7 +738,10 @@ func TestExecutionCleanup_PublicationDeliveryIsOrderedAcrossRecordGenerations(t values = append(values, data["foreground_activity"]) counts = append(counts, data["active_subagent_count"].(int)) } - want := []interface{}{nil, string(v1.ForegroundActivityBackground)} + want := []interface{}{ + nil, + nil, + } if !slices.Equal(values, want) { t.Fatalf("cross-generation publications = %#v, want %#v", values, want) } @@ -817,8 +892,8 @@ func TestExecutionCleanup_DelayedNullCannotOverwriteClaimlessSuccessorStart(t *t values = append(values, data["foreground_activity"]) } } - if len(values) != 1 || values[0] != string(v1.ForegroundActivityGenerating) { - t.Fatalf("activity publications = %#v, want only successor generating", values) + if len(values) != 1 || values[0] != nil { + t.Fatalf("activity publications = %#v, want only successor clear", values) } } @@ -861,8 +936,8 @@ func TestBackgroundCompletion_IDLessSingletonDelayedNullCannotOverwriteSuccessor values = append(values, data["foreground_activity"]) } } - if len(values) != 1 || values[0] != string(v1.ForegroundActivityGenerating) { - t.Fatalf("activity publications = %#v, want only successor generating", values) + if len(values) != 1 || values[0] != nil { + t.Fatalf("activity publications = %#v, want only successor clear", values) } } @@ -905,10 +980,10 @@ func TestExecutionCleanup_AfterClaimlessBeginCannotCreateSuccessorNull(t *testin values = append(values, data["foreground_activity"]) } } - if len(values) != 1 || values[0] != string(v1.ForegroundActivityGenerating) { - t.Fatalf("activity publications = %#v, want only successor generating", values) + if len(values) != 1 || values[0] != nil { + t.Fatalf("activity publications = %#v, want only successor clear", values) } - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityGenerating { t.Fatalf("cleanup after begin displaced successor ownership: got %q", got) } } @@ -952,10 +1027,10 @@ func TestBackgroundCompletion_AfterClaimlessBeginCannotCreateSuccessorNull(t *te values = append(values, data["foreground_activity"]) } } - if len(values) != 1 || values[0] != string(v1.ForegroundActivityGenerating) { - t.Fatalf("activity publications = %#v, want only successor generating", values) + if len(values) != 1 || values[0] != nil { + t.Fatalf("activity publications = %#v, want only successor clear", values) } - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityGenerating { t.Fatalf("completion after begin displaced successor ownership: got %q", got) } } @@ -1026,25 +1101,29 @@ func TestStopSessionPathPublishesStateAndTeardownActivityClears(t *testing.T) { TaskID: taskID, SessionID: sessionID, AgentExecutionID: executionID, }) - var stateClears, activityClears int + var stateClears, activityRetirements int for _, record := range recorded.events { data, ok := record.event.Data.(map[string]interface{}) if !ok { continue } - value, present := data["foreground_activity"] - if !present || value != nil { - continue - } switch record.subject { case eventtypes.TaskSessionStateChanged: - stateClears++ + if value, present := data["foreground_activity"]; present && value == nil { + stateClears++ + } case eventtypes.TaskSessionActivityChanged: - activityClears++ + if count, present := data["active_subagent_count"]; present && count == 0 { + activityRetirements++ + } } } - if stateClears != 1 || activityClears != 1 { - t.Fatalf("session.stop clear cardinality: state=%d activity=%d, want 1/1", stateClears, activityClears) + if stateClears != 1 || activityRetirements != 1 { + t.Fatalf( + "session.stop retirement cardinality: state=%d activity=%d, want 1/1", + stateClears, + activityRetirements, + ) } // Two task-aggregate recomputes: the teardown activity clear, plus the // republish when the session leaves RUNNING (republishTaskActivityOnSettle), @@ -1131,7 +1210,7 @@ func countActivityClears(recorded *recordingEventBus) int { continue } data, _ := record.event.Data.(map[string]interface{}) - if value, present := data["foreground_activity"]; present && value == nil { + if count, present := data["active_subagent_count"]; present && count == 0 { clears++ } } @@ -1356,7 +1435,7 @@ func TestBackgroundActivity_ClaudeMetadataOnlyChildUpdateDoesNotReopenForeground svc.registerBackgroundTask(sessionID, "async-agent") svc.markForegroundIdle(sessionID) - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground { t.Fatalf("precondition activity = %q, want background", got) } @@ -1373,7 +1452,7 @@ func TestBackgroundActivity_ClaudeMetadataOnlyChildUpdateDoesNotReopenForeground }, }) - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground { t.Fatalf("metadata-only child update changed activity to %q, want background", got) } } @@ -1413,7 +1492,7 @@ func TestBackgroundActivity_ClaudeCachedChildUpdateDoesNotReopenForeground(t *te }, }) - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground { t.Fatalf("cached child update changed activity to %q, want background", got) } } @@ -1435,7 +1514,7 @@ func TestBackgroundActivity_UnknownToolUpdatePreservesActivity(t *testing.T) { }, }) - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground { t.Fatalf("unknown tool update changed activity to %q, want background", got) } } @@ -1475,7 +1554,7 @@ func TestBackgroundWork_RegisteredFromInitialToolCallIsRetiredOnExecutionTeardow }, }) svc.markForegroundIdle(sessionID) - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground { t.Fatalf("initial tool_call registration did not surface as background, got %q", got) } @@ -1486,7 +1565,7 @@ func TestBackgroundWork_RegisteredFromInitialToolCallIsRetiredOnExecutionTeardow if svc.hasBackgroundTask(sessionID, "tool-initial-call") { t.Fatal("execution teardown left the initial tool_call registration behind") } - if got := svc.ForegroundActivity(sessionID); got == v1.ForegroundActivityBackground { + if got := svc.foregroundActivityValue(sessionID); got == v1.ForegroundActivityBackground { t.Fatalf("session still reports background after owning execution died, got %q", got) } } diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go index b503512a07..a3e779601e 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go @@ -456,10 +456,8 @@ func (s *Service) handleStreamingEventKind( // handleMessageStreamingEvent handles streaming message events for real-time text updates. // It creates a new message on first chunk (IsAppend=false) or appends to existing (IsAppend=true). func (s *Service) handleMessageStreamingEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { - // Streamed foreground output means the agent is actively generating again, - // even if a background task is still outstanding — narrows the busy signal. - // Only genuine output flips the state: an empty/invalid frame is discarded by - // handleStreamingEventKind, so it must not spuriously reclose the prompt gate. + // Keep the private ownership estimate current for accounting. Only genuine + // output flips it; empty/invalid frames are discarded below. if payload.Data.Text != "" && s.markForegroundGenerating(payload.SessionID, payload.ExecutionID) { s.publishForegroundActivityChanged(ctx, payload.TaskID, payload.SessionID) } @@ -471,9 +469,8 @@ func (s *Service) handleMessageStreamingEvent(ctx context.Context, payload *life // handleThinkingStreamingEvent handles streaming thinking events for real-time reasoning updates. // It creates a new thinking message on first chunk (IsAppend=false) or appends to existing (IsAppend=true). func (s *Service) handleThinkingStreamingEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { - // Streamed foreground reasoning means the agent is actively generating again. - // Only genuine output flips the state — an empty/invalid frame is discarded - // downstream, so it must not spuriously reclose the prompt gate. + // Keep the private ownership estimate current for accounting. Empty/invalid + // frames are discarded downstream. if payload.Data.Text != "" && s.markForegroundGenerating(payload.SessionID, payload.ExecutionID) { s.publishForegroundActivityChanged(ctx, payload.TaskID, payload.SessionID) } @@ -514,10 +511,8 @@ func (s *Service) handleToolUpdateEvent(ctx context.Context, payload *lifecycle. s.publishForegroundActivityChanged(ctx, payload.TaskID, payload.SessionID) } - // Background-work bookkeeping for the finer-grained busy signal runs - // regardless of message persistence — mirrors handleToolCallEvent — so a - // run-in-background shell or Monitor watch still drives the prompt gate even - // when no messageCreator is wired (tests, minimal configs). + // Background-work bookkeeping runs regardless of message persistence so + // accounting remains available even when no messageCreator is wired. s.trackBackgroundToolUpdate(ctx, payload, ownership) s.persistToolUpdateMessage(ctx, payload) @@ -593,9 +588,9 @@ func (s *Service) persistToolUpdateMessage(ctx context.Context, payload *lifecyc } } -// trackBackgroundToolUpdate maintains the fine-grained busy signal's background -// hold from a top-level tool_call_update: a terminal status clears the hold and -// the first recognizable non-terminal frame registers it. Child tool calls +// trackBackgroundToolUpdate maintains best-effort background accounting from a +// top-level tool_call_update: a terminal status clears the hold and the first +// recognizable non-terminal frame registers it. Child tool calls // (ParentToolCallID set) are a subagent's own internal work, not a new // background task, so they never touch the hold. func (s *Service) trackBackgroundToolUpdate( @@ -1099,8 +1094,12 @@ func (s *Service) publishTaskSessionStateChanged( } } var foregroundActivity interface{} - if nextState == models.TaskSessionStateRunning || nextState == models.TaskSessionStateWaitingForInput { - foregroundActivity = string(s.foregroundActivityValue(sessionID)) + if nextState == models.TaskSessionStateRunning { + foregroundActivity = string(s.ForegroundActivity(sessionID)) + } else if activity := s.ForegroundActivity(sessionID); activity == v1.ForegroundActivityBackground { + // Only the enabled Claude experiment can return background here. + // Preserve detached-work visibility as the coarse foreground settles. + foregroundActivity = string(activity) } eventData := map[string]interface{}{ metaKeyTaskID: taskID, @@ -1111,9 +1110,9 @@ func (s *Service) publishTaskSessionStateChanged( metaKeyAgentProfileID: agentProfileID, "agent_profile_snapshot": session.AgentProfileSnapshot, "is_passthrough": session.IsPassthrough, - // Carry activity only while the session can own foreground/background - // work. Every other state gets an explicit null so partial client-store - // merges clear a previously-live substate during session.stop/teardown. + // Carry activity only while the durable session is RUNNING. Every other + // state gets an explicit null so partial client-store merges clear a + // previously-live busy signal during settlement or teardown. "foreground_activity": foregroundActivity, "active_subagent_count": s.ActiveSubagentCount(sessionID), } diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming_test.go b/apps/backend/internal/orchestrator/event_handlers_streaming_test.go index 8011ebf2ac..6c996c863f 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming_test.go @@ -271,6 +271,34 @@ func TestUpdateTaskSessionStatePublishesPersistedUpdatedAt(t *testing.T) { require.Equal(t, session.UpdatedAt.UTC().Format(time.RFC3339Nano), data["updated_at"]) } +func TestUpdateTaskSessionState_EnabledClaudePublishesSettledBackground(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task-state-claude", "session-state-claude", "step1") + eb := &recordingEventBus{} + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + enableClaudeBackgroundPromptHandoffForTest(t, svc) + setSessionAgentNameForTest(t, svc, "session-state-claude", "claude-acp") + svc.eventBus = eb + svc.registerBackgroundTask("session-state-claude", "background-1") + svc.markForegroundIdle("session-state-claude") + + svc.updateTaskSessionState( + ctx, + "task-state-claude", + "session-state-claude", + models.TaskSessionStateWaitingForInput, + "", + false, + ) + + require.Len(t, eb.events, 1) + require.Equal(t, events.TaskSessionStateChanged, eb.events[0].subject) + data, ok := eb.events[0].event.Data.(map[string]interface{}) + require.True(t, ok) + require.Equal(t, string(v1.ForegroundActivityBackground), data["foreground_activity"]) +} + func TestTransitionTaskSessionStateReportsAcceptedWrite(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) diff --git a/apps/backend/internal/orchestrator/foreground_activity_freshload_test.go b/apps/backend/internal/orchestrator/foreground_activity_freshload_test.go index 60971e0102..4c8b3a029d 100644 --- a/apps/backend/internal/orchestrator/foreground_activity_freshload_test.go +++ b/apps/backend/internal/orchestrator/foreground_activity_freshload_test.go @@ -64,7 +64,7 @@ func TestFreshLoad_SettledSessionOmitsSubstate(t *testing.T) { } } -func TestFreshLoad_SettledSessionWithDetachedWorkSerializesBackground(t *testing.T) { +func TestFreshLoad_SettledSessionWithDetachedWorkOmitsActivity(t *testing.T) { repo := setupTestRepo(t) svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) @@ -73,15 +73,15 @@ func TestFreshLoad_SettledSessionWithDetachedWorkSerializesBackground(t *testing svc.markForegroundIdle(sessionID) sessionDTO := &taskdto.TaskSessionDTO{ID: sessionID, State: models.TaskSessionStateWaitingForInput} taskdto.EnrichForegroundActivity(sessionDTO, svc) - if got := marshalField(t, sessionDTO); got != string(v1.ForegroundActivityBackground) { - t.Fatalf("settled detached session foreground_activity: got %q, want background", got) + if body := marshalDTO(t, sessionDTO); strings.Contains(body, "foreground_activity") { + t.Fatalf("settled detached session must omit foreground_activity, got %s", body) } taskDTO := &taskdto.TaskDTO{ID: "task-detached"} sessions := []*models.TaskSession{{ID: sessionID, State: models.TaskSessionStateWaitingForInput}} taskdto.EnrichTaskForegroundActivity(taskDTO, sessions, svc) - if got := marshalField(t, taskDTO); got != string(v1.ForegroundActivityBackground) { - t.Fatalf("detached task foreground_activity: got %q, want background", got) + if body := marshalDTO(t, taskDTO); strings.Contains(body, "foreground_activity") { + t.Fatalf("settled detached task must omit foreground_activity, got %s", body) } } diff --git a/apps/backend/internal/orchestrator/foreground_activity_prompt_cycle_test.go b/apps/backend/internal/orchestrator/foreground_activity_prompt_cycle_test.go index 131a3d5461..00a76fc51b 100644 --- a/apps/backend/internal/orchestrator/foreground_activity_prompt_cycle_test.go +++ b/apps/backend/internal/orchestrator/foreground_activity_prompt_cycle_test.go @@ -1,6 +1,7 @@ package orchestrator import ( + "errors" "testing" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" @@ -71,18 +72,18 @@ func TestForegroundActivitySignal_FollowUpPromptKeepsIndependentCompletionIdenti svc.completeTurnForTaskSession(t.Context(), taskID, sessionID) svc.completeTurnForTaskSession(t.Context(), taskID, sessionID) - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground { t.Fatalf("follow-up completion was mistaken for its predecessor: activity = %q", got) } - if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil { - t.Fatalf("follow-up left background-only session unpromptable: %v", err) + if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) { + t.Fatalf("follow-up RUNNING session must remain unpromptable: %v", err) } got := activityValues(eb) want := []string{ string(v1.ForegroundActivityGenerating), - string(v1.ForegroundActivityBackground), string(v1.ForegroundActivityGenerating), - string(v1.ForegroundActivityBackground), + string(v1.ForegroundActivityGenerating), + string(v1.ForegroundActivityGenerating), } if len(got) != len(want) { t.Fatalf("activity publications = %v, want exactly %v", got, want) @@ -145,18 +146,18 @@ func TestForegroundActivitySignal_PreDispatchEventsUseCurrentPromptCycle(t *test ); err != nil { t.Fatalf("dispatch follow-up prompt: %v", err) } - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground { t.Fatalf("pre-callback completion did not own the new prompt cycle: activity = %q", got) } - if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil { - t.Fatalf("detached work after pre-callback completion must be promptable: %v", err) + if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) { + t.Fatalf("detached work must not make a RUNNING session promptable: %v", err) } got := activityValues(eb) want := []string{ string(v1.ForegroundActivityGenerating), - string(v1.ForegroundActivityBackground), string(v1.ForegroundActivityGenerating), - string(v1.ForegroundActivityBackground), + string(v1.ForegroundActivityGenerating), + string(v1.ForegroundActivityGenerating), } if len(got) != len(want) { t.Fatalf("activity publications = %v, want exactly %v", got, want) @@ -212,25 +213,13 @@ func TestForegroundActivitySignal_ClaimedPreDispatchCompletionReconcilesOnAccept } } - if _, err := svc.PromptTask(t.Context(), taskID, sessionID, "follow up", "", false, nil, false); err != nil { - t.Fatalf("dispatch claimed follow-up: %v", err) + if _, err := svc.PromptTask( + t.Context(), taskID, sessionID, "follow up", "", false, nil, false, + ); !errors.Is(err, ErrAgentPromptInProgress) { + t.Fatalf("RUNNING session must reject claimed follow-up: %v", err) } - got := activityValues(eb) - if tt.keepWork { - want := []string{string(v1.ForegroundActivityGenerating), string(v1.ForegroundActivityBackground)} - if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { - t.Fatalf("claimed pre-callback publications = %v, want %v", got, want) - } - if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil { - t.Fatalf("accepted completed foreground with detached work must be promptable: %v", err) - } - return - } - if len(got) != 1 || got[0] != string(v1.ForegroundActivityGenerating) { - t.Fatalf("no-work completion publications = %v, want generating claim only", got) - } - if gotActivity := svc.ForegroundActivity(sessionID); gotActivity != v1.ForegroundActivityGenerating { - t.Fatalf("no-work completion must settle without background hold, got %q", gotActivity) + if len(activityValues(eb)) != 0 { + t.Fatalf("rejected follow-up must not publish an activity change: %v", activityValues(eb)) } }) } diff --git a/apps/backend/internal/orchestrator/foreground_activity_signal_test.go b/apps/backend/internal/orchestrator/foreground_activity_signal_test.go index 170bea6581..134e4742e8 100644 --- a/apps/backend/internal/orchestrator/foreground_activity_signal_test.go +++ b/apps/backend/internal/orchestrator/foreground_activity_signal_test.go @@ -176,7 +176,7 @@ func TestForegroundActivitySignal_PublishesOnFlips(t *testing.T) { got := activityValues(eb) want := []string{ string(v1.ForegroundActivityGenerating), - string(v1.ForegroundActivityBackground), + string(v1.ForegroundActivityGenerating), string(v1.ForegroundActivityGenerating), } if len(got) != len(want) { @@ -246,8 +246,8 @@ func TestForegroundActivitySignal_NoPublishWithoutFlip(t *testing.T) { want := []string{ string(v1.ForegroundActivityGenerating), string(v1.ForegroundActivityGenerating), - string(v1.ForegroundActivityBackground), - string(v1.ForegroundActivityBackground), + string(v1.ForegroundActivityGenerating), + string(v1.ForegroundActivityGenerating), string(v1.ForegroundActivityGenerating), } if len(got) != len(want) { @@ -260,7 +260,7 @@ func TestForegroundActivitySignal_NoPublishWithoutFlip(t *testing.T) { } } -func TestForegroundActivitySignal_ClaimReleasePublishesRestoredBackground(t *testing.T) { +func TestForegroundActivitySignal_ClaimReleasePublishesCoarseGenerating(t *testing.T) { repo := setupTestRepo(t) agentMgr := &mockAgentManager{} svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) @@ -276,17 +276,19 @@ func TestForegroundActivitySignal_ClaimReleasePublishesRestoredBackground(t *tes svc.registerBackgroundTask(sessionID, "background-1") svc.markForegroundIdle(sessionID) - // Admission claims the foreground, but the missing executor row makes - // ensureSessionRunning fail before the prompt reaches the agent. Releasing - // that claim must tell every client that background-idle was restored. - if _, err := svc.PromptTask(context.Background(), taskID, sessionID, "retry", "", false, nil, false); err == nil { - t.Fatal("expected prompt preflight to fail without an executor record") + claim := svc.claimForegroundTurn(sessionID) + if claim == nil { + t.Fatal("expected dormant tracker to allow an internal foreground claim") + } + svc.releaseForegroundClaimOnFailure(context.Background(), taskID, sessionID, claim) + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground { + t.Fatalf("released internal claim did not restore background tracking: %q", got) } got := activityValues(eb) - want := []string{string(v1.ForegroundActivityBackground)} + want := []string{string(v1.ForegroundActivityGenerating)} if len(got) != len(want) || got[0] != want[0] { - t.Fatalf("expected restored background activity broadcast %v, got %v", want, got) + t.Fatalf("expected coarse generating activity broadcast %v, got %v", want, got) } } @@ -320,22 +322,19 @@ func TestForegroundActivitySignal_ModelSwitchPublishesClaimedGenerating(t *testi svc.registerBackgroundTask(sessionID, "background-1") svc.markForegroundIdle(sessionID) - result, err := svc.PromptTask(context.Background(), taskID, sessionID, "continue", "new-model", false, nil, false) - if err != nil { - t.Fatalf("model-switch prompt failed: %v", err) - } - if result == nil || result.StopReason != "model_switched" { - t.Fatalf("expected restart-based model switch result, got %#v", result) + if _, err := svc.PromptTask( + context.Background(), taskID, sessionID, "continue", "new-model", false, nil, false, + ); !errors.Is(err, ErrAgentPromptInProgress) { + t.Fatalf("model-switch prompt must remain blocked while RUNNING: %v", err) } got := activityValues(eb) - want := []string{string(v1.ForegroundActivityGenerating)} - if len(got) != len(want) || got[0] != want[0] { - t.Fatalf("expected claimed foreground activity broadcast %v, got %v", want, got) + if len(got) != 0 { + t.Fatalf("rejected model-switch prompt published activity: %v", got) } } -func TestForegroundActivitySignal_DispatchPublishesBackgroundRegisteredDuringClaim(t *testing.T) { +func TestForegroundActivitySignal_DispatchPublishesCoarseActivity(t *testing.T) { repo := setupTestRepo(t) svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) eb := &recordingEventBus{} @@ -352,7 +351,7 @@ func TestForegroundActivitySignal_DispatchPublishesBackgroundRegisteredDuringCla t.Fatal("prompt must claim the background-idle turn") } svc.registerBackgroundTask(sessionID, "background-2") - if s := svc.ForegroundActivity(sessionID); s != v1.ForegroundActivityGenerating { + if s := svc.foregroundActivityValue(sessionID); s != v1.ForegroundActivityGenerating { t.Fatalf("active claim must remain generating, got %q", s) } @@ -362,12 +361,15 @@ func TestForegroundActivitySignal_DispatchPublishesBackgroundRegisteredDuringCla if !svc.markForegroundIdle(sessionID) { t.Fatal("foreground idle must expose work registered during admission") } + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground { + t.Fatalf("precondition: private activity = %q, want background", got) + } svc.publishForegroundActivityChanged(context.Background(), taskID, sessionID) got := activityValues(eb) - want := []string{string(v1.ForegroundActivityBackground)} + want := []string{string(v1.ForegroundActivityGenerating)} if len(got) != len(want) || got[0] != want[0] { - t.Fatalf("expected dispatch-time background activity broadcast %v, got %v", want, got) + t.Fatalf("expected coarse dispatch-time activity broadcast %v, got %v", want, got) } } @@ -436,7 +438,7 @@ func TestForegroundActivitySignal_PropagatesToTaskLevel(t *testing.T) { } } -func TestForegroundActivitySignal_TurnCompletionPublishesBackgroundExactlyOnce(t *testing.T) { +func TestForegroundActivitySignal_TurnCompletionPublishesCoarseExactlyOnce(t *testing.T) { tests := []struct { name string act func(*Service, string, string) @@ -489,9 +491,9 @@ func TestForegroundActivitySignal_TurnCompletionPublishesBackgroundExactlyOnce(t tt.act(svc, taskID, sessionID) got := activityValues(eb) - want := []string{string(v1.ForegroundActivityBackground)} + want := []string{string(v1.ForegroundActivityGenerating)} if len(got) != len(want) || got[0] != want[0] { - t.Fatalf("expected exactly one session background publication %v, got %v", want, got) + t.Fatalf("expected exactly one coarse session publication %v, got %v", want, got) } if len(taskEvents.activityTaskIDs) != 1 || taskEvents.activityTaskIDs[0] != taskID { t.Fatalf("expected exactly one task aggregate publication for %q, got %v", taskID, taskEvents.activityTaskIDs) @@ -602,18 +604,20 @@ func TestForegroundActivitySignal_SamePromptOutputAfterIdleDoesNotInvalidateComp }) svc.completeTurnForTaskSession(t.Context(), taskID, sessionID) - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground { t.Fatalf("same-prompt %s made completion stale: got activity %q", tt.outputType, got) } - if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil { - t.Fatalf("background-only session must accept immediate input: %v", err) + if err := svc.checkSessionPromptable( + taskID, sessionID, models.TaskSessionStateRunning, + ); !errors.Is(err, ErrAgentPromptInProgress) { + t.Fatalf("background-only RUNNING session must reject immediate input: %v", err) } got := activityValues(eb) want := []string{ string(v1.ForegroundActivityGenerating), - string(v1.ForegroundActivityBackground), string(v1.ForegroundActivityGenerating), - string(v1.ForegroundActivityBackground), + string(v1.ForegroundActivityGenerating), + string(v1.ForegroundActivityGenerating), } if len(got) != len(want) { t.Fatalf("activity publications = %v, want exactly %v", got, want) @@ -676,7 +680,7 @@ func TestForegroundActivitySignal_DetachedLaunchTerminalOutputStaysBackground(t }, }) - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground { t.Fatalf("terminal launch card must not impersonate foreground output: got %q", got) } if !svc.hasBackgroundTask(sessionID, toolID) { @@ -684,7 +688,7 @@ func TestForegroundActivitySignal_DetachedLaunchTerminalOutputStaysBackground(t } if got := activityValues(eb); !slices.Equal(got, []string{ string(v1.ForegroundActivityGenerating), - string(v1.ForegroundActivityBackground), + string(v1.ForegroundActivityGenerating), }) { t.Fatalf("detached launch activity sequence = %v", got) } @@ -716,7 +720,7 @@ func TestForegroundActivitySignal_SynchronousDispatchFailureReleasesCycleClaim(t ); err == nil { t.Fatal("expected synchronous provider dispatch failure") } - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground { t.Fatalf("failed dispatch must release its foreground claim, got %q", got) } if retryClaim := svc.claimForegroundTurn(sessionID); retryClaim == nil { @@ -747,7 +751,7 @@ func TestForegroundActivitySignal_TurnCompletionPreservesFinalBackgroundCompleti }) got := activityValues(eb) - want := []string{string(v1.ForegroundActivityBackground), string(v1.ForegroundActivityGenerating)} + want := []string{string(v1.ForegroundActivityGenerating), string(v1.ForegroundActivityGenerating)} if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { t.Fatalf("expected completion and final-background publications %v, got %v", want, got) } @@ -785,7 +789,7 @@ func TestForegroundActivitySignal_DelayedOldCompletionCannotYieldSuccessor(t *te // has already claimed and begun generating in the same session. svc.completeTurnForSession(t.Context(), sessionID) - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityGenerating { t.Fatalf("delayed old completion yielded successor foreground: got %q", got) } if got := activityValues(eb); len(got) != 0 { @@ -826,7 +830,7 @@ func TestForegroundActivitySignal_OldCompletionBeforeSuccessorLeavesSuccessorCom // the successor is current and must expose the still-running background task. svc.completeTurnForSession(t.Context(), sessionID) - if got := activityValues(eb); len(got) != 1 || got[0] != string(v1.ForegroundActivityBackground) { + if got := activityValues(eb); len(got) != 1 || got[0] != string(v1.ForegroundActivityGenerating) { t.Fatalf("current successor completion must publish background once, got %v", got) } if len(taskEvents.activityTaskIDs) != 1 || taskEvents.activityTaskIDs[0] != taskID { @@ -851,7 +855,7 @@ func TestForegroundActivitySignal_TaskLookupFailureDoesNotConsumeCompletionYield svc.repo = &failOnceGetTaskSessionRepo{repoStore: baseRepo, err: errors.New("transient lookup failure")} svc.completeTurnForSession(t.Context(), sessionID) - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityGenerating { t.Fatalf("failed identity lookup must not consume transition, got %q", got) } if got := activityValues(eb); len(got) != 0 { @@ -859,7 +863,7 @@ func TestForegroundActivitySignal_TaskLookupFailureDoesNotConsumeCompletionYield } svc.completeTurnForSession(t.Context(), sessionID) - if got := activityValues(eb); len(got) != 1 || got[0] != string(v1.ForegroundActivityBackground) { + if got := activityValues(eb); len(got) != 1 || got[0] != string(v1.ForegroundActivityGenerating) { t.Fatalf("retry must publish preserved background transition once, got %v", got) } if len(taskEvents.activityTaskIDs) != 1 || taskEvents.activityTaskIDs[0] != taskID { @@ -888,13 +892,13 @@ func TestForegroundActivitySignal_TurnCompletionIsSessionIsolated(t *testing.T) svc.completeTurnForSession(t.Context(), sessionA) - if got := svc.ForegroundActivity(sessionA); got != v1.ForegroundActivityBackground { + if got := svc.foregroundActivityValue(sessionA); got != v1.ForegroundActivityBackground { t.Fatalf("completed session A must become background, got %q", got) } - if got := svc.ForegroundActivity(sessionB); got != v1.ForegroundActivityGenerating { + if got := svc.foregroundActivityValue(sessionB); got != v1.ForegroundActivityGenerating { t.Fatalf("completion for session A mutated session B: got %q", got) } - if got := activityValues(eb); len(got) != 1 || got[0] != string(v1.ForegroundActivityBackground) { + if got := activityValues(eb); len(got) != 1 || got[0] != string(v1.ForegroundActivityGenerating) { t.Fatalf("expected one session A background event, got %v", got) } if len(taskEvents.activityTaskIDs) != 1 || taskEvents.activityTaskIDs[0] != taskA { @@ -934,7 +938,7 @@ func TestForegroundActivitySignal_SettleRepublishesTaskAggregate(t *testing.T) { // left behind after retirement, whose foreground activity safely defaults to // generating. seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning) - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityGenerating { t.Fatalf("untracked RUNNING session must default generating, got %q", got) } taskEvents.activityTaskIDs = nil @@ -1014,7 +1018,7 @@ func TestForegroundActivitySignal_DelayedOldProviderIdleCannotYieldSuccessor(t * }, }) - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityGenerating { t.Fatalf("delayed old provider idle yielded successor foreground: got %q", got) } if got := activityValues(eb); len(got) != 0 { @@ -1035,10 +1039,10 @@ func TestForegroundActivitySignal_DelayedOldProviderIdleCannotYieldSuccessor(t * PromptGeneration: 2, }, }) - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground { t.Fatalf("current provider idle must yield promptly, got %q", got) } - if got := activityValues(eb); len(got) != 1 || got[0] != string(v1.ForegroundActivityBackground) { + if got := activityValues(eb); len(got) != 1 || got[0] != string(v1.ForegroundActivityGenerating) { t.Fatalf("current provider idle must publish background once, got %v", got) } if len(taskEvents.activityTaskIDs) != 1 || taskEvents.activityTaskIDs[0] != taskID { diff --git a/apps/backend/internal/orchestrator/foreground_busy_signal_test.go b/apps/backend/internal/orchestrator/foreground_busy_signal_test.go index d87699b3a0..a0e3030a46 100644 --- a/apps/backend/internal/orchestrator/foreground_busy_signal_test.go +++ b/apps/backend/internal/orchestrator/foreground_busy_signal_test.go @@ -11,6 +11,30 @@ import ( v1 "github.com/kandev/kandev/pkg/api/v1" ) +func enableClaudeBackgroundPromptHandoffForTest(t *testing.T, svc *Service) { + t.Helper() + svc.config.ClaudeBackgroundPromptHandoff = true +} + +func setSessionAgentNameForTest( + t *testing.T, + svc *Service, + sessionID, agentName string, +) { + t.Helper() + session, err := svc.repo.GetTaskSession(t.Context(), sessionID) + if err != nil { + t.Fatalf("GetTaskSession(%q): %v", sessionID, err) + } + session.AgentProfileSnapshot = map[string]interface{}{ + "agent_id": "persisted-agent-record-id", + "agent_name": agentName, + } + if err := svc.repo.UpdateTaskSession(t.Context(), session); err != nil { + t.Fatalf("UpdateTaskSession(%q): %v", sessionID, err) + } +} + func emitForegroundIdle(svc *Service, taskID, sessionID string) { svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{ TaskID: taskID, SessionID: sessionID, @@ -18,18 +42,11 @@ func emitForegroundIdle(svc *Service, taskID, sessionID string) { }) } -// TestCheckSessionPromptable_BackgroundTaskAcceptsInput is the red -// characterization test for ADR-0049: a session whose -// foreground turn is idle while a spawned background task is still running must -// accept a new message, not be rejected as "already running". -// -// Before the fix, checkSessionPromptable rejected ANY RUNNING session with -// ErrAgentPromptInProgress, so an operator whose agent kicked off background -// work was locked out of the conversation. After the fix, a RUNNING session -// with an outstanding background task (and no active foreground generation) is -// promptable, while a RUNNING session that is genuinely generating in the -// foreground is still gated. -func TestCheckSessionPromptable_BackgroundTaskAcceptsInput(t *testing.T) { +// TestCheckSessionPromptable_BackgroundTaskRemainsBusy pins the conservative +// admission contract from ADR-2026-07-28: provider background-work inference +// may remain useful for accounting, but it cannot relax a RUNNING session's +// prompt gate. +func TestCheckSessionPromptable_BackgroundTaskRemainsBusy(t *testing.T) { repo := setupTestRepo(t) svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) @@ -44,9 +61,13 @@ func TestCheckSessionPromptable_BackgroundTaskAcceptsInput(t *testing.T) { svc.registerBackgroundTask(sessionID, "tool-subagent-1") svc.markForegroundIdle(sessionID) - // The session must now accept a new message even though its state is RUNNING. - if err := svc.checkSessionPromptable("task1", sessionID, models.TaskSessionStateRunning); err != nil { - t.Fatalf("RUNNING session waiting only on background work must be promptable, got: %v", err) + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground { + t.Fatalf("precondition: private tracker activity = %q, want background", got) + } + + // Background accounting must not make the RUNNING session promptable. + if err := svc.checkSessionPromptable("task1", sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) { + t.Fatalf("RUNNING session waiting on background work must remain gated, got: %v", err) } // Once the background task finishes, the (still open) turn is once again a @@ -57,6 +78,59 @@ func TestCheckSessionPromptable_BackgroundTaskAcceptsInput(t *testing.T) { } } +func TestCheckSessionPromptable_ClaudeExperimentAcceptsBackgroundIdle(t *testing.T) { + repo := setupTestRepo(t) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + enableClaudeBackgroundPromptHandoffForTest(t, svc) + + const taskID = "task-claude-experiment" + const sessionID = "session-claude-experiment" + seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning) + setSessionAgentNameForTest(t, svc, sessionID, "claude-acp") + + svc.registerBackgroundTask(sessionID, "tool-subagent-1") + svc.markForegroundIdle(sessionID) + + if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground { + t.Fatalf("enabled Claude activity = %q, want background", got) + } + if err := svc.checkSessionPromptable( + taskID, + sessionID, + models.TaskSessionStateRunning, + ); err != nil { + t.Fatalf("enabled Claude background-idle session rejected: %v", err) + } +} + +func TestCheckSessionPromptable_ClaudeExperimentRejectsNonClaude(t *testing.T) { + repo := setupTestRepo(t) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + enableClaudeBackgroundPromptHandoffForTest(t, svc) + + const taskID = "task-codex-experiment" + const sessionID = "session-codex-experiment" + seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning) + setSessionAgentNameForTest(t, svc, sessionID, "codex-acp") + + // Deliberately forge the private tracker state. Provider gating must remain + // a separate fail-closed boundary even if a future normalizer regression + // incorrectly attests a non-Claude workload. + svc.registerBackgroundTask(sessionID, "tool-subagent-1") + svc.markForegroundIdle(sessionID) + + if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating { + t.Fatalf("enabled non-Claude activity = %q, want generating", got) + } + if err := svc.checkSessionPromptable( + taskID, + sessionID, + models.TaskSessionStateRunning, + ); !errors.Is(err, ErrAgentPromptInProgress) { + t.Fatalf("enabled non-Claude background-idle session must remain gated, got: %v", err) + } +} + func TestForegroundToolCallClosesBackgroundIdleGate(t *testing.T) { repo := setupTestRepo(t) svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) @@ -82,14 +156,8 @@ func TestForegroundToolCallClosesBackgroundIdleGate(t *testing.T) { } } -// TestTurnActivity_ForegroundBackgroundTransitions locks in the state machine -// behind isForegroundTurnGenerating. -// TestForegroundActivity_ExportedValue covers the seam the page-load / list -// serialization layer depends on (ADR-0049): the exported -// ForegroundActivity mirror of the in-memory tracker. An untracked session — which -// includes every session after a backend restart, since a restart ends the turn — -// must report the safe "generating" default so a stale "you may type" can never be -// serialized. +// TestForegroundActivity_ExportedValue proves the public seam stays coarse +// while the dormant tracker retains its fine-grained value. func TestForegroundActivity_ExportedValue(t *testing.T) { repo := setupTestRepo(t) svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) @@ -105,8 +173,11 @@ func TestForegroundActivity_ExportedValue(t *testing.T) { t.Fatalf("registration must not override foreground activity, got %q", got) } svc.markForegroundIdle(s) - if got := svc.ForegroundActivity(s); got != v1.ForegroundActivityBackground { - t.Fatalf("after foreground idle with background work, got %q, want background", got) + if got := svc.foregroundActivityValue(s); got != v1.ForegroundActivityBackground { + t.Fatalf("private activity after foreground idle = %q, want background", got) + } + if got := svc.ForegroundActivity(s); got != v1.ForegroundActivityGenerating { + t.Fatalf("public activity after foreground idle = %q, want generating", got) } svc.completeBackgroundTask(s, "t1") @@ -238,9 +309,8 @@ func TestBackgroundCompleteEventRetiresOneOutstandingTask(t *testing.T) { } // TestForegroundBusySignal_WiredThroughStreamEvents drives the real agent -// stream-event dispatch to prove the WS1 producer → WS2 gate wiring end to end: -// a subagent tool_call arriving on the stream flips the promptable gate from -// "reject (agent running)" to "accept (only background work outstanding)". +// stream-event dispatch to prove background accounting does not relax the +// coarse RUNNING gate. func TestForegroundBusySignal_WiredThroughStreamEvents(t *testing.T) { repo := setupTestRepo(t) svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) @@ -269,9 +339,9 @@ func TestForegroundBusySignal_WiredThroughStreamEvents(t *testing.T) { }) emitForegroundIdle(svc, taskID, sessionID) - // The gate now accepts a new message even though the session state is RUNNING. - if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil { - t.Fatalf("after a background subagent tool_call the session must be promptable, got: %v", err) + // The private tracker yields, but the public gate remains closed. + if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) { + t.Fatalf("background subagent work must not make RUNNING promptable, got: %v", err) } // A child tool_call from inside the subagent (ParentToolCallID set) is the @@ -288,8 +358,8 @@ func TestForegroundBusySignal_WiredThroughStreamEvents(t *testing.T) { Normalized: streams.NewShellExec("ls", "", "", 0, false), }, }) - if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil { - t.Fatalf("a subagent-internal child tool_call must not re-gate input, got: %v", err) + if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) { + t.Fatalf("a subagent-internal child tool_call must leave RUNNING gated, got: %v", err) } } @@ -309,7 +379,7 @@ func TestForegroundBusySignal_TerminalToolUpdateReclosesGate(t *testing.T) { sessionID = "session-stream" ) - // A top-level subagent tool_call opens the gate. + // A top-level subagent tool_call is tracked without opening the coarse gate. svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{ TaskID: taskID, SessionID: sessionID, @@ -321,8 +391,8 @@ func TestForegroundBusySignal_TerminalToolUpdateReclosesGate(t *testing.T) { }, }) emitForegroundIdle(svc, taskID, sessionID) - if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil { - t.Fatalf("precondition: background subagent tool_call should open the gate, got: %v", err) + if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) { + t.Fatalf("background subagent tool_call must leave RUNNING gated, got: %v", err) } // The subagent's own terminal tool_update arrives on the stream. @@ -360,7 +430,7 @@ func TestForegroundBusySignal_TerminalToolUpdateReclosesGateByIDNotKind(t *testi sessionID = "session-stream" ) - // A top-level subagent tool_call opens the gate. + // A top-level subagent tool_call is tracked without opening the coarse gate. svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{ TaskID: taskID, SessionID: sessionID, @@ -372,8 +442,8 @@ func TestForegroundBusySignal_TerminalToolUpdateReclosesGateByIDNotKind(t *testi }, }) emitForegroundIdle(svc, taskID, sessionID) - if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil { - t.Fatalf("precondition: background subagent tool_call should open the gate, got: %v", err) + if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) { + t.Fatalf("background subagent tool_call must leave RUNNING gated, got: %v", err) } // The terminal update for the SAME tool-call ID carries a plain, non-background @@ -408,7 +478,7 @@ func TestForegroundBusySignal_UnregisteredTerminalToolUpdateLeavesGateOpen(t *te sessionID = "session-stream" ) - // A top-level subagent tool_call opens the gate. + // A top-level subagent tool_call is tracked without opening the coarse gate. svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{ TaskID: taskID, SessionID: sessionID, @@ -420,8 +490,8 @@ func TestForegroundBusySignal_UnregisteredTerminalToolUpdateLeavesGateOpen(t *te }, }) emitForegroundIdle(svc, taskID, sessionID) - if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil { - t.Fatalf("precondition: background subagent tool_call should open the gate, got: %v", err) + if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) { + t.Fatalf("background subagent tool_call must leave RUNNING gated, got: %v", err) } // A terminal tool_update for an ID that was never registered as a background @@ -437,8 +507,8 @@ func TestForegroundBusySignal_UnregisteredTerminalToolUpdateLeavesGateOpen(t *te }, }) - if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil { - t.Fatalf("a terminal update for an unregistered tool-call ID must not re-gate input while background work is still outstanding, got: %v", err) + if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) { + t.Fatalf("an unrelated terminal update must leave RUNNING gated, got: %v", err) } } @@ -539,8 +609,8 @@ func TestForegroundBusySignal_BackgroundShellViaUpdate(t *testing.T) { }, }) emitForegroundIdle(svc, taskID, sessionID) - if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil { - t.Fatalf("a run_in_background shell tool_update must open the gate, got: %v", err) + if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) { + t.Fatalf("a background shell update must leave RUNNING gated, got: %v", err) } // Terminal update closes the launch card, not the detached workload. @@ -554,8 +624,8 @@ func TestForegroundBusySignal_BackgroundShellViaUpdate(t *testing.T) { Normalized: attestedBackgroundShellPayload("npm run dev"), }, }) - if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil { - t.Fatalf("terminal shell launch update must preserve background promptability, got: %v", err) + if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) { + t.Fatalf("terminal shell launch update must leave RUNNING gated, got: %v", err) } svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{ @@ -648,8 +718,8 @@ func TestForegroundBusySignal_MonitorViaUpdate(t *testing.T) { }, }) emitForegroundIdle(svc, taskID, sessionID) - if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil { - t.Fatalf("an active Monitor registration tool_update must open the gate, got: %v", err) + if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) { + t.Fatalf("an active Monitor update must leave RUNNING gated, got: %v", err) } // sweepMonitorsOnPromptEnd emits a terminal "complete" tool_update with the @@ -701,8 +771,8 @@ func TestForegroundBusySignal_UpdateDoesNotReYieldAfterForeground(t *testing.T) // A background run_in_background shell is recognized on its first update. bgUpdate() emitForegroundIdle(svc, taskID, sessionID) - if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil { - t.Fatalf("precondition: background shell update should open the gate, got: %v", err) + if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) { + t.Fatalf("background shell update must leave RUNNING gated, got: %v", err) } // The foreground resumes generating (streamed message chunk). diff --git a/apps/backend/internal/orchestrator/foreground_claim_test.go b/apps/backend/internal/orchestrator/foreground_claim_test.go index d6903e77c4..369e799fb8 100644 --- a/apps/backend/internal/orchestrator/foreground_claim_test.go +++ b/apps/backend/internal/orchestrator/foreground_claim_test.go @@ -12,16 +12,9 @@ import ( v1 "github.com/kandev/kandev/pkg/api/v1" ) -// ADR-0049 narrowed the busy gate so a RUNNING session whose foreground turn has -// yielded to background work accepts a new prompt. checkSessionPromptable only -// *reads* that substate, though, and PromptTask does real work between the read -// and the point the turn is marked generating (session reload, ensureSessionRunning, -// an optionally network-bound model switch). Two prompts arriving in that window — -// a double-send, or two tabs on the same session — would both pass the read and -// both reach executor.Prompt, starting overlapping turns on one ACP session. -// -// claimForegroundTurn closes the window: the check and the claim happen under one -// lock, so exactly one caller wins. +// ADR-0049 introduced tracker-side admission claims. The coarse policy no +// longer reaches them from operator prompt admission, but the dormant mechanism +// remains race-safe for a future protocol-backed design. // TestClaimForegroundTurn_OnlyOneConcurrentPromptWins is the regression test for // that race. Every claim but one must lose, no matter how many race in at once. @@ -179,18 +172,18 @@ func TestPromptTask_ConcurrentPromptsIntoBackgroundIdleStartOneTurn(t *testing.T start.Done() done.Wait() - if accepted != 1 { - t.Fatalf("exactly one concurrent prompt may open a turn, got %d accepted (%d rejected busy)", accepted, rejectedBusy) + if accepted != 0 { + t.Fatalf("no concurrent prompt may enter a RUNNING turn, got %d accepted", accepted) } - if rejectedBusy != prompters-1 { - t.Fatalf("every prompt that lost the claim must be rejected with ErrAgentPromptInProgress, got %d of %d", rejectedBusy, prompters-1) + if rejectedBusy != prompters { + t.Fatalf("every prompt must be rejected with ErrAgentPromptInProgress, got %d of %d", rejectedBusy, prompters) } // The decisive assertion: only one turn was actually started on the agent. agentMgr.mu.Lock() captured := len(agentMgr.capturedPrompts) agentMgr.mu.Unlock() - if captured != 1 { - t.Fatalf("overlapping turns reached the agent: %d prompts forwarded, want exactly 1", captured) + if captured != 0 { + t.Fatalf("RUNNING-session prompts reached the agent: %d forwarded, want 0", captured) } } @@ -216,10 +209,10 @@ func TestPromptTask_SupersededQueuedDispatchReleasesForegroundClaim(t *testing.T _, err := svc.promptTask( context.Background(), taskID, sessionID, "queued prompt", "", false, nil, false, "stale-entry", ) - if !errors.Is(err, errQueuedDispatchSuperseded) { - t.Fatalf("a dispatch without the current ownership token must be superseded, got: %v", err) + if !errors.Is(err, ErrAgentPromptInProgress) { + t.Fatalf("a queued dispatch into a RUNNING session must be rejected, got: %v", err) } - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground { t.Fatalf("a superseded dispatch must restore the background-idle gate, got %q", got) } if svc.claimForegroundTurn(sessionID) == nil { @@ -336,7 +329,7 @@ func TestReleaseForegroundClaim_FailedPromptReopensTheGate(t *testing.T) { if claim == nil { t.Fatal("the first prompt must win the claim") } - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityGenerating { t.Fatalf("a claimed turn reads as generating, got %q", got) } @@ -347,7 +340,7 @@ func TestReleaseForegroundClaim_FailedPromptReopensTheGate(t *testing.T) { // Background work is still outstanding, so the session is background-idle again // and the operator can retry. - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground { t.Fatalf("a released claim must return the turn to background-idle, got %q", got) } if svc.claimForegroundTurn(sessionID) == nil { @@ -377,7 +370,7 @@ func TestReleaseForegroundClaim_DoesNotReopenGateWithoutBackgroundWork(t *testin t.Fatal("with no background work outstanding the release must not reopen the gate") } - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityGenerating { t.Fatalf("with no background work outstanding the turn must read as generating, got %q", got) } } @@ -444,7 +437,7 @@ func TestForegroundDispatch_FailedDispatchRollsBackForRetry(t *testing.T) { if restored := <-rollbackDone; !restored { t.Fatal("an unobserved synchronous dispatch failure must restore background-idle") } - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground { t.Fatalf("failed dispatch must reopen immediate input, got %q", got) } @@ -464,7 +457,7 @@ func TestForegroundDispatch_FailedDispatchRollsBackForRetry(t *testing.T) { t.Fatal("accepting a retry does not expose background work before foreground idle") } svc.markForegroundIdle(sessionID) - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground { t.Fatalf("accepted retry must retain its own completion/idle identity, got %q", got) } } @@ -503,7 +496,7 @@ func TestForegroundDispatch_StaleRollbackCannotClobberRetry(t *testing.T) { if !svc.markForegroundIdle(sessionID) { t.Fatal("retry's current idle event must expose its background work") } - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground { t.Fatalf("stale rollback clobbered retry activity: got %q", got) } } @@ -516,13 +509,13 @@ func TestForegroundDispatch_ClaimlessRollbackRestoresOnlyExactUnobservedStart(t svc.registerBackgroundTask(sessionID, "background-1") svc.markForegroundIdle(sessionID) dispatch := svc.beginForegroundDispatch(sessionID, nil) - if dispatch == nil || svc.ForegroundActivity(sessionID) != v1.ForegroundActivityGenerating { + if dispatch == nil || svc.foregroundActivityValue(sessionID) != v1.ForegroundActivityGenerating { t.Fatal("begin must atomically establish claimless successor ownership") } if !svc.rollbackForegroundDispatch(dispatch) { t.Fatal("exact unobserved failure must restore prior background idle") } - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground { t.Fatalf("rollback restored %q, want background", got) } }) @@ -538,7 +531,7 @@ func TestForegroundDispatch_ClaimlessRollbackRestoresOnlyExactUnobservedStart(t if svc.rollbackForegroundDispatch(dispatch) { t.Fatal("provider-observed cycle must not roll back") } - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityGenerating { t.Fatalf("provider output lost successor ownership: got %q", got) } }) @@ -560,7 +553,7 @@ func TestForegroundDispatch_ClaimlessRollbackRestoresOnlyExactUnobservedStart(t if svc.rollbackForegroundDispatch(first) { t.Fatal("stale failure token must not restore over retry") } - if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating { + if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityGenerating { t.Fatalf("stale rollback displaced retry: got %q", got) } }) diff --git a/apps/backend/internal/orchestrator/prompt_background_acceptance_test.go b/apps/backend/internal/orchestrator/prompt_background_acceptance_test.go index 5180ae88aa..094a5b5b41 100644 --- a/apps/backend/internal/orchestrator/prompt_background_acceptance_test.go +++ b/apps/backend/internal/orchestrator/prompt_background_acceptance_test.go @@ -11,38 +11,11 @@ import ( "github.com/kandev/kandev/internal/task/models" ) -// TestPromptTask_BackgroundWorkAcceptsInput is the falsifiable acceptance proof -// for ADR-0049, driven through -// the REAL operator entrypoint (PromptTask) rather than checkSessionPromptable -// in isolation — it reproduces the operator-lockout→fixed transition end to end -// for both of Claude's background-work wire shapes. -// -// The symptom: an operator whose agent launched background work (a Monitor -// watch or a run_in_background shell) is locked out — PromptTask rejects the -// next message with ErrAgentPromptInProgress because the session state still -// reads RUNNING, and the only recovery is a service restart. Both shapes only -// become recognizable on a tool_call_update (the Monitor registration banner -// seeds its Generic view; the run_in_background flag and command are streamed -// after the initial empty tool_call), so this exercises the full producer path: -// a live stream update → background registration → the prompt gate opening. -// -// Why #1600 does not already cover this: the upstream idle-turn completion -// (#1600) only synthesizes a turn-complete after async content has been idle -// for a ~5s debounce, and a chatty Monitor re-extends that debounce on every -// event burst — so the synthetic completion never fires while the watch is -// active. The monitor case below drives repeated event bursts and asserts the -// session is STILL RUNNING (the #1600 window is open, not closed) before it -// sends the mid-burst prompt: without the fine-grained gate, that prompt is -// rejected even though #1600 is armed. See the RED/GREEN note in the batch plan. -// -// Deterministic backend integration harness chosen over Playwright by design: -// surfacing the fine-grained signal to the web composer is a documented -// follow-up (Batch 2 — the composer still derives "busy" from state === -// RUNNING), so a UI test cannot isolate this fix without a timing-flaky -// assertion. This harness drives PromptTask against a live-agent mock and -// asserts the message is forwarded to the agent, which is exactly the -// acceptance the operator sees. -func TestPromptTask_BackgroundWorkAcceptsInput(t *testing.T) { +// TestPromptTask_BackgroundWorkStaysBusy drives the real operator entrypoint +// for both recognized background-work wire shapes. Accounting remains live, +// but neither a background shell nor a chatty Monitor can bypass the coarse +// RUNNING gate. +func TestPromptTask_BackgroundWorkStaysBusy(t *testing.T) { cases := []struct { name string toolCallID string @@ -120,10 +93,8 @@ func TestPromptTask_BackgroundWorkAcceptsInput(t *testing.T) { } emitForegroundIdle(svc, taskID, sessionID) - // The session is still RUNNING: #1600's synthetic turn-complete has not - // fired (the bursts kept its debounce alive), so the durable state alone - // would keep the operator locked out. This is the window the - // fine-grained gate must open. + // The session is still RUNNING, so durable state remains the admission + // authority regardless of the private background estimate. refreshed, err := repo.GetTaskSession(context.Background(), sessionID) if err != nil { t.Fatalf("reload session: %v", err) @@ -132,14 +103,100 @@ func TestPromptTask_BackgroundWorkAcceptsInput(t *testing.T) { t.Fatalf("expected session to remain RUNNING while background work is outstanding, got %s", refreshed.State) } - // Fixed: the same message now goes through — the session accepts input - // while only background work is outstanding, and it is forwarded to the - // live agent instead of being dropped as "busy". - if _, err := svc.PromptTask(context.Background(), taskID, sessionID, "are you still working?", "", false, nil, false); err != nil { - t.Fatalf("session with only background work outstanding must accept input, got: %v", err) + if _, err := svc.PromptTask( + context.Background(), taskID, sessionID, "are you still working?", "", false, nil, false, + ); !errors.Is(err, ErrAgentPromptInProgress) { + t.Fatalf("RUNNING session with background work must reject input, got: %v", err) + } + if len(agentMgr.capturedPrompts) != 0 { + t.Fatalf("rejected prompt reached the agent, captured=%d", len(agentMgr.capturedPrompts)) + } + }) + } +} + +func TestPromptTask_ClaudeExperimentAdmitsRecognizedBackgroundWork(t *testing.T) { + cases := []struct { + name string + toolCallID string + normalized func() *streams.NormalizedPayload + }{ + { + name: "async subagent", + toolCallID: "agent-1", + normalized: func() *streams.NormalizedPayload { + payload := attestedSubagentPayload("background work", "do it", "general-purpose") + payload.SubagentTask().IsAsync = true + payload.SetBackgroundWorkIdentity( + streams.BackgroundWorkKindSubagent, + "child-1", + true, + false, + ) + return payload + }, + }, + { + name: "run_in_background shell", + toolCallID: "bash-1", + normalized: func() *streams.NormalizedPayload { + return attestedBackgroundShellPayload("npm run dev") + }, + }, + { + name: "monitor", + toolCallID: "monitor-1", + normalized: func() *streams.NormalizedPayload { + return monitorGenericPayload(false) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := setupTestRepo(t) + agentMgr := &mockAgentManager{isAgentRunning: true, repoForExecutionLookup: repo} + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + svc.messageCreator = &mockMessageCreator{} + enableClaudeBackgroundPromptHandoffForTest(t, svc) + + const ( + taskID = "task1" + sessionID = "session1" + ) + seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning) + setSessionAgentNameForTest(t, svc, sessionID, "claude-acp") + session, err := repo.GetTaskSession(context.Background(), sessionID) + if err != nil { + t.Fatalf("load session: %v", err) + } + session.AgentExecutionID = "exec-1" + seedExecutorRunning(t, repo, sessionID, taskID, "exec-1") + if err := repo.UpdateTaskSession(context.Background(), session); err != nil { + t.Fatalf("update session: %v", err) + } + + svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{ + TaskID: taskID, + SessionID: sessionID, + ExecutionID: "exec-1", + Data: &lifecycle.AgentStreamEventData{ + Type: "tool_update", + ToolCallID: tc.toolCallID, + ToolStatus: "in_progress", + Normalized: tc.normalized(), + }, + }) + emitForegroundIdle(svc, taskID, sessionID) + + if _, err := svc.PromptTask( + context.Background(), taskID, sessionID, "are you still working?", "", false, nil, false, + ); err != nil { + t.Fatalf("enabled Claude background handoff rejected input: %v", err) } if len(agentMgr.capturedPrompts) != 1 { - t.Fatalf("accepted prompt must be forwarded to the agent, captured=%d", len(agentMgr.capturedPrompts)) + t.Fatalf("accepted prompt count = %d, want 1", len(agentMgr.capturedPrompts)) } }) } diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index f015421946..cfb5c44cee 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -49,9 +49,10 @@ var ( // ServiceConfig holds orchestrator service configuration type ServiceConfig struct { - Scheduler scheduler.SchedulerConfig - QueueSize int - QueueGroup string + Scheduler scheduler.SchedulerConfig + QueueSize int + QueueGroup string + ClaudeBackgroundPromptHandoff bool } // DefaultServiceConfig returns default configuration @@ -459,8 +460,8 @@ type Service struct { // foregroundActivity tracks, per session, whether the open turn is actively // generating in the foreground or only waiting on a spawned background task // (subagent / run-in-background shell). Keyed sessionID -> *turnActivity; - // see turn_activity.go. Consulted by checkSessionPromptable so a session - // that kicked off background work still accepts operator input. + // see turn_activity.go. This is best-effort accounting state and does not + // relax the coarse RUNNING admission policy. // foregroundActivityMu protects record identity: lookups lock the selected // record before releasing it, and execution teardown uses the same order // before detaching an unused record. @@ -1130,8 +1131,8 @@ func (s *Service) completeTurnForSession(ctx context.Context, sessionID string) func (s *Service) completeTurnForTaskSession(ctx context.Context, taskID, sessionID string) { // Foreground ownership ends with the turn, but detached background work can - // outlive it. Preserve those registrations and expose them as background-idle; - // full cleanup belongs to execution/session teardown paths. + // outlive it. Preserve those registrations for accounting; full cleanup + // belongs to execution/session teardown paths. s.yieldForegroundAndPublish(ctx, taskID, sessionID, foregroundYieldTurnCompletion) if s.turnService == nil { diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index e0b71b0eee..0a47ac91cf 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -2975,8 +2975,9 @@ func (s *Service) loadPromptableSession(ctx context.Context, taskID, sessionID s return session, nil } -// claimForegroundForPrompt closes the gap between reading a RUNNING session's -// background-idle substate and dispatching its next prompt. +// claimForegroundForPrompt serializes two callers racing to take the +// background-idle foreground turn. For non-experiment RUNNING sessions, +// checkSessionPromptable already rejects the prompt before this helper is reached. func (s *Service) claimForegroundForPrompt(taskID, sessionID string, session *models.TaskSession) (*foregroundClaim, error) { if session.State != models.TaskSessionStateRunning { return nil, nil @@ -3175,16 +3176,12 @@ func (s *Service) checkSessionPromptable(taskID, sessionID string, state models. models.TaskSessionStateIdle: return nil case models.TaskSessionStateRunning: - // Narrow the busy signal: a session that kicked off background work and - // is otherwise idle in the foreground should still accept a new message - // rather than reporting "running" and dropping it. - // - // This is a *read*, not a claim — DrainQueuedMessage and the STARTING wait - // both call it without going on to drive a turn themselves. PromptTask, - // which does, follows a passing read with claimForegroundTurn to close the - // check-then-act window against a second concurrent prompt. - if !s.isForegroundTurnGenerating(sessionID) { - s.logger.Debug("accepting prompt: foreground turn idle, only background work outstanding", + // The safe default is coarse: every RUNNING session is busy. A + // deployment may explicitly opt a persisted Claude Code session into + // ADR-0049's adapter-attested background handoff experiment. Every + // missing identity and non-Claude provider still fails closed. + if s.ForegroundActivity(sessionID) == v1.ForegroundActivityBackground { + s.logger.Debug("accepting prompt: enabled Claude foreground handoff is background-idle", zap.String("task_id", taskID), zap.String("session_id", sessionID)) return nil diff --git a/apps/backend/internal/orchestrator/turn_activity.go b/apps/backend/internal/orchestrator/turn_activity.go index df490f8b03..9bedaef24c 100644 --- a/apps/backend/internal/orchestrator/turn_activity.go +++ b/apps/backend/internal/orchestrator/turn_activity.go @@ -9,6 +9,7 @@ import ( "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -16,18 +17,14 @@ import ( // independently. Foreground activity always has precedence; background work is // visible only after an explicit foreground-idle boundary. // -// It is the finer-grained signal behind checkSessionPromptable: a session whose -// foreground turn has yielded to background work should accept a new message -// even though its DB state still reads RUNNING. The single-scalar session state -// cannot tell "the foreground agent is generating" from "the foreground turn is -// idle but a spawned background task (a subagent, a run-in-background shell) is -// still running", which is how a long background job used to lock the operator -// out of the conversation. +// It is a best-effort accounting signal only. The default admission and public +// activity contracts never trust it. A high-risk, default-off experiment may +// use it for persisted Claude Code sessions, whose adapter supplies the +// recognized lifecycle attestations below. // -// The absent/zero state means "foreground generating": callers default to the -// pre-existing behaviour (reject a new prompt while RUNNING) unless a background -// task has been explicitly registered for the session, so nothing changes for a -// session that has no background work outstanding. +// The absent/zero internal state means "foreground generating". A recognized +// registration plus an explicit idle boundary can move only this private +// estimate to background. type turnActivity struct { mu sync.Mutex revision uint64 // invalidates delayed publications after newer mutations @@ -821,10 +818,8 @@ func (s *Service) clearTurnActivity(sessionID string) { // yielded to an outstanding background task. An untracked session defaults to // true, preserving the historical "reject a new prompt while RUNNING" contract. // -// This is a pure predicate: checkSessionPromptable and the DTO/WS serializers -// call it to *report* promptability. A caller that is about to actually drive a -// turn on the strength of the answer must use claimForegroundTurn instead, or it -// races every other prompt reading the same window. +// This is a pure internal predicate. The coarse default admission and +// serialization seams deliberately do not use it. func (s *Service) isForegroundTurnGenerating(sessionID string) bool { ta := s.lockTurnActivity(sessionID, false) if ta == nil { @@ -845,26 +840,15 @@ func (ta *turnActivity) generatingLocked() bool { return !ta.yielded } -// claimForegroundTurn is the check-and-claim half of the background-idle gate. +// claimForegroundTurn is the retained check-and-claim mechanism for the private +// background-idle estimate. // It atomically verifies the session's foreground turn has yielded to background // work and, if so, takes it for the caller by flipping it back to generating — // under the same lock, so exactly one caller can win. // -// checkSessionPromptable only *reads* the substate, which leaves a wide -// check-then-act window in PromptTask: between the gate and the point the turn is -// finally marked generating sit a session reload, ensureSessionRunning, and an -// optional (network-bound) model switch. Two prompts arriving in that window — -// a double-send, or two browser tabs onto the same background-idle session — -// would both pass the read-only gate and both reach executor.Prompt, starting -// overlapping turns on one ACP session. Claiming closes that window: the first -// prompt in wins, and every prompt behind it sees a generating foreground and is -// rejected with ErrAgentPromptInProgress exactly as it would have been before -// ADR-0049. -// -// The claim is held until agentctl accepts the prompt (beginForegroundDispatch -// followed by acceptForegroundDispatch — see promptTask in task_operations.go) -// or it is handed back (releaseForegroundClaim). The returned token binds both -// operations to this activity record and admission generation. +// The public coarse gate rejects RUNNING sessions before this is reached unless +// the Claude-only experiment is enabled. Atomic claims preserve serialization +// for the experimental path. // // Returns nil for an untracked session — no background work is outstanding, so // there is nothing to claim and the historical reject-while-RUNNING default @@ -973,7 +957,7 @@ func (s *Service) beginForegroundDispatch( } } -// acceptForegroundDispatch closes a background-idle admission claim after +// acceptForegroundDispatch closes a retained background-idle claim after // agentctl acknowledges the prompt. The cycle itself was already established by // beginForegroundDispatch, so pre-callback provider events own the right token. func (s *Service) acceptForegroundDispatch(dispatch *foregroundDispatch) bool { @@ -1139,11 +1123,10 @@ func (s *Service) releaseForegroundClaim(claim *foregroundClaim) bool { return true } -// foregroundActivityValue reports the fine-grained busy substate of a session -// for the operator-facing signal: "generating" when the foreground turn is -// actively producing output (the default), "background" when it has yielded to -// outstanding spawned work. Background activity can remain meaningful after the -// coarse session state settles because detached work can outlive a turn. +// foregroundActivityValue reports the best-effort internal activity estimate. +// ACP providers do not expose background work with consistent enough lifecycle +// semantics for this estimate to control operator-facing admission or busy +// state. Keep it available for dormant tracking and diagnostics only. func (s *Service) foregroundActivityValue(sessionID string) v1.ForegroundActivity { if s.isForegroundTurnGenerating(sessionID) { return v1.ForegroundActivityGenerating @@ -1151,22 +1134,50 @@ func (s *Service) foregroundActivityValue(sessionID string) v1.ForegroundActivit return v1.ForegroundActivityBackground } -// ForegroundActivity exposes the in-memory fine-grained busy substate so the -// page-load / list serialization layer can stamp it onto a session DTO -// (ADR-0049). This is the same value that drives the -// live task_session.activity_changed WS event, read straight from the in-memory -// tracker — the single source of truth. There is no persisted copy. Callers emit -// generating only for RUNNING sessions, but may emit background for a settled -// session while its connected execution still has detached work. -func (s *Service) ForegroundActivity(sessionID string) v1.ForegroundActivity { - return s.foregroundActivityValue(sessionID) +const ( + claudeACPProviderID = "claude-acp" + mockAgentProviderID = "mock-agent" +) + +// claudeBackgroundPromptHandoffEnabled reports whether sessionID is allowed to +// use the high-risk fine-grained policy. Provider identity comes from the +// persisted launch snapshot rather than a client-supplied activity value. A +// missing session, snapshot, or provider fails closed. +func (s *Service) claudeBackgroundPromptHandoffEnabled(sessionID string) bool { + if !s.config.ClaudeBackgroundPromptHandoff || s.repo == nil || sessionID == "" { + return false + } + session, err := s.repo.GetTaskSession(context.Background(), sessionID) + if err != nil || session == nil { + return false + } + return s.claudeBackgroundPromptHandoffEnabledForSession(session) } -// publishForegroundActivityChanged emits the fine-grained busy signal so the -// web composer and status indicator can distinguish "generating" from "waiting -// on background work" without a coarse session-state transition. Callers invoke -// it only when a flip actually happened (the mark/register/complete helpers -// return that), so it never fires per background frame. +func (s *Service) claudeBackgroundPromptHandoffEnabledForSession( + session *models.TaskSession, +) bool { + if !s.config.ClaudeBackgroundPromptHandoff || session == nil { + return false + } + agentName, _ := session.AgentProfileSnapshot["agent_name"].(string) + return agentName == claudeACPProviderID || agentName == mockAgentProviderID +} + +// ForegroundActivity exposes the conservative operator-facing busy signal by +// default. A deployment that explicitly enables the experiment may expose the +// private tracker value only for a persisted Claude Code session (or the mock +// provider used to exercise Claude lifecycle frames in E2E). +func (s *Service) ForegroundActivity(sessionID string) v1.ForegroundActivity { + if s.claudeBackgroundPromptHandoffEnabled(sessionID) { + return s.foregroundActivityValue(sessionID) + } + return v1.ForegroundActivityGenerating +} + +// publishForegroundActivityChanged emits a provider-aware refresh when the +// internal tracker changes. publishForegroundActivityNow applies the coarse +// default or the explicitly enabled Claude experiment before broadcasting. func (s *Service) publishForegroundActivityChanged(ctx context.Context, taskID, sessionID string) { publicationLock, releasePublication := s.acquireActivityPublicationGuard(sessionID) defer releasePublication() @@ -1239,7 +1250,7 @@ func (s *Service) publishForegroundActivityNow( eventData := map[string]interface{}{ metaKeyTaskID: taskID, metaKeySessionID: sessionID, - "foreground_activity": value, + "foreground_activity": s.publicForegroundActivityValue(ctx, sessionID, value), "active_subagent_count": activeSubagentCount, } if err := s.eventBus.Publish(ctx, events.TaskSessionActivityChanged, @@ -1255,6 +1266,32 @@ func (s *Service) publishForegroundActivityNow( s.publishTaskActivityIfChanged(ctx, taskID) } +func (s *Service) publicForegroundActivityValue( + ctx context.Context, + sessionID string, + value interface{}, +) interface{} { + if value == nil { + return nil + } + publicValue := interface{}(string(v1.ForegroundActivityGenerating)) + if s.repo == nil { + return publicValue + } + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil || session == nil { + return publicValue + } + if s.claudeBackgroundPromptHandoffEnabledForSession(session) && + value == string(v1.ForegroundActivityBackground) { + return value + } + if session.State != models.TaskSessionStateRunning { + return nil + } + return publicValue +} + // normalizedIsBackgroundTask reports whether the adapter attested a live // workload whose complete lifecycle Kandev recognizes. // diff --git a/apps/backend/internal/profiles/profiles.yaml b/apps/backend/internal/profiles/profiles.yaml index 8a015e3973..5b20a95f53 100644 --- a/apps/backend/internal/profiles/profiles.yaml +++ b/apps/backend/internal/profiles/profiles.yaml @@ -67,6 +67,13 @@ features: dev: "false" e2e: "false" + # High-risk Claude-only background prompt handoff experiment. Off in every + # profile so it is exercised only by contributors who deliberately opt in. + KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF: + prod: "false" + dev: "false" + e2e: "false" + # ── Mock providers ──────────────────────────────────────────────── # Swap real third-party services for in-memory fakes. Production # leaves all of these unset (real APIs). Dev opts into the mock agent diff --git a/apps/backend/internal/profiles/profiles_test.go b/apps/backend/internal/profiles/profiles_test.go index 5087c1e109..70784303b8 100644 --- a/apps/backend/internal/profiles/profiles_test.go +++ b/apps/backend/internal/profiles/profiles_test.go @@ -22,8 +22,8 @@ func TestApplyProfile_DefaultsToProd(t *testing.T) { t.Errorf("env = %q, want %q (no selector env vars set)", env, EnvProd) } // Prod writes each registered feature flag with its safe shipped default. - if count != 4 { - t.Errorf("ApplyProfile wrote %d vars in prod; want 4", count) + if count != 5 { + t.Errorf("ApplyProfile wrote %d vars in prod; want 5", count) } if v := os.Getenv("KANDEV_FEATURES_OFFICE"); v != "false" { t.Errorf("KANDEV_FEATURES_OFFICE = %q after prod ApplyProfile; want %q", v, "false") @@ -37,6 +37,13 @@ func TestApplyProfile_DefaultsToProd(t *testing.T) { if v := os.Getenv("KANDEV_FEATURES_AUTH"); v != "false" { t.Errorf("KANDEV_FEATURES_AUTH = %q after prod ApplyProfile; want %q", v, "false") } + if v := os.Getenv("KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF"); v != "false" { + t.Errorf( + "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF = %q after prod ApplyProfile; want %q", + v, + "false", + ) + } } // TestApplyProfile_DevUsesDevelopmentDefaults verifies the mixed dev profile: @@ -67,6 +74,13 @@ func TestApplyProfile_DevUsesDevelopmentDefaults(t *testing.T) { if v := os.Getenv("KANDEV_FEATURES_AUTH"); v != "false" { t.Errorf("KANDEV_FEATURES_AUTH = %q in dev; want %q", v, "false") } + if v := os.Getenv("KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF"); v != "false" { + t.Errorf( + "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF = %q in dev; want %q", + v, + "false", + ) + } } // TestApplyProfile_E2EWinsOverDev checks the documented detection @@ -92,6 +106,13 @@ func TestApplyProfile_E2EWinsOverDev(t *testing.T) { if v := os.Getenv("AGENTCTL_AUTO_APPROVE_PERMISSIONS"); v != "true" { t.Errorf("AGENTCTL_AUTO_APPROVE_PERMISSIONS = %q in e2e; want %q", v, "true") } + if v := os.Getenv("KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF"); v != "false" { + t.Errorf( + "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF = %q in e2e; want %q", + v, + "false", + ) + } } // TestApplyProfile_ExistingEnvWins is the precedence guarantee that @@ -154,6 +175,13 @@ func TestFeatureFlagDefaults_LowercasesShortName(t *testing.T) { if _, ok := defaults["app_status_bar"]; !ok { t.Errorf("FeatureFlagDefaults missing %q key; got %#v", "app_status_bar", defaults) } + if _, ok := defaults["claude_background_prompt_handoff"]; !ok { + t.Errorf( + "FeatureFlagDefaults missing %q key; got %#v", + "claude_background_prompt_handoff", + defaults, + ) + } } func TestMarkApplied_OnlyAllowsKnownDerivedEnvVars(t *testing.T) { @@ -181,7 +209,15 @@ func TestMarkApplied_OnlyAllowsKnownDerivedEnvVars(t *testing.T) { // hell out of whoever debugs it. func TestProfilesYAML_ContainsRequiredSections(t *testing.T) { yaml := string(ProfilesYAML()) - for _, section := range []string{"features:", "mocks:", "debug:", "KANDEV_FEATURES_OFFICE:", "KANDEV_FEATURES_PLUGINS:", "KANDEV_FEATURES_APP_STATUS_BAR:"} { + for _, section := range []string{ + "features:", + "mocks:", + "debug:", + "KANDEV_FEATURES_OFFICE:", + "KANDEV_FEATURES_PLUGINS:", + "KANDEV_FEATURES_APP_STATUS_BAR:", + "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF:", + } { if !strings.Contains(yaml, section) { t.Errorf("embedded profiles.yaml missing section/key %q; embed broken or file truncated", section) } diff --git a/apps/backend/internal/runtimeflags/config.go b/apps/backend/internal/runtimeflags/config.go index af69a35c6b..138494d13f 100644 --- a/apps/backend/internal/runtimeflags/config.go +++ b/apps/backend/internal/runtimeflags/config.go @@ -9,18 +9,20 @@ import ( ) const ( - featureOfficeKey = "features.office" - featurePluginsKey = "features.plugins" - featureAppStatusBarKey = "features.appStatusBar" - featureAuthKey = "features.auth" - debugDevModeKey = "debug.devMode" - envFeaturesOffice = "KANDEV_FEATURES_OFFICE" - envFeaturesPlugins = "KANDEV_FEATURES_PLUGINS" - envFeaturesAppStatusBar = "KANDEV_FEATURES_APP_STATUS_BAR" - envFeaturesAuth = "KANDEV_FEATURES_AUTH" - envDebugDevMode = "KANDEV_DEBUG_DEV_MODE" - envDebugPprofEnabled = "KANDEV_DEBUG_PPROF_ENABLED" - envDebugAgentMessages = "KANDEV_DEBUG_AGENT_MESSAGES" + featureOfficeKey = "features.office" + featurePluginsKey = "features.plugins" + featureAppStatusBarKey = "features.appStatusBar" + featureAuthKey = "features.auth" + featureClaudeBackgroundPromptHandoffKey = "features.claudeBackgroundPromptHandoff" + debugDevModeKey = "debug.devMode" + envFeaturesOffice = "KANDEV_FEATURES_OFFICE" + envFeaturesPlugins = "KANDEV_FEATURES_PLUGINS" + envFeaturesAppStatusBar = "KANDEV_FEATURES_APP_STATUS_BAR" + envFeaturesAuth = "KANDEV_FEATURES_AUTH" + envFeaturesClaudeBackgroundPromptHandoff = "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF" + envDebugDevMode = "KANDEV_DEBUG_DEV_MODE" + envDebugPprofEnabled = "KANDEV_DEBUG_PPROF_ENABLED" + envDebugAgentMessages = "KANDEV_DEBUG_AGENT_MESSAGES" ) func OptionsFromConfig(cfg *config.Config) Options { @@ -32,9 +34,12 @@ func OptionsFromConfig(cfg *config.Config) Options { envFeaturesPlugins: isTruthy(os.Getenv(envFeaturesPlugins)), envFeaturesAppStatusBar: isTruthy(os.Getenv(envFeaturesAppStatusBar)), envFeaturesAuth: isTruthy(os.Getenv(envFeaturesAuth)), - envDebugDevMode: isTruthy(os.Getenv(envDebugDevMode)), - envDebugPprofEnabled: isTruthy(os.Getenv(envDebugPprofEnabled)), - envDebugAgentMessages: isTruthy(os.Getenv(envDebugAgentMessages)), + envFeaturesClaudeBackgroundPromptHandoff: isTruthy( + os.Getenv(envFeaturesClaudeBackgroundPromptHandoff), + ), + envDebugDevMode: isTruthy(os.Getenv(envDebugDevMode)), + envDebugPprofEnabled: isTruthy(os.Getenv(envDebugPprofEnabled)), + envDebugAgentMessages: isTruthy(os.Getenv(envDebugAgentMessages)), }, IsExplicitEnv: func(name string) bool { _, ok := os.LookupEnv(name) @@ -46,11 +51,12 @@ func OptionsFromConfig(cfg *config.Config) Options { func ValuesFromConfig(cfg *config.Config) map[string]bool { debugEnabled := cfg.Debug.DevMode || cfg.Debug.PprofEnabled return map[string]bool{ - featureOfficeKey: cfg.Features.Office, - featurePluginsKey: cfg.Features.Plugins, - featureAppStatusBarKey: cfg.Features.AppStatusBar, - featureAuthKey: cfg.Features.Auth, - debugDevModeKey: debugEnabled, + featureOfficeKey: cfg.Features.Office, + featurePluginsKey: cfg.Features.Plugins, + featureAppStatusBarKey: cfg.Features.AppStatusBar, + featureAuthKey: cfg.Features.Auth, + featureClaudeBackgroundPromptHandoffKey: cfg.Features.ClaudeBackgroundPromptHandoff, + debugDevModeKey: debugEnabled, } } @@ -65,6 +71,8 @@ func ApplyStatesToConfig(cfg *config.Config, states []RuntimeFlagState) { cfg.Features.AppStatusBar = state.EffectiveValue case featureAuthKey: cfg.Features.Auth = state.EffectiveValue + case featureClaudeBackgroundPromptHandoffKey: + cfg.Features.ClaudeBackgroundPromptHandoff = state.EffectiveValue case debugDevModeKey: cfg.Debug.DevMode = state.EffectiveValue cfg.Debug.PprofEnabled = state.EffectiveValue diff --git a/apps/backend/internal/runtimeflags/config_test.go b/apps/backend/internal/runtimeflags/config_test.go index c17cfdaa16..eb2b2c03aa 100644 --- a/apps/backend/internal/runtimeflags/config_test.go +++ b/apps/backend/internal/runtimeflags/config_test.go @@ -2,6 +2,7 @@ package runtimeflags import ( "os" + "reflect" "testing" "github.com/kandev/kandev/internal/common/config" @@ -80,6 +81,17 @@ func TestOptionsFromConfigParsesUppercaseTruthyEnvForAppStatusBar(t *testing.T) } } +func TestOptionsFromConfigParsesClaudeBackgroundPromptHandoffEnv(t *testing.T) { + preserveEnv(t, "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF") + t.Setenv("KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF", "TRUE") + + opts := OptionsFromConfig(&config.Config{}) + + if !opts.EnvValues["KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF"] { + t.Fatal("KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF TRUE parsed false, want true") + } +} + func TestValuesFromConfigIncludesPlugins(t *testing.T) { cfg := &config.Config{} cfg.Features.Plugins = true @@ -125,6 +137,34 @@ func TestValuesFromConfigIncludesAppStatusBar(t *testing.T) { } } +func TestValuesFromConfigIncludesClaudeBackgroundPromptHandoff(t *testing.T) { + cfg := &config.Config{} + field := reflect.ValueOf(&cfg.Features).Elem().FieldByName("ClaudeBackgroundPromptHandoff") + if !field.IsValid() { + t.Fatal("FeaturesConfig.ClaudeBackgroundPromptHandoff field missing") + } + field.SetBool(true) + + values := ValuesFromConfig(cfg) + + if !values["features.claudeBackgroundPromptHandoff"] { + t.Fatal("ValuesFromConfig did not surface features.claudeBackgroundPromptHandoff = true") + } +} + +func TestApplyStatesToConfigSetsClaudeBackgroundPromptHandoff(t *testing.T) { + cfg := &config.Config{} + ApplyStatesToConfig(cfg, []RuntimeFlagState{{ + Key: "features.claudeBackgroundPromptHandoff", + EffectiveValue: true, + }}) + + field := reflect.ValueOf(&cfg.Features).Elem().FieldByName("ClaudeBackgroundPromptHandoff") + if !field.IsValid() || !field.Bool() { + t.Fatal("ApplyStatesToConfig did not set Features.ClaudeBackgroundPromptHandoff = true") + } +} + func TestApplyStatesToConfigMarksImpliedDebugEnvAsApplied(t *testing.T) { for _, name := range []string{ "KANDEV_DEBUG_PPROF_ENABLED", diff --git a/apps/backend/internal/runtimeflags/registry.go b/apps/backend/internal/runtimeflags/registry.go index cb9da90dd8..cfcbf1870d 100644 --- a/apps/backend/internal/runtimeflags/registry.go +++ b/apps/backend/internal/runtimeflags/registry.go @@ -56,6 +56,20 @@ var definitions = []RuntimeFlagDefinition{ RestartRequired: true, Mutable: true, }, + { + Key: featureClaudeBackgroundPromptHandoffKey, + EnvVar: envFeaturesClaudeBackgroundPromptHandoff, + Kind: KindFeature, + Label: "Claude background prompt handoff", + Description: "Allows Claude Code to accept a new prompt after its foreground yields while recognized background work remains active.", + Stability: StabilityExperimental, + RiskLevel: RiskHigh, + RiskDescription: "Claude ACP background lifecycle signals can be missing, delayed, duplicated, or ambiguous. " + + "Enabling this experiment can misclassify session activity or dispatch overlapping prompts. " + + "Use it only for controlled testing and disable it if a session behaves unexpectedly.", + RestartRequired: true, + Mutable: true, + }, { Key: debugDevModeKey, EnvVar: envDebugDevMode, diff --git a/apps/backend/internal/runtimeflags/registry_test.go b/apps/backend/internal/runtimeflags/registry_test.go index 36f0f3e7e8..1f2fe6d804 100644 --- a/apps/backend/internal/runtimeflags/registry_test.go +++ b/apps/backend/internal/runtimeflags/registry_test.go @@ -53,6 +53,34 @@ func TestDefinitionsIncludeAppStatusBarMetadata(t *testing.T) { } } +func TestDefinitionsIncludeClaudeBackgroundPromptHandoffMetadata(t *testing.T) { + def, ok := DefinitionByKey("features.claudeBackgroundPromptHandoff") + if !ok { + t.Fatal("features.claudeBackgroundPromptHandoff definition missing") + } + if def.EnvVar != "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF" { + t.Fatalf( + "EnvVar = %q, want KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF", + def.EnvVar, + ) + } + if def.Stability != StabilityExperimental { + t.Fatalf("Stability = %q, want %q", def.Stability, StabilityExperimental) + } + if def.RiskLevel != RiskHigh { + t.Fatalf("RiskLevel = %q, want %q", def.RiskLevel, RiskHigh) + } + if def.RiskDescription == "" { + t.Fatal("RiskDescription empty") + } + if !def.RestartRequired { + t.Fatal("RestartRequired = false, want true") + } + if !def.Mutable { + t.Fatal("Mutable = false, want true") + } +} + func TestDefinitionsExposeSingleUserFacingDebugToggle(t *testing.T) { def, ok := DefinitionByKey("debug.devMode") if !ok { diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go index 8f0c73adf8..0e4db2ca21 100644 --- a/apps/backend/internal/task/handlers/message_handlers.go +++ b/apps/backend/internal/task/handlers/message_handlers.go @@ -32,20 +32,9 @@ type OrchestratorService interface { StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string, skipMessageRecord, planMode, autoStart bool, attachments []v1.MessageAttachment, references []v1.EntityReference) error ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) error StepRequiresCompletionSignal(ctx context.Context, taskID string) bool - // ForegroundActivity reports the fine-grained busy substate of a RUNNING - // session (ADR-0049): "background" when the foreground turn has yielded to - // spawned background work and can accept a new message. - // - // Deliberately a hard dependency here, unlike TaskHandlers — which reaches the - // same method through an optional type-assertion on OrchestratorStarter and - // simply omits the field when it isn't there. The asymmetry is the difference - // in blast radius, not an oversight: this is the admission gate, so an - // orchestrator that silently lacked the method would fall back to rejecting - // every RUNNING session and quietly un-ship the feature. Requiring it makes - // that a compile error. TaskHandlers only enriches a DTO with the substate; if - // it's absent the client just falls back to the safe "generating" default, so - // there a soft dependency costs nothing. Resist "fixing" the inconsistency by - // making this one optional. + // ForegroundActivity is already filtered by the orchestrator's runtime flag + // and persisted provider identity. Background therefore means this exact + // session is eligible for the experimental direct-admission path. ForegroundActivity(sessionID string) v1.ForegroundActivity } @@ -436,11 +425,8 @@ func (h *MessageHandlers) resolveSessionAfterTurnStart( func (h *MessageHandlers) errorForBlockedMessageSession(msg *ws.Message, sessionID string, state models.TaskSessionState) *ws.Message { switch state { case models.TaskSessionStateRunning: - // A RUNNING session whose foreground turn has yielded to spawned - // background work (ADR-0049) accepts a new message: it flows on to - // PromptTask, which owns the same foreground-idle gate and forwards it. - // Only a foreground-generating turn is blocked here. - if h.orchestrator != nil && h.orchestrator.ForegroundActivity(sessionID) == v1.ForegroundActivityBackground { + if h.orchestrator != nil && + h.orchestrator.ForegroundActivity(sessionID) == v1.ForegroundActivityBackground { return nil } wsErr, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "Agent is currently processing. Please wait for the current operation to complete.", nil) diff --git a/apps/backend/internal/task/handlers/message_handlers_blocked_test.go b/apps/backend/internal/task/handlers/message_handlers_blocked_test.go index b5ab423e85..c92a858d81 100644 --- a/apps/backend/internal/task/handlers/message_handlers_blocked_test.go +++ b/apps/backend/internal/task/handlers/message_handlers_blocked_test.go @@ -644,8 +644,8 @@ func TestWSAddMessageFailsWhenSessionReloadAfterOnTurnStartFails(t *testing.T) { assert.Empty(t, orch.getForwardedSession()) } -// fgActivityOrchestrator is a minimal OrchestratorService whose ForegroundActivity -// is configurable, for testing the ADR-0049 message-add gate. +// fgActivityOrchestrator retains a configurable legacy activity value to prove +// message admission ignores it. type fgActivityOrchestrator struct { activity v1.ForegroundActivity } @@ -697,7 +697,7 @@ func TestWSAddMessage_ForegroundActivityAdmissionWiring(t *testing.T) { wantPrompt bool }{ { - name: "running background dispatches prompt", + name: "running eligible background is accepted", state: models.TaskSessionStateRunning, activity: v1.ForegroundActivityBackground, wantResponse: ws.MessageTypeResponse, @@ -772,11 +772,11 @@ func TestWSAddMessage_ForegroundActivityAdmissionWiring(t *testing.T) { } } -// TestErrorForBlockedMessageSession_BackgroundIdleAccepts is the ADR-0049 -// message-add gate: a RUNNING session whose foreground turn has yielded to -// background work must NOT be blocked at the message.add layer (it flows on to -// PromptTask), while a foreground-generating RUNNING session stays blocked. -func TestErrorForBlockedMessageSession_BackgroundIdleAccepts(t *testing.T) { +// TestErrorForBlockedMessageSession_RunningAlwaysBlocks proves a legacy +// TestErrorForBlockedMessageSession_UsesOrchestratorActivityPolicy proves the +// transport accepts only the provider/flag-filtered background value returned +// by the orchestrator while retaining the coarse block for generating turns. +func TestErrorForBlockedMessageSession_UsesOrchestratorActivityPolicy(t *testing.T) { log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) require.NoError(t, err) msg := &ws.Message{ID: "1", Action: ws.ActionMessageAdd} @@ -788,7 +788,7 @@ func TestErrorForBlockedMessageSession_BackgroundIdleAccepts(t *testing.T) { wantBlock bool }{ {"running + generating is blocked", v1.ForegroundActivityGenerating, models.TaskSessionStateRunning, true}, - {"running + background is accepted", v1.ForegroundActivityBackground, models.TaskSessionStateRunning, false}, + {"running + eligible background is accepted", v1.ForegroundActivityBackground, models.TaskSessionStateRunning, false}, {"waiting is accepted regardless", v1.ForegroundActivityGenerating, models.TaskSessionStateWaitingForInput, false}, {"failed stays blocked", v1.ForegroundActivityBackground, models.TaskSessionStateFailed, true}, } diff --git a/apps/web/e2e/tests/chat/busy-signal.spec.ts b/apps/web/e2e/tests/chat/busy-signal.spec.ts index 5fd163147a..a26f0bd87a 100644 --- a/apps/web/e2e/tests/chat/busy-signal.spec.ts +++ b/apps/web/e2e/tests/chat/busy-signal.spec.ts @@ -2,24 +2,10 @@ import { type Page } from "@playwright/test"; import { test, expect } from "../../fixtures/test-base"; import type { SeedData } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; -import { typeWhileBusy } from "../../helpers/type-while-busy"; import { waitForActiveSessionForegroundActivity } from "../../helpers/session-store"; +import { typeWhileBusy } from "../../helpers/type-while-busy"; import { SessionPage } from "../../pages/session-page"; -// --------------------------------------------------------------------------- -// ADR-0049 — operator-visible surfacing. -// -// The mock `/detached-background ` command returns its foreground response -// immediately while the launched workload continues, so this suite exercises -// work that genuinely outlives the foreground turn. -// -// The distinguishing observable of the background-idle window (b) is that the -// agent status still reads "running" (the working affordance) WHILE the composer -// shows its idle/accept placeholder — condition (a), a genuinely generating -// turn, keeps the "Queue more instructions…" busy placeholder and diverts input -// to the queue. -// --------------------------------------------------------------------------- - async function seedTaskAndWaitForIdle( testPage: Page, apiClient: ApiClient, @@ -39,18 +25,16 @@ async function seedTaskAndWaitForIdle( ); await testPage.goto(`/t/${task.id}`); - const session = new SessionPage(testPage); await session.waitForLoad(); await session.waitForChatIdle({ timeout: 30_000 }); - return session; } -test.describe("Fine-grained busy signal — composer + status", () => { +test.describe("Coarse RUNNING busy signal", () => { test.describe.configure({ retries: 1 }); - test("async subagent accepts a new foreground turn and clears on singleton ID-less completion", async ({ + test("held-open background turn remains busy across input and reload", async ({ testPage, apiClient, seedData, @@ -61,194 +45,39 @@ test.describe("Fine-grained busy signal — composer + status", () => { testPage, apiClient, seedData, - "Async subagent lifecycle", + "Coarse busy signal", ); - // This replay is deliberately ordered like Claude's async Agent path: - // Agent launch (with agentId) -> human-origin foreground idle -> final - // same-prompt thought/message -> prompt completion -> Claude's ID-less - // task-notification completion. It is not the shell-background ordering - // covered below. Exact-ID completion is pinned by backend unit coverage; - // this browser path exercises the safe singleton fallback Claude requires. - await session.sendMessage("/async-subagent-lifecycle 20s"); - await expect(testPage.getByText("Foreground response after async launch.")).toBeVisible({ - timeout: 20_000, + await session.sendMessage("/background 30s"); + await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 }); + await expect(testPage.getByText("Kicking off background work")).toBeVisible({ + timeout: 15_000, }); - await expect(session.idleInput()).toBeVisible({ timeout: 20_000 }); - await waitForActiveSessionForegroundActivity(testPage, "background"); - const backgroundIndicator = session - .sidebarTaskItem("Async subagent lifecycle") - .getByTestId("task-state-background-running"); - await expect(backgroundIndicator).toBeVisible(); - await expect(backgroundIndicator).toHaveClass(/text-violet-500/); - await expect(backgroundIndicator).toHaveClass(/animate-spin/); - await expect(backgroundIndicator.locator("..")).toHaveAttribute( - "aria-label", - "Background work is running", - ); + // The foreground-idle frame follows this text in the mock's ordered ACP + // stream. Allow the subsequent WS publication to settle before asserting + // the stable composer contract. + await testPage.waitForTimeout(500); - // A prompt submitted while only the async child remains must be admitted - // immediately. Foreground activity temporarily wins over the child. - await session.sendMessage("/slow 2s"); - await expect(testPage.getByText("/slow 2s")).toBeVisible({ timeout: 15_000 }); - await expect(testPage.getByTestId("queue-chip")).not.toBeVisible(); + // The private tracker may identify background work, but the public + // contract remains coarse for the entire RUNNING turn. await waitForActiveSessionForegroundActivity(testPage, "generating"); - - // The older async child remains visible after the second foreground yields, - // then its singleton ID-less task-notification removes the final registration. - await expect(session.idleInput()).toBeVisible({ timeout: 15_000 }); - await waitForActiveSessionForegroundActivity(testPage, "background"); - await waitForActiveSessionForegroundActivity(testPage, null); - await expect(backgroundIndicator).not.toBeVisible(); - }); - - test("execution teardown clears an async child whose completion never arrives", async ({ - testPage, - apiClient, - seedData, - }) => { - test.setTimeout(90_000); - - const task = await apiClient.createTaskWithAgent( - seedData.workspaceId, - "Async subagent teardown", - seedData.agentProfileId, - { - description: "/async-subagent-teardown", - workflow_id: seedData.workflowId, - workflow_step_id: seedData.startStepId, - repository_ids: [seedData.repositoryId], - }, - ); - if (!task.session_id) throw new Error("auto-started task did not return a session id"); - - await testPage.goto(`/t/${task.id}`); - const session = new SessionPage(testPage); - await session.waitForLoad(); - await expect(session.idleInput()).toBeVisible({ timeout: 20_000 }); - await expect(session.agentStatus()).toBeVisible(); - await waitForActiveSessionForegroundActivity(testPage, "background"); - - // No child completion is emitted by this scenario. The terminal execution - // boundary must reconcile its owned registration instead. - await apiClient.stopSession({ - session_id: task.session_id, - reason: "e2e teardown", - force: true, - }); - await expect(session.agentStatus()).not.toBeVisible({ timeout: 20_000 }); - await waitForActiveSessionForegroundActivity(testPage, null); - await expect(session.agentStatus()).not.toBeVisible(); - }); - - test("background-idle session shows working AND accepts input, then flips to done", async ({ - testPage, - apiClient, - seedData, - }) => { - test.setTimeout(90_000); - - const session = await seedTaskAndWaitForIdle(testPage, apiClient, seedData, "Busy signal (b)"); - - // Launch work whose foreground prompt closes immediately. - await session.sendMessage("/detached-background 12s"); - - // The turn is working… - await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 }); - - // …and once it yields to background work the composer flips to accept-input - // (idle placeholder) even though the status still reads "running": the two - // independent facts — "you may type" AND "work is still in progress" — are - // both visible at once. This is the whole point of the fine-grained signal. - await expect(session.idleInput()).toBeVisible({ timeout: 20_000 }); - await expect(session.agentStatus()).toBeVisible(); - await waitForActiveSessionForegroundActivity(testPage, "background"); - - // The working affordance must NOT be the done state while background runs. - // Once the detached workload completes, the working affordance clears. - await expect(session.agentStatus()).not.toBeVisible({ timeout: 40_000 }); - await expect(session.idleInput()).toBeVisible({ timeout: 10_000 }); - }); - - test("input typed during the background-idle window is sent, not queued", async ({ - testPage, - apiClient, - seedData, - }) => { - test.setTimeout(90_000); - - const session = await seedTaskAndWaitForIdle(testPage, apiClient, seedData, "Busy signal send"); - - await session.sendMessage("/detached-background 20s"); - await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 }); - - // Wait for the background-idle window (accept-input while still working). - await expect(session.idleInput()).toBeVisible({ timeout: 20_000 }); - await expect(session.agentStatus()).toBeVisible(); - await waitForActiveSessionForegroundActivity(testPage, "background"); - - // Type and submit a foreground turn — it is accepted and posted, not - // diverted, and foreground busy temporarily takes absolute precedence. - const editor = testPage.locator(".tiptap.ProseMirror").first(); - await editor.click(); - await editor.fill("/slow 3s"); - const modifier = process.platform === "darwin" ? "Meta" : "Control"; - await editor.press(`${modifier}+Enter`); - - // It appears in the conversation as a sent user message… - await expect(testPage.getByText("/slow 3s")).toBeVisible({ timeout: 15_000 }); - // …and was NOT silently diverted to the queue. - await expect(testPage.getByTestId("queue-chip")).not.toBeVisible(); - - // While that foreground is active, the instant-send placeholder disappears - // even though the older detached workload remains registered. - await expect(session.idleInput()).not.toBeVisible({ timeout: 2_000 }); + await expect(session.idleInput()).not.toBeVisible(); await expect(testPage.locator('[data-placeholder^="Queue"]')).toBeVisible(); - await waitForActiveSessionForegroundActivity(testPage, "generating"); - // Foreground completion reveals the still-running detached work again. - await expect(session.idleInput()).toBeVisible({ timeout: 15_000 }); - await expect(session.agentStatus()).toBeVisible(); - await waitForActiveSessionForegroundActivity(testPage, "background"); - }); + const editor = session.activeChat().locator(".tiptap.ProseMirror:visible"); + await typeWhileBusy(testPage, editor, "queue this follow-up"); + await testPage.getByTestId("submit-message-button").click(); + await expect(testPage.getByTestId("queue-chip")).toBeVisible({ timeout: 10_000 }); - test("background-idle substate survives a fresh page reload (boot payload, no WS flip)", async ({ - testPage, - apiClient, - seedData, - }) => { - test.setTimeout(90_000); - - const session = await seedTaskAndWaitForIdle( - testPage, - apiClient, - seedData, - "Busy signal reload", - ); - - // Open a long background window so it is still held after the reload lands. - await session.sendMessage("/detached-background 20s"); - await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 }); - - // Reach the background-idle window: accept-input while still working. - await expect(session.idleInput()).toBeVisible({ timeout: 20_000 }); - await expect(session.agentStatus()).toBeVisible(); - - // Reload: this is a fresh client that loads MID background-window. The - // substate is not persisted and no activity_changed WS flip is due, so the - // only way the composer can - // show accept-input + working here is if the boot payload carried the - // fine-grained substate. This is the exact gap this batch closes: before it, - // a reload showed the coarse "Queue more instructions…" busy affordance until - // the next flip — which, for a genuinely idle-on-background turn, never comes. await testPage.reload(); await session.waitForLoad(); - - await expect(session.idleInput()).toBeVisible({ timeout: 15_000 }); await expect(session.agentStatus()).toBeVisible(); + await waitForActiveSessionForegroundActivity(testPage, "generating"); + await expect(session.idleInput()).not.toBeVisible(); + await expect(testPage.locator('[data-placeholder^="Queue"]')).toBeVisible(); }); - test("a turn with no background work keeps the composer gated (queues input)", async ({ + test("foreground generation continues to queue input", async ({ testPage, apiClient, seedData, @@ -259,24 +88,116 @@ test.describe("Fine-grained busy signal — composer + status", () => { testPage, apiClient, seedData, - "Busy signal gated", + "Coarse busy foreground", ); - // A plain slow turn generates in the foreground the whole time — no - // recognized background work — so the composer stays gated exactly as today. await session.sendMessage("/slow 10s"); await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 }); + await waitForActiveSessionForegroundActivity(testPage, "generating"); - // The accept-input (idle) placeholder must NOT appear while generating. - await expect(session.idleInput()).not.toBeVisible(); - - const editor = testPage.locator(".tiptap.ProseMirror").first(); - await typeWhileBusy(testPage, editor, "should queue"); - const submitBtn = testPage.getByTestId("submit-message-button"); - await expect(submitBtn).toBeVisible({ timeout: 5_000 }); - await submitBtn.click(); - - // Diverted to the queue, not posted — the historical contract is unchanged. + const editor = session.activeChat().locator(".tiptap.ProseMirror:visible"); + await typeWhileBusy(testPage, editor, "queue foreground follow-up"); + await testPage.getByTestId("submit-message-button").click(); await expect(testPage.getByTestId("queue-chip")).toBeVisible({ timeout: 10_000 }); }); }); + +test.describe.serial("Claude background prompt handoff experiment", () => { + test.describe.configure({ retries: 1 }); + + test.beforeAll(async ({ backend }) => { + await backend.restart({ + KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF: "true", + }); + const response = await fetch(`${backend.baseUrl}/api/v1/features`); + expect(response.ok).toBeTruthy(); + expect(await response.json()).toMatchObject({ + claudeBackgroundPromptHandoff: true, + }); + }); + + test.afterAll(async ({ backend }) => { + await backend.restart(); + }); + + test("async subagent accepts a foreground turn and clears on completion", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + + const session = await seedTaskAndWaitForIdle( + testPage, + apiClient, + seedData, + "Experimental async subagent", + ); + const agentName = await testPage.evaluate(() => { + const store = ( + window as Window & { + __KANDEV_E2E_STORE__?: { + getState: () => { + tasks: { activeSessionId: string | null }; + taskSessions: { items: Record> }; + }; + }; + } + ).__KANDEV_E2E_STORE__; + const state = store?.getState(); + const sessionID = state?.tasks.activeSessionId; + const snapshot = sessionID + ? (state?.taskSessions.items[sessionID]?.agent_profile_snapshot as + | Record + | undefined) + : undefined; + return snapshot?.agent_name; + }); + expect(agentName).toBe("mock-agent"); + + await session.sendMessage("/async-subagent-lifecycle 12s"); + await expect(testPage.getByText("Foreground response after async launch.")).toBeVisible({ + timeout: 20_000, + }); + await expect(session.idleInput()).toBeVisible({ timeout: 20_000 }); + await waitForActiveSessionForegroundActivity(testPage, "background"); + + await session.sendMessage("/slow 2s"); + await expect(testPage.getByText("/slow 2s")).toBeVisible({ timeout: 15_000 }); + await expect(testPage.getByTestId("queue-chip")).not.toBeVisible(); + await waitForActiveSessionForegroundActivity(testPage, "generating"); + await expect(session.idleInput()).toBeVisible({ timeout: 15_000 }); + await waitForActiveSessionForegroundActivity(testPage, "background"); + await waitForActiveSessionForegroundActivity(testPage, null); + }); + + test("detached work accepts input and preserves the background state on reload", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + + const session = await seedTaskAndWaitForIdle( + testPage, + apiClient, + seedData, + "Experimental detached work", + ); + + await session.sendMessage("/detached-background 20s"); + await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 }); + await expect(session.idleInput()).toBeVisible({ timeout: 20_000 }); + await waitForActiveSessionForegroundActivity(testPage, "background"); + + await testPage.reload(); + await session.waitForLoad(); + await expect(session.agentStatus()).toBeVisible(); + await expect(session.idleInput()).toBeVisible({ timeout: 15_000 }); + await waitForActiveSessionForegroundActivity(testPage, "background"); + + await session.sendMessage("/slow 2s"); + await expect(testPage.getByText("/slow 2s")).toBeVisible({ timeout: 15_000 }); + await expect(testPage.getByTestId("queue-chip")).not.toBeVisible(); + }); +}); diff --git a/apps/web/e2e/tests/chat/mobile-busy-signal.spec.ts b/apps/web/e2e/tests/chat/mobile-busy-signal.spec.ts index daf7fce52f..767ba3fe60 100644 --- a/apps/web/e2e/tests/chat/mobile-busy-signal.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-busy-signal.spec.ts @@ -1,114 +1,24 @@ import { test, expect } from "../../fixtures/test-base"; -import { SessionPage } from "../../pages/session-page"; import { waitForActiveSessionForegroundActivity } from "../../helpers/session-store"; +import { typeWhileBusy } from "../../helpers/type-while-busy"; +import { SessionPage } from "../../pages/session-page"; -// Mobile (Pixel 5) coverage for ADR-0049. Filename matches -// /mobile-.*\.spec\.ts/ so the `mobile-chrome` project picks it up. The composer -// gating and the working affordance derive from the same shared hooks the -// desktop path uses, so this asserts the operator-visible outcome holds at -// mobile width: a background-idle session shows "working" while the composer -// accepts input, then flips to done once the background task finishes. - -test.describe("Mobile fine-grained busy signal", () => { +test.describe("Mobile coarse RUNNING busy signal", () => { test.describe.configure({ retries: 1 }); - test("async subagent allows instant mobile send and clears on singleton ID-less completion", async ({ - testPage, - apiClient, - seedData, - }) => { - test.setTimeout(150_000); - - const task = await apiClient.createTaskWithAgent( - seedData.workspaceId, - "Mobile async subagent lifecycle", - seedData.agentProfileId, - { - description: "/async-subagent-lifecycle 25s", - workflow_id: seedData.workflowId, - workflow_step_id: seedData.startStepId, - repository_ids: [seedData.repositoryId], - }, - ); - - await testPage.goto(`/t/${task.id}`); - const session = new SessionPage(testPage); - await session.waitForLoad(); - await expect(testPage.getByText("Foreground response after async launch.")).toBeVisible({ - timeout: 25_000, - }); - await expect(session.idleInput()).toBeVisible({ timeout: 25_000 }); - await expect(session.agentStatus()).toBeVisible(); - await waitForActiveSessionForegroundActivity(testPage, "background"); - - // Use the shipped Pixel 5 composer button path; the prompt must be posted, - // not queued, and foreground must take visual precedence over the child. - await session.sendMessageViaButton("/slow 3s"); - await expect(testPage.getByText("/slow 3s")).toBeVisible({ timeout: 15_000 }); - await expect(testPage.getByTestId("queue-chip")).not.toBeVisible(); - await waitForActiveSessionForegroundActivity(testPage, "generating"); - - await expect(session.idleInput()).toBeVisible({ timeout: 20_000 }); - await waitForActiveSessionForegroundActivity(testPage, "background"); - await expect(session.agentStatus()).not.toBeVisible({ timeout: 45_000 }); - await waitForActiveSessionForegroundActivity(testPage, null); - await expect(session.agentStatus()).not.toBeVisible(); - }); - - test("execution teardown clears a missing async completion on mobile", async ({ - testPage, - apiClient, - seedData, - }) => { - test.setTimeout(100_000); - - const task = await apiClient.createTaskWithAgent( - seedData.workspaceId, - "Mobile async subagent teardown", - seedData.agentProfileId, - { - description: "/async-subagent-teardown", - workflow_id: seedData.workflowId, - workflow_step_id: seedData.startStepId, - repository_ids: [seedData.repositoryId], - }, - ); - if (!task.session_id) throw new Error("auto-started task did not return a session id"); - - await testPage.goto(`/t/${task.id}`); - const session = new SessionPage(testPage); - await session.waitForLoad(); - await expect(session.idleInput()).toBeVisible({ timeout: 25_000 }); - await expect(session.agentStatus()).toBeVisible(); - await waitForActiveSessionForegroundActivity(testPage, "background"); - - await apiClient.stopSession({ - session_id: task.session_id, - reason: "e2e teardown", - force: true, - }); - await expect(session.agentStatus()).not.toBeVisible({ timeout: 20_000 }); - await waitForActiveSessionForegroundActivity(testPage, null); - await expect(session.agentStatus()).not.toBeVisible(); - }); - - test("background-idle session shows working AND accepts input at mobile width", async ({ + test("held-open background turn remains busy across input and reload", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(120_000); - // Drive the background window via the auto-started first turn (the task - // description) rather than a sendMessage follow-up: the live turn reliably - // reaches the mobile client while it is still running (same approach as - // mobile-empty-turn.spec.ts). const task = await apiClient.createTaskWithAgent( seedData.workspaceId, - "Mobile busy signal", + "Mobile coarse busy signal", seedData.agentProfileId, { - description: "/detached-background 30s", + description: "/background 30s", workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, repository_ids: [seedData.repositoryId], @@ -118,50 +28,56 @@ test.describe("Mobile fine-grained busy signal", () => { await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - - // The auto-started turn is working… await expect(session.agentStatus()).toBeVisible({ timeout: 20_000 }); + await expect(testPage.getByText("Kicking off background work")).toBeVisible({ + timeout: 20_000, + }); + await testPage.waitForTimeout(500); - // …and once it yields to background work the composer flips to accept-input - // (idle placeholder) while the status still reads "working" — both facts - // visible at once at mobile width. - await expect(session.idleInput()).toBeVisible({ timeout: 25_000 }); - await expect(session.agentStatus()).toBeVisible(); - await waitForActiveSessionForegroundActivity(testPage, "background"); - - // A mobile button submission during the background-only window must start - // a new foreground turn immediately, never divert the prompt to the queue. - await session.sendMessageViaButton("/slow 5s"); - await expect(testPage.getByText("/slow 5s")).toBeVisible({ timeout: 15_000 }); - await expect(testPage.getByTestId("queue-chip")).not.toBeVisible(); - await expect(session.idleInput()).not.toBeVisible({ timeout: 5_000 }); await waitForActiveSessionForegroundActivity(testPage, "generating"); + await expect(session.idleInput()).not.toBeVisible(); + await expect(testPage.locator('[data-placeholder^="Queue"]')).toBeVisible(); - // Foreground completion exposes the older detached work again until its - // own lifecycle completion arrives. - await expect(session.idleInput()).toBeVisible({ timeout: 20_000 }); + const editor = session.activeChat().locator(".tiptap.ProseMirror:visible"); + await typeWhileBusy(testPage, editor, "queue this mobile follow-up"); + await testPage.getByTestId("submit-message-button").tap(); + await expect(testPage.getByTestId("queue-chip")).toBeVisible({ timeout: 10_000 }); + + await testPage.reload(); + await session.waitForLoad(); await expect(session.agentStatus()).toBeVisible(); - await waitForActiveSessionForegroundActivity(testPage, "background"); + await waitForActiveSessionForegroundActivity(testPage, "generating"); + await expect(session.idleInput()).not.toBeVisible(); + await expect(testPage.locator('[data-placeholder^="Queue"]')).toBeVisible(); + }); +}); - // After the detached task completes, the working affordance clears. - await expect(session.agentStatus()).not.toBeVisible({ timeout: 40_000 }); - await expect(session.idleInput()).toBeVisible({ timeout: 10_000 }); +test.describe.serial("Mobile Claude background prompt handoff experiment", () => { + test.describe.configure({ retries: 1 }); + + test.beforeAll(async ({ backend }) => { + await backend.restart({ + KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF: "true", + }); }); - test("background-idle substate survives a reload at mobile width", async ({ + test.afterAll(async ({ backend }) => { + await backend.restart(); + }); + + test("background-only work keeps working visible while input sends immediately", async ({ testPage, apiClient, seedData, }) => { - test.setTimeout(90_000); + test.setTimeout(120_000); - // Long background window so it is still held after the reload lands. const task = await apiClient.createTaskWithAgent( seedData.workspaceId, - "Mobile busy signal reload", + "Mobile experimental background handoff", seedData.agentProfileId, { - description: "/detached-background 20s", + description: "/e2e:simple-message", workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, repository_ids: [seedData.repositoryId], @@ -171,19 +87,22 @@ test.describe("Mobile fine-grained busy signal", () => { await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); - // Reach the background-idle window. + const editor = session.activeChat().locator(".tiptap.ProseMirror:visible"); + await editor.tap(); + await editor.fill("/detached-background 20s"); + await testPage.getByTestId("submit-message-button").tap(); await expect(session.agentStatus()).toBeVisible({ timeout: 20_000 }); - await expect(session.idleInput()).toBeVisible({ timeout: 25_000 }); - await expect(session.agentStatus()).toBeVisible(); + await expect(session.idleInput()).toBeVisible({ timeout: 20_000 }); + await waitForActiveSessionForegroundActivity(testPage, "background"); - // Reload mid-window: a fresh mobile client. The accept-input + working - // affordance must come straight from the boot payload (no persisted value, - // no activity_changed WS flip due) — ADR-0049. - await testPage.reload(); - await session.waitForLoad(); + await editor.tap(); + await editor.fill("/slow 2s"); + await testPage.getByTestId("submit-message-button").tap(); - await expect(session.idleInput()).toBeVisible({ timeout: 15_000 }); - await expect(session.agentStatus()).toBeVisible(); + await expect(testPage.getByText("/slow 2s")).toBeVisible({ timeout: 15_000 }); + await expect(testPage.getByTestId("queue-chip")).not.toBeVisible(); + await waitForActiveSessionForegroundActivity(testPage, "generating"); }); }); diff --git a/apps/web/lib/state/slices/features/features-slice.test.ts b/apps/web/lib/state/slices/features/features-slice.test.ts index 5c7f15cc27..b5307fdb90 100644 --- a/apps/web/lib/state/slices/features/features-slice.test.ts +++ b/apps/web/lib/state/slices/features/features-slice.test.ts @@ -24,24 +24,34 @@ describe("features slice", () => { expect(store.getState().features.plugins).toBe(false); expect(defaultFeaturesState.features).toHaveProperty("appStatusBar", false); expect(defaultFeaturesState.features).toHaveProperty("auth", false); + expect(defaultFeaturesState.features).toHaveProperty("claudeBackgroundPromptHandoff", false); }); it("setFeatures replaces the whole flag map", () => { const store = makeStore(); - store.getState().setFeatures({ office: true, plugins: true, appStatusBar: true, auth: true }); + store.getState().setFeatures({ + office: true, + plugins: true, + appStatusBar: true, + auth: true, + claudeBackgroundPromptHandoff: true, + }); expect(store.getState().features.office).toBe(true); expect(store.getState().features.plugins).toBe(true); expect(store.getState().features.appStatusBar).toBe(true); expect(store.getState().features.auth).toBe(true); + expect(store.getState().features.claudeBackgroundPromptHandoff).toBe(true); store.getState().setFeatures({ office: false, plugins: false, appStatusBar: false, auth: false, + claudeBackgroundPromptHandoff: false, }); expect(store.getState().features.office).toBe(false); expect(store.getState().features.plugins).toBe(false); expect(store.getState().features.appStatusBar).toBe(false); expect(store.getState().features.auth).toBe(false); + expect(store.getState().features.claudeBackgroundPromptHandoff).toBe(false); }); }); diff --git a/apps/web/lib/state/slices/features/features-slice.ts b/apps/web/lib/state/slices/features/features-slice.ts index efd1e9fd51..9b952370f6 100644 --- a/apps/web/lib/state/slices/features/features-slice.ts +++ b/apps/web/lib/state/slices/features/features-slice.ts @@ -10,6 +10,7 @@ export const defaultFeaturesState: FeaturesSliceState = { plugins: false, appStatusBar: false, auth: false, + claudeBackgroundPromptHandoff: false, }, }; diff --git a/apps/web/lib/state/slices/features/types.ts b/apps/web/lib/state/slices/features/types.ts index 91e02a0928..5ed63c9e04 100644 --- a/apps/web/lib/state/slices/features/types.ts +++ b/apps/web/lib/state/slices/features/types.ts @@ -9,6 +9,7 @@ export type FeatureFlags = { plugins: boolean; appStatusBar: boolean; auth: boolean; + claudeBackgroundPromptHandoff: boolean; }; export type FeatureName = keyof FeatureFlags; diff --git a/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md b/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md index ec6cf62fa4..e1ec4bd0dc 100644 --- a/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md +++ b/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md @@ -1,6 +1,6 @@ # 0049: Fine-grained foreground-idle busy signal -**Status:** accepted (amended 2026-07-26) +**Status:** superseded by 2026-07-28-coarse-running-busy-signal **Date:** 2026-07-11 **Area:** backend, frontend, protocol **Related:** [Background work liveness spec](../specs/platform/background-work-liveness.md) diff --git a/docs/decisions/2026-07-28-coarse-running-busy-signal.md b/docs/decisions/2026-07-28-coarse-running-busy-signal.md new file mode 100644 index 0000000000..e6e4c92e15 --- /dev/null +++ b/docs/decisions/2026-07-28-coarse-running-busy-signal.md @@ -0,0 +1,74 @@ +# ADR-2026-07-28-coarse-running-busy-signal: Default to Coarse Running Prompt Admission + +**Status:** accepted +**Date:** 2026-07-28 +**Area:** backend, frontend, protocol + +## Context + +ADR-0049 allowed a `RUNNING` session to accept input when Kandev inferred that +the foreground had yielded to recognized background work. In practice, ACP +providers do not report subagent and background-work lifecycles with enough +consistent identity, nesting, or terminal-state detail to make that inference a +reliable admission boundary. Misclassification can enable overlapping prompts, +show an incorrect background status, or leave a session transition waiting on +an event the provider never emits. + +## Decision + +Restore the conservative pre-ADR-0049 operator contract as the shipped default: + +- Every `RUNNING` session rejects direct prompt admission and routes composer + input through the existing queued-message path. +- Every `RUNNING` session is exposed as `foreground_activity=generating`. + Settled sessions do not expose a background activity tier. +- `session.activity_changed`, `session.state_changed`, boot payloads, REST + session DTOs, and task aggregates follow the same coarse activity policy. +- The in-memory background-work tracker and adapter attestations remain in place + as accounting infrastructure. They may continue to supply + `active_subagent_count`, but they do not relax admission or select an + operator-visible background activity tier by default. + +Retain ADR-0049's complete Claude Code behavior behind the default-off +`features.claudeBackgroundPromptHandoff` runtime toggle. The exception is +effective only for `claude-acp` sessions (plus the mock provider in E2E): + +- adapter-attested Claude subagents, background shells, and Monitor watches may + expose the background activity tier; +- a generation-matched Claude foreground handoff may admit the next prompt + while the durable session remains `RUNNING`; and +- every other provider remains coarse even when the toggle is enabled. + +The toggle is experimental, high risk, restart-required, and off in every +embedded profile. Missing provider identity fails closed. Its purpose is to let +designated contributors gather protocol fixtures and refine the behavior +without changing the release default. + +## Consequences + +Prompt admission is safe and consistent across ACP providers by default, and +the composer cannot advertise direct input that the coarse lifecycle considers +busy. An operator may need to queue a follow-up while a held-open foreground +turn waits on background work. Detached work that outlives a completed +foreground turn is not shown as a distinct background-running tier unless the +Claude experiment is deliberately enabled. + +The opt-in path preserves a focused real-world test bed without exposing known +ACP lifecycle uncertainty to every user. Enabling it accepts the original +risks: missing, delayed, duplicated, or ID-less Claude lifecycle frames can +misclassify activity or promptability. The environment variable or persisted +runtime override provides a kill switch, but it does not make those signals +authoritative. + +## Alternatives Considered + +- **Revert ADR-0049 and all implementation commits.** Rejected because later + lifecycle hardening and accounting work spans many subsystems; removing it + immediately creates more release risk than disabling the policy boundary. +- **Disable only prompt admission.** Rejected because the UI would still + advertise direct input or a background state that no longer controls + admission. +- **Remove the fine-grained path completely.** Rejected because a default-off, + Claude-scoped experiment gives the original contributor a controlled way to + reproduce and improve all supported background modes without changing the + release default. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 52842b8e0f..676484268d 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -56,7 +56,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 0046 | [Settings route save coordinator](0046-settings-route-save-coordinator.md) | accepted | frontend | 2026-07-14 | | 0047 | [Plugins read conversation content via a capability-gated Host RPC](0047-plugin-host-conversation-reads.md) | accepted | backend, protocol | 2026-07-21 | | 0048 | [Plugins invoke a settings-selectable utility agent](0048-plugin-host-utility-agent-invoke.md) | accepted | backend, frontend, protocol | 2026-07-21 | -| 0049 | [Fine-grained foreground-idle busy signal](0049-fine-grained-foreground-idle-busy-signal.md) | accepted (amended 2026-07-26) | backend, frontend, protocol | 2026-07-11 | +| 0049 | [Fine-grained foreground-idle busy signal](0049-fine-grained-foreground-idle-busy-signal.md) | superseded by 2026-07-28-coarse-running-busy-signal | backend, frontend, protocol | 2026-07-11 | | 0050 | [Plugins provide OIDC/SAML login via a capability-gated, host-minted session](0050-plugin-external-auth-capability.md) | accepted | backend, security, protocol | 2026-07-26 | | 2026-07-23-opencode-review-evidence-trust | [Trusted OpenCode Review Evidence](2026-07-23-opencode-review-evidence-trust.md) | accepted | workflow, infra | 2026-07-23 | | 2026-07-23-planner-direct-small-work | [Planner Direct Small Work](2026-07-23-planner-direct-small-work.md) | superseded by 2026-07-26 | workflow | 2026-07-23 | @@ -83,3 +83,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-07-27-task-git-credential-policy | [Separate GitHub Automation From Task Git Credential Policy](2026-07-27-task-git-credential-policy.md) | accepted | backend, frontend, security | 2026-07-27 | | 2026-07-27-legacy-add-branch-live-rescan | [Preserve Live Rescan for Legacy Add Branch](2026-07-27-legacy-add-branch-live-rescan.md) | accepted | backend, protocol | 2026-07-27 | | 2026-07-28-visible-wip-overflow-queues | [Separate Visible Queueing From WIP Admission](2026-07-28-visible-wip-overflow-queues.md) | proposed | backend, frontend, protocol, workflow | 2026-07-28 | +| 2026-07-28-coarse-running-busy-signal | [Restore Coarse Running Prompt Admission](2026-07-28-coarse-running-busy-signal.md) | accepted | backend, frontend, protocol | 2026-07-28 | diff --git a/docs/plans/coarse-running-busy-signal/plan.md b/docs/plans/coarse-running-busy-signal/plan.md new file mode 100644 index 0000000000..2a7bb8fdb6 --- /dev/null +++ b/docs/plans/coarse-running-busy-signal/plan.md @@ -0,0 +1,75 @@ +--- +spec: docs/specs/platform/background-work-liveness.md +created: 2026-07-28 +status: completed +--- + +# Implementation Plan: Coarse Running Default with Claude Experiment + +## Overview + +Restore the pre-ADR-0049 admission and display contract as the default while +leaving ADR-0049's complete Claude behavior available behind a default-off, +high-risk runtime flag. Pin both sides of the contract with focused backend +tests and desktop/mobile browser coverage, then update the public configuration +and API documentation. + +## Backend + +### Admission and exposed activity + +- Update `apps/backend/internal/orchestrator/task_operations.go` so + `checkSessionPromptable` rejects every `RUNNING` session with + `ErrAgentPromptInProgress`. +- Update `apps/backend/internal/orchestrator/turn_activity.go` so + `ForegroundActivity` and activity publications expose only the coarse + generating policy while the private tracker retains its fine-grained value. +- Update `publishTaskSessionStateChanged` in + `apps/backend/internal/orchestrator/event_handlers_streaming.go` to use the + same public policy and omit activity after `RUNNING`. +- Add `features.claudeBackgroundPromptHandoff` and require both that flag and an + exact `claude-acp` provider snapshot before the private tracker can relax + admission or expose the background tier. The mock provider follows this path + only for E2E coverage. + +## Frontend + +The existing composer, activity store, and status components already consume +the backend's `foreground_activity`. Add the flag to the shared feature shape; +the backend remains authoritative for whether a specific session is eligible. + +## Tests + +- **What:** a `RUNNING` background-idle session remains unpromptable. + **File:** `apps/backend/internal/orchestrator/foreground_busy_signal_test.go`. + **How:** drive the real tracker into background-idle, assert the private value + remains available, then assert the public admission and activity seams remain + coarse. +- **What:** live activity publications never advertise the dormant background + tier. **File:** + `apps/backend/internal/orchestrator/foreground_activity_signal_test.go`. + **How:** publish a background-idle transition through the recording event bus + and assert `generating`. + +## E2E Tests + +- **Scenario:** GIVEN a held-open background turn on desktop, WHEN the operator + submits during its background-idle window, THEN the composer queues the + message and remains generating across reload. + **File:** `apps/web/e2e/tests/chat/busy-signal.spec.ts`. +- **Scenario:** the same user outcome through the existing mobile composer + button on Pixel 5. + **File:** `apps/web/e2e/tests/chat/mobile-busy-signal.spec.ts`. +- **Scenario:** with the flag explicitly enabled, the complete desktop and + mobile Claude-mock suite covers async subagents, detached work, foreground + precedence, reload hydration, and touch submission. Backend prompt-entry + tests cover every recognized Claude mode, including `run_in_background` + shells and Monitor. + +No layout, navigation, scroll, or touch composition changes are required. Both +viewports reuse the shipped session composer and status surfaces. + +## Implementation Waves + +- [x] [Task 01: Restore coarse running policy](task-01-restore-coarse-running-policy.md) +- [x] [Task 02: Add Claude experiment flag](task-02-add-claude-experiment-flag.md) diff --git a/docs/plans/coarse-running-busy-signal/task-01-restore-coarse-running-policy.md b/docs/plans/coarse-running-busy-signal/task-01-restore-coarse-running-policy.md new file mode 100644 index 0000000000..683c3d185c --- /dev/null +++ b/docs/plans/coarse-running-busy-signal/task-01-restore-coarse-running-policy.md @@ -0,0 +1,75 @@ +--- +id: "01-restore-coarse-running-policy" +title: "Restore coarse running policy" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/platform/background-work-liveness.md" +--- + +# Task 01: Restore coarse running policy + +## Acceptance + +- Every `RUNNING` session rejects direct prompt admission even when the private + tracker reports foreground-idle background work. +- Boot/REST/WS/task activity surfaces expose `generating` for `RUNNING` and do + not expose a settled background tier. +- Desktop and mobile composer flows queue input during a held-open background + turn and retain that behavior after a fresh reload. + +## Verification + +- `make -C apps/backend test` +- `cd apps && pnpm --filter @kandev/web e2e:run -- tests/chat/busy-signal.spec.ts -- --grep "held-open background turn remains busy"` +- `cd apps && pnpm --filter @kandev/web e2e:run -- tests/chat/mobile-busy-signal.spec.ts -- --grep "held-open background turn remains busy"` +- `make fmt` +- `make typecheck test lint` + +## Files likely touched + +- `apps/backend/internal/orchestrator/task_operations.go` +- `apps/backend/internal/orchestrator/turn_activity.go` +- `apps/backend/internal/orchestrator/event_handlers_streaming.go` +- `apps/backend/internal/orchestrator/foreground_busy_signal_test.go` +- `apps/backend/internal/orchestrator/foreground_activity_signal_test.go` +- `apps/web/e2e/tests/chat/busy-signal.spec.ts` +- `apps/web/e2e/tests/chat/mobile-busy-signal.spec.ts` +- `docs/specs/platform/background-work-liveness.md` +- `docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md` +- `docs/decisions/2026-07-28-coarse-running-busy-signal.md` +- `docs/decisions/INDEX.md` +- `docs/public/tasks-and-workflows.md` +- `docs/public/websocket-api.md` + +## Dependencies + +None. + +## Parallelism + +Sequential. Admission and all activity surfaces share one policy boundary. + +## Inputs + +- Background work liveness spec: coarse admission and display scenarios. +- ADR-2026-07-28: retain dormant accounting while disabling policy use. +- Existing tracker and busy-signal tests around ADR-0049. + +## Output contract + +Report the root cause, files changed, RED/GREEN evidence, E2E results, required +verification results, commit receipt, remaining risks, and task/plan status. + +## Results + +- RED: targeted orchestrator tests failed because background-idle was admitted + and exported as `background`; desktop and mobile E2E timed out waiting for the + coarse `generating` value. +- GREEN: targeted orchestrator and mock-agent regressions passed. +- GREEN: desktop Chromium and mobile Chrome held-open background scenarios + queued input and remained generating across reload. +- GREEN: `make fmt`. +- GREEN: `make typecheck test lint`. +- GREEN: public documentation validation tests and source validation. diff --git a/docs/plans/coarse-running-busy-signal/task-02-add-claude-experiment-flag.md b/docs/plans/coarse-running-busy-signal/task-02-add-claude-experiment-flag.md new file mode 100644 index 0000000000..a7cae76076 --- /dev/null +++ b/docs/plans/coarse-running-busy-signal/task-02-add-claude-experiment-flag.md @@ -0,0 +1,74 @@ +--- +id: "02-add-claude-experiment-flag" +title: "Add Claude background prompt handoff experiment" +status: completed +wave: 2 +depends_on: ["01-restore-coarse-running-policy"] +plan: "plan.md" +spec: "../../specs/platform/background-work-liveness.md" +--- + +# Task 02: Add Claude background prompt handoff experiment + +## Acceptance + +- `features.claudeBackgroundPromptHandoff` is experimental, high risk, + restart-required, and off in prod, dev, and E2E unless explicitly overridden. +- Flag off preserves Task 01's coarse admission and activity behavior. +- Flag on restores every ADR-0049 background mode for `claude-acp` sessions. +- Flag on never relaxes admission or activity for another real provider. +- Desktop and mobile Playwright cover both the default and opt-in contracts. + +## Verification + +- Targeted config, runtime-flag, orchestrator, handler, and frontend tests. +- Targeted desktop and mobile Playwright for coarse and experimental behavior. +- `make fmt` +- `make typecheck test lint` +- Public documentation validation. + +## Files likely touched + +- `apps/backend/internal/profiles/profiles.yaml` +- `apps/backend/internal/common/config/config.go` +- `apps/backend/internal/runtimeflags/` +- `apps/backend/internal/backendapp/orchestrator.go` +- `apps/backend/internal/orchestrator/` +- `apps/backend/internal/task/handlers/message_handlers.go` +- `apps/web/lib/state/slices/features/` +- `apps/web/e2e/tests/chat/` +- `docs/decisions/2026-07-28-coarse-running-busy-signal.md` +- `docs/specs/platform/background-work-liveness.md` +- `docs/public/configuration.md` +- `docs/public/tasks-and-workflows.md` +- `docs/public/websocket-api.md` + +## Dependencies + +Task 01 supplies the safe default and retains the internal tracker. + +## Parallelism + +Sequential. The config, admission, publication, and client contracts must use +one policy and be proven together. + +## Verification Results + +- `make fmt` — passed. +- `make typecheck test lint` — passed: + - Go suite, including orchestrator concurrency tests. + - Web: 922 files passed; 7,050 tests passed; 4 skipped. + - CLI: 30 files and 280 tests passed. + - Backend, web, and harness lint passed. +- `cd apps && pnpm --filter @kandev/web e2e:run --host tests/chat/busy-signal.spec.ts` + — default-off desktop + cases passed; the first opt-in run found and drove the persisted provider + discriminator fix (`agent_name`, not the database UUID in `agent_id`). +- `cd apps && pnpm --filter @kandev/web e2e:run --host --no-build tests/chat/busy-signal.spec.ts -- --grep "Claude background prompt handoff experiment"` + — 2 passed. +- `cd apps && pnpm --filter @kandev/web e2e:run --host --no-build --project mobile-chrome tests/chat/mobile-busy-signal.spec.ts` + — default-off mobile passed; the opt-in test exposed a desktop-keyboard test + helper mismatch. +- `cd apps && pnpm --filter @kandev/web e2e:run --host --no-build --project mobile-chrome tests/chat/mobile-busy-signal.spec.ts -- --grep "background-only work"` + — 1 passed after switching the mobile flow to touch submission. +- Public documentation validation passed as part of `make test`. diff --git a/docs/public/configuration.md b/docs/public/configuration.md index ca589ad338..6835615561 100644 --- a/docs/public/configuration.md +++ b/docs/public/configuration.md @@ -118,6 +118,7 @@ The launcher starts `agentctl`, performs a one-time nonce handshake, and supplie | `features.office` | `KANDEV_FEATURES_OFFICE` | `false` in production | Experimental Office UI, routes, services, and automation. | | `features.plugins` | `KANDEV_FEATURES_PLUGINS` | `false` in production | Extensible plugin system: install/manage plugins, spawn plugin backends, and load native UI bundles. Loaded plugin code runs with backend privileges. | | `features.app_status_bar` | `KANDEV_FEATURES_APP_STATUS_BAR` | `false` in production | Opt-in global connection/host-metrics/plugin surface: bottom bar on tablet/desktop and Status drawer on phones. | +| `features.claude_background_prompt_handoff` | `KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF` | `false` | High-risk experiment that lets Claude Code accept a new prompt after its foreground yields while adapter-attested background work remains active. Other providers keep the coarse busy gate. | Do not infer security from `auth.jwtSecret`: setting it currently does not turn the local server into an authenticated public service. Office's JWT key has a narrower, active purpose. Store both active secrets and third-party API keys in your deployment secret manager; never commit them in `config.yaml`. @@ -294,11 +295,12 @@ Copying this entire file is unnecessary and can freeze old defaults in a deploym | `features.office` | `KANDEV_FEATURES_OFFICE` | off | Experimental autonomous-agent Office surfaces and automation. | | `features.plugins` | `KANDEV_FEATURES_PLUGINS` | off | Extensible plugin system: install/manage plugins and load their backends and native UI bundles. | | `features.appStatusBar` | `KANDEV_FEATURES_APP_STATUS_BAR` | off | Global status bar on tablet/desktop and Status drawer entry on phones. Enabling changes visibility only. | +| `features.claudeBackgroundPromptHandoff` | `KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF` | off | High-risk Claude Code experiment that exposes recognized background-only activity and admits a successor prompt. | | `debug.devMode` | `KANDEV_DEBUG_DEV_MODE` (also locked by explicit legacy/debug-message vars) | off | High-risk diagnostic endpoints and ACP frame logging. | UI changes are persisted in the database and require a restart. An explicitly set environment value wins and locks the UI control. Otherwise a database override wins over the embedded profile/default. Resetting a toggle removes its database override. -The source checkout's `make dev` activates the embedded development profile, which enables Office, debug surfaces, ACP logging, and a mock agent; the App status bar remains opt-in. Installed `run`/desktop builds select the safe production profile unless the environment explicitly opts in. E2E mock variables and routes are test-only and must never be enabled on a public deployment. +The source checkout's `make dev` activates the embedded development profile, which enables Office, debug surfaces, ACP logging, and a mock agent; the App status bar and Claude background prompt handoff remain opt-in. Installed `run`/desktop builds select the safe production profile unless the environment explicitly opts in. E2E mock variables and routes are test-only and must never be enabled on a public deployment. ## Credentials and product settings diff --git a/docs/public/feature-status.md b/docs/public/feature-status.md index 8cb6a145b3..92939b4ae8 100644 --- a/docs/public/feature-status.md +++ b/docs/public/feature-status.md @@ -100,9 +100,10 @@ Released builds can lag this page. Check **Settings > System > About** or `kande | Statistics and host resource metrics | Supported | Workspace stats and on-demand CPU/memory/disk display are shipped. Execution-environment metrics depend on executor/runtime support. | | SQLite backups, database maintenance, status, and logs | Supported | Backups use SQLite `VACUUM INTO` snapshots under the data root. Retention, free space, file permissions, and off-host copies remain operator responsibilities. | | Update checking and applying updates | Dependency-bound | Checking is shipped. Package-manager installs update through their package manager; backend self-update is service-install-specific; desktop updater support varies by artifact/platform. | -| Feature Toggle settings | Supported | The page exposes Office, Plugins, and Debug overrides, environment locks, and restart-required state. Debug is high risk because logs/endpoints can expose prompt, file, and tool data. A toggle being present does not promote its target feature to supported. | +| Feature Toggle settings | Supported | The page exposes Office, Plugins, Claude background prompt handoff, and Debug overrides, environment locks, and restart-required state. Claude background handoff and Debug are high risk. A toggle being present does not promote its target feature to supported. | | Office mode | In progress | Production defaults `KANDEV_FEATURES_OFFICE=false`; development and E2E enable it for implementation/testing. Its routes, agents, labels, documents, dependencies, routines, skills, routing, costs, and approvals may change between releases. | | Plugin system | In progress | Production defaults `KANDEV_FEATURES_PLUGINS=false`; development and E2E enable it for implementation/testing. Loaded plugin code runs with backend privileges; manifest schema, capabilities, and host API may change between releases. | +| Claude background prompt handoff | Experimental | Defaults `KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF=false` in every profile. When explicitly enabled, only Claude Code sessions can expose recognized background-only activity and accept a successor prompt. ACP lifecycle gaps can still misclassify activity or overlap prompts. | | Mock providers and E2E-only routes | Internal | They are selected only by development/test runtime profiles and must not be exposed as product integrations. | ## Publication boundary diff --git a/docs/public/operations.md b/docs/public/operations.md index 2f5f079938..3da7be6fe7 100644 --- a/docs/public/operations.md +++ b/docs/public/operations.md @@ -267,6 +267,7 @@ These metrics are lightweight UI observability. Set alerts, retention, CPU/memor - **Office mode** — experimental, medium risk, and off in the production profile by default. - **App status bar** — stable, low risk, and off in the production profile by default. Enabling it adds the desktop/tablet bar and phone Status entry after restart; disabling it again does not stop connections, metrics collection requested by other clients, or plugins. +- **Claude background prompt handoff** — experimental, high risk, and off in every profile by default. Enabling it lets Claude Code accept another prompt after its foreground yields while recognized async subagent, `run_in_background` shell, or Monitor work remains active. ACP lifecycle gaps can misclassify activity or overlap prompts; use it only for controlled testing. - **Debug mode** — high risk; enables diagnostic endpoints and agent-message logging that can contain sensitive content. Each requires restart. A value supplied explicitly by its environment variable locks the UI control; the debug toggle is also locked by explicit legacy/debug-message environment variables. Otherwise the UI stores an override in the database. The page can request restart only when the native local supervisor is available. A normal Unix `kandev` terminal launch is supervised; Desktop, a service, a container, a directly started backend, a deploy preview, or Windows requires a manual application restart. diff --git a/docs/public/sessions-and-review.md b/docs/public/sessions-and-review.md index 60466f4f31..7610d888d6 100644 --- a/docs/public/sessions-and-review.md +++ b/docs/public/sessions-and-review.md @@ -144,7 +144,7 @@ Pending inline comments are scoped to the current review session but persist onl ## Generate a walkthrough -Select **Walkthrough** from Changes or Review. Kandev sends the built-in `changes-walkthrough` prompt to the active session. If the agent is actively generating, the request queues; if it is idle — or only waiting on background work it spawned — it starts a new turn immediately. The agent must have task MCP and must call `show_walkthrough_kandev` with an ordered list of file and line anchors. +Select **Walkthrough** from Changes or Review. Kandev sends the built-in `changes-walkthrough` prompt to the active session. If the agent is actively generating, the request queues; if it is idle, it starts a new turn immediately. A running Claude Code session that is only waiting on recognized background work also starts immediately when the high-risk **Claude background prompt handoff** experiment is enabled; the default behavior queues it. The agent must have task MCP and must call `show_walkthrough_kandev` with an ordered list of file and line anchors.