diff --git a/apps/backend/internal/agent/runtime/lifecycle/event_types.go b/apps/backend/internal/agent/runtime/lifecycle/event_types.go index fb604e82a1..04ec10cd13 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/event_types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/event_types.go @@ -187,6 +187,9 @@ type AgentStreamEventData struct { // ConfigBaselineCandidate is an authoritative startup response snapshot. // ConfigOptions remains the latest live provider state. ConfigBaselineCandidate []streams.ConfigOption `json:"config_baseline_candidate,omitempty"` + // OriginalConfigCandidate is the profile-settled state captured before + // runtime/workflow overrides are applied. + OriginalConfigCandidate []streams.ConfigOption `json:"original_config_candidate,omitempty"` // Session info (from "session_info" event) SessionTitle string `json:"session_title,omitempty"` diff --git a/apps/backend/internal/agent/runtime/lifecycle/events.go b/apps/backend/internal/agent/runtime/lifecycle/events.go index 13fdc92849..ba985b7106 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/events.go +++ b/apps/backend/internal/agent/runtime/lifecycle/events.go @@ -205,6 +205,7 @@ func (p *EventPublisher) PublishAgentStreamEvent(execution *AgentExecution, even SessionModels: event.SessionModels, ConfigOptions: event.ConfigOptions, ConfigBaselineCandidate: event.ConfigBaselineCandidate, + OriginalConfigCandidate: event.OriginalConfigCandidate, SessionTitle: event.SessionTitle, SessionUpdatedAt: event.SessionUpdatedAt, SessionMeta: event.SessionMeta, diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_profile.go b/apps/backend/internal/agent/runtime/lifecycle/manager_profile.go index 700051c445..805fa5be4d 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_profile.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_profile.go @@ -169,8 +169,24 @@ func (m *Manager) resolveProfileSessionConfig(ctx context.Context, profileID str // values across process recovery. func (m *Manager) initializeACPSession(ctx context.Context, execution *AgentExecution, agentConfig agents.Agent, taskDescription string, attachments []MessageAttachment, mcpServers []agentctltypes.McpServer) error { profileModel, profileMode, profileConfigOptions := m.resolveProfileSessionConfig(ctx, execution.AgentProfileID) - model, mode, configOptions := m.effectiveSessionRuntimeConfig(ctx, execution, profileModel, profileMode, profileConfigOptions) - return m.sessionManager.InitializeAndPrompt(ctx, execution, agentConfig, taskDescription, attachments, mcpServers, m.MarkBootReady, model, mode, configOptions) + runtimeModel, runtimeMode, runtimeConfigOptions := m.sessionRuntimeOverrides(ctx, execution) + return m.sessionManager.InitializeAndPromptWithLayers( + ctx, execution, agentConfig, taskDescription, attachments, mcpServers, m.MarkBootReady, + profileModel, profileMode, profileConfigOptions, + runtimeModel, runtimeMode, runtimeConfigOptions, + ) +} + +func (m *Manager) sessionRuntimeOverrides(ctx context.Context, execution *AgentExecution) (string, string, map[string]string) { + info := m.sessionWorkspaceInfo(ctx, execution) + if info == nil { + return "", "", nil + } + var options map[string]string + if info.RuntimeConfigOptionsSet { + options = maps.Clone(info.RuntimeConfigOptions) + } + return info.RuntimeModel, info.SessionMode, options } func (m *Manager) effectiveSessionRuntimeConfig(ctx context.Context, execution *AgentExecution, profileModel, profileMode string, profileConfigOptions map[string]string) (string, string, map[string]string) { diff --git a/apps/backend/internal/agent/runtime/lifecycle/session.go b/apps/backend/internal/agent/runtime/lifecycle/session.go index a18e900609..9bd4067f10 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/session.go +++ b/apps/backend/internal/agent/runtime/lifecycle/session.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sort" "strings" "time" @@ -293,122 +294,58 @@ func (sm *SessionManager) InitializeAndPrompt( profileMode string, profileConfigOptions map[string]string, ) error { - // Create session-level trace span to group all operations under one trace - _, sessionSpan := tracing.TraceSessionStart( - context.Background(), execution.TaskID, execution.SessionID, execution.ID, + // Preserve the legacy call contract for direct callers: its supplied + // configuration is the final runtime layer. The lifecycle manager uses the + // layered entry point below so profile settings can be snapshotted first. + return sm.InitializeAndPromptWithLayers( + ctx, execution, agentConfig, taskDescription, attachments, mcpServers, + markReady, + "", "", nil, + profileModel, profileMode, profileConfigOptions, ) - execution.SetSessionSpan(sessionSpan) - ctx = trace.ContextWithSpan(ctx, sessionSpan) - if execution.agentctl != nil { - execution.agentctl.SetTraceContext(execution.SessionTraceContext()) - } +} - // Create short-lived init span so the init phase is visible in trace backends - // (the parent session span won't be exported until the session ends) - ctx, initSpan := tracing.TraceSessionInit(ctx, execution.TaskID, execution.SessionID, execution.ID) - defer initSpan.End() - - rt := agentConfig.Runtime() - sm.logger.Info("initializing ACP session", - zap.String("execution_id", execution.ID), - zap.String("agentctl_url", execution.agentctl.BaseURL()), - zap.String("agent_type", agentConfig.ID()), - zap.String("existing_acp_session_id", execution.ACPSessionID), - zap.Bool("native_session_resume", rt.SessionConfig.NativeSessionResume)) - - // Connect WebSocket streams FIRST — agent operations now go over the stream - if sm.streamManager != nil { - updatesReady := make(chan struct{}) - sm.streamManager.ConnectAll(execution, updatesReady) - - // Wait for the updates stream to connect — required for agent operations - select { - case <-updatesReady: - sm.logger.Debug("updates stream ready") - case <-time.After(10 * time.Second): - return fmt.Errorf("timeout waiting for agent stream to connect") - } - } - - // Use InitializeSession for configuration-driven session initialization - result, err := sm.InitializeSession( - ctx, - execution.agentctl, - agentConfig, - execution.ACPSessionID, - execution.WorkspacePath, - mcpServers, - ) +// InitializeAndPromptWithLayers performs ACP initialization with a distinct +// profile layer and final runtime layer. The original effective snapshot is +// published after the profile layer and before the runtime layer. +func (sm *SessionManager) InitializeAndPromptWithLayers( + ctx context.Context, + execution *AgentExecution, + agentConfig agents.Agent, + taskDescription string, + attachments []MessageAttachment, + mcpServers []agentctltypes.McpServer, + markReady func(executionID string) error, + profileModel string, + profileMode string, + profileConfigOptions map[string]string, + runtimeModel string, + runtimeMode string, + runtimeConfigOptions map[string]string, +) error { + ctx, result, err := sm.initializeACPConnection(ctx, execution, agentConfig, mcpServers) if err != nil { - sm.logger.Error("session initialization failed", - zap.String("execution_id", execution.ID), - zap.Error(err)) return err } - sm.logger.Info("ACP session initialized", - zap.String("execution_id", execution.ID), - zap.String("agent_name", result.AgentName), - zap.String("agent_version", result.AgentVersion), - zap.String("session_id", result.SessionID)) - execution.ACPSessionID = result.SessionID execution.sessionInitialized = true providerDefaultConfig := execution.GetModelState() - finalConfigID := "" + finalConfigID, profileModelApplied, profileModeApplied, profileConfigOptionsApplied := sm.applyProfileSessionLayers( + ctx, execution, result.SessionID, profileModel, profileMode, profileConfigOptions, + ) - // Apply profile model through the ACP session's advertised model-selection - // mechanism (best-effort). ACP is the only surface for model selection now; - // no --model CLI flag. - if profileModel != "" && execution.agentctl != nil { - if err := execution.agentctl.SetModel(ctx, profileModel); err != nil { - sm.logger.Warn("failed to set profile model via ACP", - zap.String("execution_id", execution.ID), - zap.String("model", profileModel), - zap.Error(err)) - } else { - finalConfigID = modelConfigIDFromState(execution.GetModelState()) - sm.logger.Info("set profile model on ACP session", - zap.String("execution_id", execution.ID), - zap.String("model", profileModel)) - } - } + // Capture the effective profile state before runtime overrides or workflow + // launch settings are applied. This event is intentionally separate from + // ConfigBaselineCandidate, which remains provider-default comparison data. + sm.publishOriginalConfigOptionsWithProfile(execution, result.SessionID, profileModelApplied, profileConfigOptionsApplied) - // Apply profile mode via ACP session/set_mode (best-effort). - if profileMode != "" && execution.agentctl != nil { - if err := execution.agentctl.SetMode(ctx, result.SessionID, profileMode); err != nil { - sm.logger.Warn("failed to set profile mode via ACP", - zap.String("execution_id", execution.ID), - zap.String("mode", profileMode), - zap.Error(err)) - } else { - sm.logger.Info("set profile mode on ACP session", - zap.String("execution_id", execution.ID), - zap.String("mode", profileMode)) - } - } - - // Apply any dynamic ACP config options saved on the profile. Model and - // mode are handled above so their existing semantics stay unchanged. - for configID, value := range profileconfig.SanitizeConfigOptions(profileConfigOptions) { - if execution.agentctl == nil { - break - } - if err := execution.agentctl.SetConfigOption(ctx, configID, value); err != nil { - sm.logger.Warn("failed to set profile config option via ACP", - zap.String("execution_id", execution.ID), - zap.String("config_id", configID), - zap.String("value", value), - zap.Error(err)) - } else { - finalConfigID = configID - sm.logger.Info("set profile config option on ACP session", - zap.String("execution_id", execution.ID), - zap.String("config_id", configID), - zap.String("value", value)) - } - } + finalConfigID, runtimeFailures := sm.applyRuntimeSessionLayers( + ctx, execution, result.SessionID, finalConfigID, + profileModelApplied, profileModeApplied, runtimeModel, runtimeMode, runtimeConfigOptions, + ) sm.publishSettledConfigOptions(execution, result.SessionID, finalConfigID, providerDefaultConfig) + sm.publishWorkflowSessionConfigFailures(execution, result.SessionID, runtimeFailures) // Publish session created event if sm.eventPublisher != nil { @@ -421,6 +358,157 @@ func (sm *SessionManager) InitializeAndPrompt( return nil } +func (sm *SessionManager) initializeACPConnection( + ctx context.Context, + execution *AgentExecution, + agentConfig agents.Agent, + mcpServers []agentctltypes.McpServer, +) (context.Context, *InitializeResult, error) { + _, sessionSpan := tracing.TraceSessionStart( + context.Background(), execution.TaskID, execution.SessionID, execution.ID, + ) + execution.SetSessionSpan(sessionSpan) + ctx = trace.ContextWithSpan(ctx, sessionSpan) + if execution.agentctl != nil { + execution.agentctl.SetTraceContext(execution.SessionTraceContext()) + } + ctx, initSpan := tracing.TraceSessionInit(ctx, execution.TaskID, execution.SessionID, execution.ID) + defer initSpan.End() + + rt := agentConfig.Runtime() + sm.logger.Info("initializing ACP session", + zap.String("execution_id", execution.ID), zap.String("agentctl_url", execution.agentctl.BaseURL()), + zap.String("agent_type", agentConfig.ID()), zap.String("existing_acp_session_id", execution.ACPSessionID), + zap.Bool("native_session_resume", rt.SessionConfig.NativeSessionResume)) + if sm.streamManager != nil { + updatesReady := make(chan struct{}) + sm.streamManager.ConnectAll(execution, updatesReady) + select { + case <-updatesReady: + sm.logger.Debug("updates stream ready") + case <-time.After(10 * time.Second): + return ctx, nil, fmt.Errorf("timeout waiting for agent stream to connect") + } + } + result, err := sm.InitializeSession(ctx, execution.agentctl, agentConfig, execution.ACPSessionID, execution.WorkspacePath, mcpServers) + if err != nil { + sm.logger.Error("session initialization failed", zap.String("execution_id", execution.ID), zap.Error(err)) + return ctx, nil, err + } + sm.logger.Info("ACP session initialized", + zap.String("execution_id", execution.ID), zap.String("agent_name", result.AgentName), + zap.String("agent_version", result.AgentVersion), zap.String("session_id", result.SessionID)) + return ctx, result, nil +} + +func (sm *SessionManager) applyProfileSessionLayers( + ctx context.Context, + execution *AgentExecution, + acpSessionID string, + profileModel string, + profileMode string, + profileConfigOptions map[string]string, +) (string, string, string, map[string]string) { + if execution.agentctl == nil { + return "", "", "", nil + } + finalConfigID := "" + profileModelApplied := "" + profileModeApplied := "" + profileConfigOptionsApplied := make(map[string]string) + if profileModel != "" { + if err := execution.agentctl.SetModel(ctx, profileModel); err != nil { + sm.logger.Warn("failed to set profile model via ACP", + zap.String("execution_id", execution.ID), zap.String("model", profileModel), zap.Error(err)) + } else { + finalConfigID = modelConfigIDFromState(execution.GetModelState()) + profileModelApplied = profileModel + sm.logger.Info("set profile model on ACP session", + zap.String("execution_id", execution.ID), zap.String("model", profileModel)) + } + } + if profileMode != "" { + if err := execution.agentctl.SetMode(ctx, acpSessionID, profileMode); err != nil { + sm.logger.Warn("failed to set profile mode via ACP", + zap.String("execution_id", execution.ID), zap.String("mode", profileMode), zap.Error(err)) + } else { + profileModeApplied = profileMode + sm.logger.Info("set profile mode on ACP session", + zap.String("execution_id", execution.ID), zap.String("mode", profileMode)) + } + } + sanitizedOptions := profileconfig.SanitizeConfigOptions(profileConfigOptions) + for _, configID := range sortedConfigOptionKeys(sanitizedOptions) { + value := sanitizedOptions[configID] + if err := execution.agentctl.SetConfigOption(ctx, configID, value); err != nil { + sm.logger.Warn("failed to set profile config option via ACP", + zap.String("execution_id", execution.ID), zap.String("config_id", configID), + zap.String("value", value), zap.Error(err)) + continue + } + finalConfigID = configID + profileConfigOptionsApplied[configID] = value + sm.logger.Info("set profile config option on ACP session", + zap.String("execution_id", execution.ID), zap.String("config_id", configID), zap.String("value", value)) + } + return finalConfigID, profileModelApplied, profileModeApplied, profileConfigOptionsApplied +} + +func (sm *SessionManager) applyRuntimeSessionLayers( + ctx context.Context, + execution *AgentExecution, + acpSessionID string, + finalConfigID string, + profileModel string, + profileMode string, + runtimeModel string, + runtimeMode string, + runtimeConfigOptions map[string]string, +) (string, []string) { + failed := make([]string, 0) + if execution.agentctl == nil { + return finalConfigID, failed + } + if runtimeModel != "" && runtimeModel != profileModel { + if err := execution.agentctl.SetModel(ctx, runtimeModel); err != nil { + failed = append(failed, "model") + sm.logger.Warn("failed to set runtime model via ACP", + zap.String("execution_id", execution.ID), zap.String("model", runtimeModel), zap.Error(err)) + } else { + finalConfigID = modelConfigIDFromState(execution.GetModelState()) + } + } + if runtimeMode != "" && runtimeMode != profileMode { + if err := execution.agentctl.SetMode(ctx, acpSessionID, runtimeMode); err != nil { + failed = append(failed, "mode") + sm.logger.Warn("failed to set runtime mode via ACP", + zap.String("execution_id", execution.ID), zap.String("mode", runtimeMode), zap.Error(err)) + } + } + sanitizedOptions := profileconfig.SanitizeConfigOptions(runtimeConfigOptions) + for _, configID := range sortedConfigOptionKeys(sanitizedOptions) { + value := sanitizedOptions[configID] + if err := execution.agentctl.SetConfigOption(ctx, configID, value); err != nil { + failed = append(failed, configID) + sm.logger.Warn("failed to set runtime config option via ACP", + zap.String("execution_id", execution.ID), zap.String("config_id", configID), + zap.String("value", value), zap.Error(err)) + continue + } + finalConfigID = configID + } + return finalConfigID, failed +} + +func sortedConfigOptionKeys(options map[string]string) []string { + keys := make([]string, 0, len(options)) + for key := range options { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + func (sm *SessionManager) publishSettledConfigOptions( execution *AgentExecution, acpSessionID string, @@ -445,6 +533,83 @@ func (sm *SessionManager) publishSettledConfigOptions( }) } +func (sm *SessionManager) publishWorkflowSessionConfigFailures( + execution *AgentExecution, + acpSessionID string, + failures []string, +) { + if sm.eventPublisher == nil || len(failures) == 0 { + return + } + sm.eventPublisher.PublishAgentStreamEvent(execution, agentctl.AgentEvent{ + Type: streams.EventTypeSessionModels, + SessionID: acpSessionID, + Data: map[string]any{ + "workflow_session_config_failures": append([]string(nil), failures...), + }, + }) +} + +// publishOriginalConfigOptions publishes the profile-settled model and option +// values before any runtime override layer is applied. The orchestrator uses +// the marker to write the task session's immutable original snapshot once. +func (sm *SessionManager) publishOriginalConfigOptions(execution *AgentExecution, acpSessionID string) { + sm.publishOriginalConfigOptionsWithProfile(execution, acpSessionID, "", nil) +} + +func (sm *SessionManager) publishOriginalConfigOptionsWithProfile( + execution *AgentExecution, + acpSessionID string, + profileModel string, + profileConfigOptions map[string]string, +) { + if sm.eventPublisher == nil || execution == nil { + return + } + state := execution.GetModelState() + if state == nil { + return + } + options := make([]streams.ConfigOption, len(state.ConfigOptions)) + copy(options, state.ConfigOptions) + currentModel := state.CurrentModelID + if profileModel != "" { + currentModel = profileModel + } + if currentModel != "" { + if !setConfigOptionValue(options, modelConfigOptionID, currentModel) { + options = append(options, streams.ConfigOption{ID: modelConfigOptionID, Category: modelConfigOptionID, CurrentValue: currentModel}) + } + } + for configID, value := range profileconfig.SanitizeConfigOptions(profileConfigOptions) { + if !setConfigOptionValue(options, configID, value) { + options = append(options, streams.ConfigOption{ID: configID, CurrentValue: value}) + } + } + if currentModel == "" && len(options) == 0 { + return + } + sm.eventPublisher.PublishAgentStreamEvent(execution, agentctl.AgentEvent{ + Type: streams.EventTypeSessionModels, + SessionID: acpSessionID, + CurrentModelID: currentModel, + SessionModels: state.Models, + ConfigOptions: options, + OriginalConfigCandidate: options, + Data: map[string]any{"original_config_settled": true}, + }) +} + +func setConfigOptionValue(options []streams.ConfigOption, configID, value string) bool { + for index := range options { + if options[index].ID == configID || (configID == modelConfigOptionID && options[index].Category == modelConfigOptionID) { + options[index].CurrentValue = value + return true + } + } + return false +} + func modelConfigIDFromState(state *CachedModelState) string { if state == nil { return modelConfigOptionID diff --git a/apps/backend/internal/agent/runtime/lifecycle/session_test.go b/apps/backend/internal/agent/runtime/lifecycle/session_test.go index bc97c9feee..7f5df2d0c6 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/session_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/session_test.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "net/http/httptest" + "reflect" "strings" "sync" "testing" @@ -398,6 +399,34 @@ func settledConfigEventData(data any) bool { return settled } +func TestPublishWorkflowSessionConfigFailures(t *testing.T) { + log := newSessionTestLogger() + eventBus := &MockEventBusWithTracking{} + sm := NewSessionManager(log, nil) + sm.SetDependencies(NewEventPublisher(eventBus, log), nil, nil, nil) + execution := &AgentExecution{ID: "exec-1", TaskID: "task-1", SessionID: "session-1"} + + sm.publishWorkflowSessionConfigFailures(execution, "acp-session-1", []string{"model", "reasoning_effort"}) + + events := eventBus.getStreamEvents() + if len(events) != 1 { + t.Fatalf("stream events = %d, want one failure event", len(events)) + } + metadata, ok := events[0].Data.Data.(map[string]any) + if !ok { + t.Fatalf("event data = %#v, want metadata map", events[0].Data.Data) + } + if got := metadata["workflow_session_config_failures"]; !reflect.DeepEqual(got, []string{"model", "reasoning_effort"}) { + t.Fatalf("failure metadata = %#v, want model and option", got) + } +} + +func originalConfigEventData(data any) bool { + metadata, _ := data.(map[string]any) + settled, _ := metadata["original_config_settled"].(bool) + return settled +} + func configValueByID(options []streams.ConfigOption, id string) string { for _, option := range options { if option.ID == id { @@ -509,6 +538,79 @@ func TestConfigBaselineRetainsProviderDefaultsWhenProfileOverrideSettles(t *test } } +func TestPublishOriginalConfigOptionsUsesProfileSettledState(t *testing.T) { + log := newSessionTestLogger() + eventBus := &MockEventBusWithTracking{} + publisher := NewEventPublisher(eventBus, log) + sm := NewSessionManager(log, nil) + sm.SetDependencies(publisher, nil, nil, nil) + execution := &AgentExecution{ID: "exec-1", TaskID: "task-1", SessionID: "session-1"} + execution.SetModelState(&CachedModelState{ + CurrentModelID: "gpt-5.6-sol", + Models: []streams.SessionModelInfo{{ModelID: "gpt-5.6-sol"}}, + ConfigOptions: []streams.ConfigOption{ + {ID: "model", Category: "model", CurrentValue: "gpt-5.6-sol"}, + {ID: "reasoning_effort", CurrentValue: "high"}, + }, + }) + + sm.publishOriginalConfigOptions(execution, "acp-session-1") + + events := eventBus.getStreamEvents() + if len(events) != 1 { + t.Fatalf("published events = %d, want 1", len(events)) + } + payload := events[0].Data + if payload == nil { + t.Fatal("expected stream payload") + } + if got := payload.CurrentModelID; got != "gpt-5.6-sol" { + t.Fatalf("original model = %q, want profile model", got) + } + if got := configValueByID(payload.OriginalConfigCandidate, "reasoning_effort"); got != "high" { + t.Fatalf("original reasoning effort = %q, want high", got) + } + if !originalConfigEventData(payload.Data) { + t.Fatalf("event data = %#v, want original_config_settled marker", payload.Data) + } +} + +func TestPublishOriginalConfigOptionsAppliesProfileValuesWhenStreamStateLags(t *testing.T) { + log := newSessionTestLogger() + eventBus := &MockEventBusWithTracking{} + publisher := NewEventPublisher(eventBus, log) + sm := NewSessionManager(log, nil) + sm.SetDependencies(publisher, nil, nil, nil) + execution := &AgentExecution{ID: "exec-1", TaskID: "task-1", SessionID: "session-1"} + execution.SetModelState(&CachedModelState{ + CurrentModelID: "provider-default", + ConfigOptions: []streams.ConfigOption{ + {ID: "model", Category: "model", CurrentValue: "provider-default"}, + {ID: "reasoning_effort", CurrentValue: "medium"}, + {ID: "fast_mode", CurrentValue: "off"}, + }, + }) + + sm.publishOriginalConfigOptionsWithProfile(execution, "acp-session-1", "gpt-5.6-sol", map[string]string{ + "reasoning_effort": "max", + }) + + events := eventBus.getStreamEvents() + if len(events) != 1 { + t.Fatalf("published events = %d, want 1", len(events)) + } + payload := events[0].Data + if payload.CurrentModelID != "gpt-5.6-sol" { + t.Fatalf("original model = %q, want profile model", payload.CurrentModelID) + } + if got := configValueByID(payload.OriginalConfigCandidate, "reasoning_effort"); got != "max" { + t.Fatalf("original reasoning effort = %q, want max", got) + } + if got := configValueByID(payload.OriginalConfigCandidate, "fast_mode"); got != "off" { + t.Fatalf("provider default fast mode = %q, want off", got) + } +} + func TestConfigSettlementAuthoritativeResponseOrdering(t *testing.T) { newHarness := func() (*SessionManager, *Manager, *AgentExecution, *MockEventBusWithTracking) { log := newSessionTestLogger() diff --git a/apps/backend/internal/agentctl/types/streams/agent.go b/apps/backend/internal/agentctl/types/streams/agent.go index fa4f5cc979..2a562f6cb1 100644 --- a/apps/backend/internal/agentctl/types/streams/agent.go +++ b/apps/backend/internal/agentctl/types/streams/agent.go @@ -291,6 +291,11 @@ type AgentEvent struct { // lifecycle settlement without replacing the event's newer live options. ConfigBaselineCandidate []ConfigOption `json:"config_baseline_candidate,omitempty"` + // OriginalConfigCandidate is the profile-settled model-adjacent state used + // once to capture a task session's immutable original configuration. It is + // emitted before runtime/workflow overrides are applied. + OriginalConfigCandidate []ConfigOption `json:"original_config_candidate,omitempty"` + // --- Session info fields --- // SessionTitle is the agent-provided human-readable ACP session title. diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go index bbbb45e29c..533ea12197 100644 --- a/apps/backend/internal/backendapp/adapters.go +++ b/apps/backend/internal/backendapp/adapters.go @@ -433,6 +433,14 @@ func (a *lifecycleAdapter) SetSessionModelBySessionID(ctx context.Context, sessi return a.mgr.SetSessionModelBySessionID(ctx, sessionID, modelID) } +// SetSessionConfigOptionBySessionID applies an ACP dynamic session option. +// Workflow conditional configuration uses this optional seam so older agent +// manager test doubles and non-ACP providers can fail closed without widening +// the executor launch contract. +func (a *lifecycleAdapter) SetSessionConfigOptionBySessionID(ctx context.Context, sessionID, configID, value string) error { + return a.mgr.SetSessionConfigOptionBySessionID(ctx, sessionID, configID, value) +} + // SetSessionModeBySessionID applies a session permission mode via ACP session/set_mode. func (a *lifecycleAdapter) SetSessionModeBySessionID(ctx context.Context, sessionID, modeID string) error { return a.mgr.SetSessionModeBySessionID(ctx, sessionID, modeID) diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go index 845fd9231a..313a675e04 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go @@ -3,6 +3,7 @@ package orchestrator import ( "context" "fmt" + "strings" "time" "go.uber.org/zap" @@ -17,6 +18,8 @@ import ( v1 "github.com/kandev/kandev/pkg/api/v1" ) +const sessionModelConfigKey = "model" + // handleAgentStreamEvent handles agent stream events (tool calls, message chunks, etc.) func (s *Service) handleAgentStreamEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { if payload == nil || payload.Data == nil { @@ -2106,7 +2109,7 @@ func resolvePromptUsageLabels( agentType := "" if session != nil && session.AgentProfileSnapshot != nil { if model == "" { - if m, ok := session.AgentProfileSnapshot["model"].(string); ok { + if m, ok := session.AgentProfileSnapshot[sessionModelConfigKey].(string); ok { model = m } } @@ -2174,7 +2177,7 @@ func (s *Service) persistPromptMetadataOnTurn( } metadata["prompt_usage"] = promptUsageMetadata(payload.Data.Usage) if model != "" { - metadata["model"] = model + metadata[sessionModelConfigKey] = model } if agentType != "" { metadata["agent_type"] = agentType @@ -2382,6 +2385,22 @@ func (s *Service) handleSessionModelsEvent(ctx context.Context, payload *lifecyc if sessionID == "" || s.eventBus == nil { return } + if failures := workflowSessionConfigFailures(payload.Data.Data); len(failures) > 0 { + stepID := "" + if s.repo != nil && payload.TaskID != "" { + if task, err := s.repo.GetTask(ctx, payload.TaskID); err == nil && task != nil { + stepID = task.WorkflowStepID + } + } + s.warnWorkflowSessionConfig(ctx, payload.TaskID, sessionID, stepID, + fmt.Sprintf("Some session settings could not be applied at startup: %s.", strings.Join(failures, ", "))) + return + } + if _, err := s.sessionOriginalEffectiveConfigurationForEvent(ctx, sessionID, payload.Data); err != nil { + s.logger.Warn("failed to persist original effective session configuration", + zap.String("session_id", sessionID), + zap.Error(err)) + } // Store the write-once baseline before the mutable selector snapshot so a // concurrent task-detail boot cannot observe the new state without its @@ -2416,6 +2435,104 @@ func (s *Service) handleSessionModelsEvent(ctx context.Context, payload *lifecyc _ = s.eventBus.Publish(ctx, subject, bus.NewEvent(events.SessionModelsUpdated, "orchestrator", eventPayload)) } +func workflowSessionConfigFailures(raw any) []string { + data, ok := raw.(map[string]any) + if !ok || data == nil { + return nil + } + switch values := data["workflow_session_config_failures"].(type) { + case []string: + return values + case []any: + failures := make([]string, 0, len(values)) + for _, value := range values { + if failure, ok := value.(string); ok && failure != "" { + failures = append(failures, failure) + } + } + return failures + default: + return nil + } +} + +func (s *Service) sessionOriginalEffectiveConfigurationForEvent( + ctx context.Context, + sessionID string, + data *lifecycle.AgentStreamEventData, +) (*models.SessionOriginalEffectiveConfiguration, error) { + if data == nil || !originalConfigSettled(data.Data) || s.repo == nil { + return nil, nil + } + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, err + } + if session == nil || !models.IsOriginalTaskSession(session.Metadata) { + return nil, nil + } + if existing, ok := models.LoadOriginalSessionEffectiveConfiguration(session.Metadata); ok { + return &existing, nil + } + config := originalConfigurationFromEvent(data) + if config.Model == "" && len(config.ConfigOptions) == 0 { + return nil, nil + } + return s.persistOriginalSessionConfiguration(ctx, sessionID, session, config) +} + +func originalConfigurationFromEvent(data *lifecycle.AgentStreamEventData) models.SessionOriginalEffectiveConfiguration { + options := data.OriginalConfigCandidate + if len(options) == 0 { + options = data.ConfigOptions + } + return models.SessionOriginalEffectiveConfiguration{ + Model: data.CurrentModelID, + ConfigOptions: configOptionValuesWithoutModel(options), + } +} + +func (s *Service) persistOriginalSessionConfiguration( + ctx context.Context, + sessionID string, + session *models.TaskSession, + config models.SessionOriginalEffectiveConfiguration, +) (*models.SessionOriginalEffectiveConfiguration, error) { + writeCtx := context.WithoutCancel(ctx) + stored, err := s.repo.SetSessionMetadataKeyIfAbsent( + writeCtx, sessionID, models.SessionMetaKeyOriginalEffectiveConfig, config, + ) + if err != nil { + return nil, err + } + if stored { + return &config, nil + } + return s.originalSessionConfigurationAfterRace(writeCtx, sessionID, session, config) +} + +func (s *Service) originalSessionConfigurationAfterRace( + ctx context.Context, + sessionID string, + session *models.TaskSession, + config models.SessionOriginalEffectiveConfiguration, +) (*models.SessionOriginalEffectiveConfiguration, error) { + if existing, ok := models.LoadOriginalSessionEffectiveConfiguration(session.Metadata); ok { + return &existing, nil + } + fresh, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, err + } + if fresh == nil { + return &config, nil + } + if existing, ok := models.LoadOriginalSessionEffectiveConfiguration(fresh.Metadata); ok { + return &existing, nil + } + return &config, nil +} + // handleSessionMCPAttachmentEvent reduces safe attachment observations into // bounded session metadata and broadcasts the resulting report. A failed // diagnostic write is intentionally isolated from task and agent state. @@ -2515,12 +2632,44 @@ func configOptionValues(options []streams.ConfigOption) map[string]string { return values } +func configOptionValuesWithoutModel(options []streams.ConfigOption) map[string]string { + values := configOptionValues(options) + for key, option := range optionsByID(options) { + // The immutable restore snapshot contains only selectable ACP options. + // Toggle/boolean values are provider state, not model configuration, and + // may be invalid when replayed after a model switch. + if option.Type != "select" || key == sessionModelConfigKey || option.Category == sessionModelConfigKey { + delete(values, key) + } + } + if len(values) == 0 { + return nil + } + return values +} + +func optionsByID(options []streams.ConfigOption) map[string]streams.ConfigOption { + result := make(map[string]streams.ConfigOption, len(options)) + for _, option := range options { + if option.ID != "" { + result[option.ID] = option + } + } + return result +} + func configOptionsSettled(data any) bool { metadata, _ := data.(map[string]any) result, _ := metadata["config_options_settled"].(bool) return result } +func originalConfigSettled(data any) bool { + metadata, _ := data.(map[string]any) + result, _ := metadata["original_config_settled"].(bool) + return result +} + func (s *Service) loadSessionACPConfigBaseline(ctx context.Context, sessionID string) map[string]string { if s.repo == nil { return nil @@ -2644,7 +2793,7 @@ func (s *Service) persistSessionRuntimeConfigOnSession(ctx context.Context, sess s.logger.Warn("failed to clear stale context window after runtime model change", zap.String("session_id", sessionID), zap.String("previous_model", previousModel), - zap.String("model", cfg.Model), + zap.String(sessionModelConfigKey, cfg.Model), zap.Error(err)) } } @@ -2654,10 +2803,10 @@ func (s *Service) persistSessionModelOnSession(ctx context.Context, sessionID st if session.AgentProfileSnapshot == nil { session.AgentProfileSnapshot = make(map[string]interface{}) } - if existing, _ := session.AgentProfileSnapshot["model"].(string); existing == model { + if existing, _ := session.AgentProfileSnapshot[sessionModelConfigKey].(string); existing == model { return } - session.AgentProfileSnapshot["model"] = model + session.AgentProfileSnapshot[sessionModelConfigKey] = model if updater, ok := s.repo.(taskSessionAgentProfileSnapshotUpdater); ok { _ = updater.UpdateTaskSessionAgentProfileSnapshot(ctx, sessionID, session.AgentProfileSnapshot) } else { @@ -2692,7 +2841,7 @@ func applySessionRuntimeConfigUpdate(cfg *models.SessionRuntimeConfig, model, mo cfg.ConfigOptions = make(map[string]string) } cfg.ConfigOptions[option.ID] = option.CurrentValue - if option.ID == "model" || option.Category == "model" { + if option.ID == sessionModelConfigKey || option.Category == sessionModelConfigKey { cfg.Model = option.CurrentValue } } diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming_test.go b/apps/backend/internal/orchestrator/event_handlers_streaming_test.go index 64a39ed008..14a1eff5a3 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming_test.go @@ -2767,6 +2767,53 @@ func TestHandleSessionModelsEventCapturesSettledConfigBaselineOnce(t *testing.T) require.Equal(t, baseline, lastPayload.ConfigBaseline) } +func TestHandleSessionModelsEventCapturesOriginalEffectiveConfigurationOnce(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + require.NoError(t, repo.SetSessionMetadataKey(ctx, "s1", models.SessionMetaKeyOrigin, models.SessionOriginTaskInitial)) + svc := &Service{logger: testLogger(), repo: repo, eventBus: &recordingEventBus{}} + + svc.handleSessionModelsEvent(ctx, &lifecycle.AgentStreamEventPayload{ + TaskID: "t1", + SessionID: "s1", + Data: &lifecycle.AgentStreamEventData{ + CurrentModelID: "gpt-5.6-sol", + OriginalConfigCandidate: []streams.ConfigOption{ + {Type: "select", ID: "reasoning_effort", CurrentValue: "high"}, + {Type: "toggle", ID: "fast_mode", CurrentValue: "on"}, + }, + Data: map[string]any{"original_config_settled": true}, + }, + }) + + updated, err := repo.GetTaskSession(ctx, "s1") + require.NoError(t, err) + original, ok := models.LoadOriginalSessionEffectiveConfiguration(updated.Metadata) + require.True(t, ok) + require.Equal(t, "gpt-5.6-sol", original.Model) + require.Equal(t, map[string]string{"reasoning_effort": "high"}, original.ConfigOptions) + + svc.handleSessionModelsEvent(ctx, &lifecycle.AgentStreamEventPayload{ + TaskID: "t1", + SessionID: "s1", + Data: &lifecycle.AgentStreamEventData{ + CurrentModelID: "gpt-5.6-luna", + OriginalConfigCandidate: []streams.ConfigOption{ + {ID: "reasoning_effort", CurrentValue: "low"}, + }, + Data: map[string]any{"original_config_settled": true}, + }, + }) + + updated, err = repo.GetTaskSession(ctx, "s1") + require.NoError(t, err) + original, ok = models.LoadOriginalSessionEffectiveConfiguration(updated.Metadata) + require.True(t, ok) + require.Equal(t, "gpt-5.6-sol", original.Model) + require.Equal(t, map[string]string{"reasoning_effort": "high"}, original.ConfigOptions) +} + func TestHandleSessionModelsEventStoresBaselineCandidateAndPublishesLiveState(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) diff --git a/apps/backend/internal/orchestrator/event_handlers_test.go b/apps/backend/internal/orchestrator/event_handlers_test.go index f115ccb589..82d4bf4056 100644 --- a/apps/backend/internal/orchestrator/event_handlers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_test.go @@ -293,12 +293,15 @@ type mockAgentManager struct { // set_session_mode tracking (issue #1183). Records (sessionID, modeID) for // every SetSessionModeBySessionID call. setSessionModeErr, when set, is // returned to simulate "no running agent". - setSessionModeCalls []sessionModeCall - setSessionModeErr error - mcpModeCalls []sessionModeCall - setSessionModelCalls []sessionModelCall - setSessionModelSupported bool - setSessionModelErr error + setSessionModeCalls []sessionModeCall + setSessionModeErr error + mcpModeCalls []sessionModeCall + setSessionModelCalls []sessionModelCall + setSessionModelSupported bool + setSessionModelErr error + setSessionConfigCalls []sessionConfigCall + setSessionConfigSupported bool + setSessionConfigErr error } type sessionModelCall struct { @@ -306,6 +309,12 @@ type sessionModelCall struct { ModelID string } +type sessionConfigCall struct { + SessionID string + ConfigID string + Value string +} + type sessionModeCall struct { SessionID string ModeID string @@ -478,6 +487,16 @@ func (m *mockAgentManager) SetSessionModeBySessionID(_ context.Context, sessionI return m.setSessionModeErr } +func (m *mockAgentManager) SetSessionConfigOptionBySessionID(_ context.Context, sessionID, configID, value string) error { + if !m.setSessionConfigSupported { + return fmt.Errorf("not supported") + } + m.mu.Lock() + m.setSessionConfigCalls = append(m.setSessionConfigCalls, sessionConfigCall{SessionID: sessionID, ConfigID: configID, Value: value}) + m.mu.Unlock() + return m.setSessionConfigErr +} + func (m *mockAgentManager) SetMcpMode(_ context.Context, executionID, mode string) error { m.mu.Lock() defer m.mu.Unlock() diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index 6c274879d7..f4945bc060 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -1071,6 +1071,11 @@ func (s *Service) processOnEnter(ctx context.Context, taskID string, session *mo s.markIdleAfterReset(ctx, taskID, sessionID, session, step, isPassthrough) } + // Conditional session configuration is applied after a context reset so the + // new ACP session receives the workflow-selected settings before any + // auto-start prompt is dispatched. It never switches or creates a tab. + s.applyWorkflowSessionConfigOnEnter(ctx, taskID, session, step) + hasAutoStart := false for _, action := range step.Events.OnEnter { switch action.Type { diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_session_config_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_session_config_test.go new file mode 100644 index 0000000000..1d05c5a82d --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_session_config_test.go @@ -0,0 +1,172 @@ +package orchestrator + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" +) + +func TestProcessOnEnterConfigureSessionAppliesMatchingOriginalSession(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task-config", "session-config", "step-config") + if err := repo.SetSessionMetadataKey(ctx, "session-config", models.SessionMetaKeyOrigin, models.SessionOriginTaskInitial); err != nil { + t.Fatalf("mark original session: %v", err) + } + session, err := repo.GetTaskSession(ctx, "session-config") + if err != nil { + t.Fatalf("load session: %v", err) + } + session.AgentProfileSnapshot = map[string]interface{}{"agent_name": "codex"} + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("update session snapshot: %v", err) + } + + agent := &mockAgentManager{isAgentRunning: true, setSessionModelSupported: true, setSessionConfigSupported: true} + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agent) + step := configureSessionStep("step-config", "codex", "set", "gpt-5.6-luna", map[string]string{"reasoning_effort": "max"}) + + svc.processOnEnter(ctx, "task-config", session, step, "") + + if len(agent.setSessionModelCalls) != 1 || agent.setSessionModelCalls[0].ModelID != "gpt-5.6-luna" { + t.Fatalf("model calls = %#v, want one luna switch", agent.setSessionModelCalls) + } + if len(agent.setSessionConfigCalls) != 1 || agent.setSessionConfigCalls[0].ConfigID != "reasoning_effort" || agent.setSessionConfigCalls[0].Value != "max" { + t.Fatalf("config calls = %#v, want max effort", agent.setSessionConfigCalls) + } + + updated, err := repo.GetTaskSession(ctx, "session-config") + if err != nil { + t.Fatalf("reload session: %v", err) + } + overrides, ok := models.LoadSessionRuntimeConfigOverrides(updated.Metadata) + if !ok || overrides.Model != "gpt-5.6-luna" || overrides.ConfigOptions["reasoning_effort"] != "max" { + t.Fatalf("runtime overrides = %#v, want model and effort", overrides) + } +} + +func TestProcessOnEnterConfigureSessionSkipsNonMatchingAgent(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task-config-skip", "session-config-skip", "step-config-skip") + if err := repo.SetSessionMetadataKey(ctx, "session-config-skip", models.SessionMetaKeyOrigin, models.SessionOriginTaskInitial); err != nil { + t.Fatalf("mark original session: %v", err) + } + session, err := repo.GetTaskSession(ctx, "session-config-skip") + if err != nil { + t.Fatalf("load session: %v", err) + } + session.AgentProfileSnapshot = map[string]interface{}{"agent_name": "grok"} + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("update session snapshot: %v", err) + } + + agent := &mockAgentManager{isAgentRunning: true, setSessionModelSupported: true, setSessionConfigSupported: true} + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agent) + step := configureSessionStep("step-config-skip", "codex", "set", "gpt-5.6-luna", map[string]string{"reasoning_effort": "max"}) + + svc.processOnEnter(ctx, "task-config-skip", session, step, "") + + if len(agent.setSessionModelCalls) != 0 || len(agent.setSessionConfigCalls) != 0 { + t.Fatalf("non-matching agent was configured: model=%#v config=%#v", agent.setSessionModelCalls, agent.setSessionConfigCalls) + } +} + +func TestProcessOnEnterConfigureSessionRestoreOriginal(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task-config-restore", "session-config-restore", "step-config-restore") + if err := repo.SetSessionMetadataKey(ctx, "session-config-restore", models.SessionMetaKeyOrigin, models.SessionOriginTaskInitial); err != nil { + t.Fatalf("mark original session: %v", err) + } + session, err := repo.GetTaskSession(ctx, "session-config-restore") + if err != nil { + t.Fatalf("load session: %v", err) + } + session.AgentProfileSnapshot = map[string]interface{}{"agent_name": "claude"} + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("update session snapshot: %v", err) + } + original := models.SessionOriginalEffectiveConfiguration{ + Model: "claude-sonnet-original", + ConfigOptions: map[string]string{"reasoning_effort": "low"}, + } + if err := repo.SetSessionMetadataKey(ctx, "session-config-restore", models.SessionMetaKeyOriginalEffectiveConfig, original); err != nil { + t.Fatalf("persist original config: %v", err) + } + + agent := &mockAgentManager{isAgentRunning: true, setSessionModelSupported: true, setSessionConfigSupported: true} + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agent) + step := configureSessionStep("step-config-restore", "claude", "restore_original", "", nil) + + svc.processOnEnter(ctx, "task-config-restore", session, step, "") + + if len(agent.setSessionModelCalls) != 1 || agent.setSessionModelCalls[0].ModelID != "claude-sonnet-original" { + t.Fatalf("restore model calls = %#v", agent.setSessionModelCalls) + } + if len(agent.setSessionConfigCalls) != 1 || agent.setSessionConfigCalls[0].Value != "low" { + t.Fatalf("restore config calls = %#v", agent.setSessionConfigCalls) + } +} + +func TestApplyWorkflowSessionConfigBeforeLaunchPersistsRuntimeLayer(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task-config-start", "session-config-start", "step-config-start") + if err := repo.SetSessionMetadataKey(ctx, "session-config-start", models.SessionMetaKeyOrigin, models.SessionOriginTaskInitial); err != nil { + t.Fatalf("mark original session: %v", err) + } + session, err := repo.GetTaskSession(ctx, "session-config-start") + if err != nil { + t.Fatalf("load session: %v", err) + } + session.AgentProfileSnapshot = map[string]interface{}{"agent_name": "codex"} + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("update session snapshot: %v", err) + } + + // A CREATED session has no prompt-ready ACP process yet. The matching rule + // must be durable before lifecycle initialization consumes it. + agent := &mockAgentManager{isAgentReadyFn: func(context.Context, string) bool { return false }} + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agent) + step := configureSessionStep("step-config-start", "codex", "set", "gpt-5.6-luna", map[string]string{"reasoning_effort": "max"}) + + svc.applyWorkflowSessionConfigBeforeLaunch(ctx, "task-config-start", session, step) + + updated, err := repo.GetTaskSession(ctx, "session-config-start") + if err != nil { + t.Fatalf("reload session: %v", err) + } + overrides, ok := models.LoadSessionRuntimeConfigOverrides(updated.Metadata) + if !ok || overrides.Model != "gpt-5.6-luna" || overrides.ConfigOptions["reasoning_effort"] != "max" { + t.Fatalf("runtime overrides = %#v, want launch model and effort", overrides) + } +} + +func configureSessionStep(id, agentName, operation, model string, options map[string]string) *wfmodels.WorkflowStep { + config := map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{ + "agent_name": agentName, + "operation": operation, + }, + }, + } + rule := config["rules"].([]interface{})[0].(map[string]interface{}) + if model != "" { + rule["model"] = model + } + if len(options) > 0 { + raw := make(map[string]interface{}, len(options)) + for key, value := range options { + raw[key] = value + } + rule["config_options"] = raw + } + return &wfmodels.WorkflowStep{ID: id, Events: wfmodels.StepEvents{OnEnter: []wfmodels.OnEnterAction{{ + Type: wfmodels.OnEnterConfigureSession, + Config: config, + }}}} +} diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index e7853a964e..d1657603c5 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -85,6 +85,13 @@ type executorStore interface { GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) } +// officeTaskSessionCreator lets repositories make Office-session origin +// selection part of the insert transaction. Test and legacy stores can omit +// it; the executor keeps a per-task fallback lock for those implementations. +type officeTaskSessionCreator interface { + CreateOfficeTaskSession(context.Context, *models.TaskSession) error +} + type primarySessionTaskStateStore interface { UpdateTaskStateIfPrimarySessionState( context.Context, @@ -730,6 +737,10 @@ type Executor struct { // succeeds and the second reuses its env. taskEnvLocks sync.Map // map[string]*sync.Mutex + // Per-task locks serialize Office session creation so concurrent agents + // cannot both observe an empty task and claim the immutable origin marker. + officeSessionLocks sync.Map // map[string]*sync.Mutex + // Optional cloner for provider-backed repos without a local path. repoCloner RepoCloner repoUpdater RepoUpdater @@ -742,6 +753,11 @@ func (e *Executor) taskEnvLock(taskID string) *sync.Mutex { return mu.(*sync.Mutex) } +func (e *Executor) officeSessionLock(taskID string) *sync.Mutex { + mu, _ := e.officeSessionLocks.LoadOrStore(taskID, &sync.Mutex{}) + return mu.(*sync.Mutex) +} + // RepoCloner clones remote repositories to local disk. type RepoCloner interface { EnsureWorkspaceClonedForProvider( diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 0d6b0666fd..ca54207c7e 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -709,10 +709,17 @@ func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfi // Resolve agent profile to get model and other settings for snapshot agentProfileSnapshot, isPassthrough := e.resolveAgentProfileSnapshot(ctx, agentProfileID) - // Determine if this new session should become primary. - // Only the first session for a task is primary by default; subsequent sessions - // leave the existing primary unchanged so the user's explicit choice is preserved. - existingSessions, _ := e.repo.ListTaskSessions(ctx, task.ID) + // Determine whether this is the task's first session and whether it should + // become primary. The immutable origin marker follows creation order, not + // primary-session ownership, because a later user-selected primary must not + // change which conversation workflow rules treat as the original. + existingSessions, err := e.repo.ListTaskSessions(ctx, task.ID) + if err != nil { + e.logger.Error("failed to list task sessions before creating initial session", + zap.String("task_id", task.ID), zap.Error(err)) + return "", fmt.Errorf("list task sessions: %w", err) + } + isTaskInitialSession := len(existingSessions) == 0 hasPrimary := false for _, s := range existingSessions { if s.IsPrimary { @@ -720,7 +727,13 @@ func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfi break } } - isFirstSession := !hasPrimary + isPrimarySession := !hasPrimary + if isTaskInitialSession { + if metadata == nil { + metadata = make(map[string]interface{}) + } + metadata[models.SessionMetaKeyOrigin] = models.SessionOriginTaskInitial + } // Create agent session in database. WorkspacePath is propagated from task // metadata for repo-less tasks where the user picked a starting folder. @@ -738,7 +751,7 @@ func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfi StartedAt: now, UpdatedAt: now, AgentProfileSnapshot: agentProfileSnapshot, - IsPrimary: isFirstSession, + IsPrimary: isPrimarySession, IsPassthrough: isPassthrough, Metadata: metadata, } @@ -768,7 +781,7 @@ func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfi // Set primary flag only for the first session (no existing primary). // Subsequent sessions do not override the established primary. - if isFirstSession { + if isPrimarySession { if err := e.repo.SetSessionPrimary(ctx, sessionID); err != nil { e.logger.Warn("failed to update primary session flag", zap.String("task_id", task.ID), diff --git a/apps/backend/internal/orchestrator/executor/executor_office.go b/apps/backend/internal/orchestrator/executor/executor_office.go index c5573e0a9f..228a292cb5 100644 --- a/apps/backend/internal/orchestrator/executor/executor_office.go +++ b/apps/backend/internal/orchestrator/executor/executor_office.go @@ -252,7 +252,7 @@ func (e *Executor) createOfficeSession( session.ExecutorID = execConfig.ExecutorID } - if err := e.repo.CreateTaskSession(ctx, session); err != nil { + if err := e.persistOfficeSession(ctx, task.ID, session); err != nil { return nil, fmt.Errorf("persist office session: %w", err) } e.logger.Info("office session created", @@ -261,3 +261,27 @@ func (e *Executor) createOfficeSession( zap.String("agent_profile_id", agentInstanceID)) return session, nil } + +func (e *Executor) persistOfficeSession(ctx context.Context, taskID string, session *models.TaskSession) error { + if creator, ok := e.repo.(officeTaskSessionCreator); ok { + return creator.CreateOfficeTaskSession(ctx, session) + } + creationLock := e.officeSessionLock(taskID) + creationLock.Lock() + defer creationLock.Unlock() + return e.persistOfficeSessionFallback(ctx, taskID, session) +} + +func (e *Executor) persistOfficeSessionFallback(ctx context.Context, taskID string, session *models.TaskSession) error { + existingSessions, err := e.repo.ListTaskSessions(ctx, taskID) + if err != nil { + return fmt.Errorf("list task sessions before creating office session: %w", err) + } + if len(existingSessions) == 0 { + if session.Metadata == nil { + session.Metadata = make(map[string]interface{}) + } + session.Metadata[models.SessionMetaKeyOrigin] = models.SessionOriginTaskInitial + } + return e.repo.CreateTaskSession(ctx, session) +} diff --git a/apps/backend/internal/orchestrator/executor/executor_office_test.go b/apps/backend/internal/orchestrator/executor/executor_office_test.go index 56e2e94e18..c4b83e8d80 100644 --- a/apps/backend/internal/orchestrator/executor/executor_office_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_office_test.go @@ -69,11 +69,36 @@ func TestEnsureSessionForAgent_CreatesWhenMissing(t *testing.T) { if got.State != models.TaskSessionStateCreated { t.Errorf("state: got %q want CREATED", got.State) } + if got.Metadata[models.SessionMetaKeyOrigin] != models.SessionOriginTaskInitial { + t.Errorf("origin marker = %#v, want %q", got.Metadata[models.SessionMetaKeyOrigin], models.SessionOriginTaskInitial) + } if len(repo.createTaskSessionCalls) != 1 { t.Fatalf("expected 1 CreateTaskSession call, got %d", len(repo.createTaskSessionCalls)) } } +func TestEnsureSessionForAgent_DoesNotMarkExistingTaskSessionAsOrigin(t *testing.T) { + repo := newMockRepository() + repo.sessions["sess-existing"] = &models.TaskSession{ + ID: "sess-existing", + TaskID: "task-office", + AgentProfileID: "agent-existing", + State: models.TaskSessionStateIdle, + StartedAt: time.Now().UTC(), + } + exec := newTestExecutor(t, &mockAgentManager{}, repo) + + got, err := exec.EnsureSessionForAgent( + context.Background(), officeTestTask(), "agent-new", "profile-1", "exec-1", "", + ) + if err != nil { + t.Fatalf("EnsureSessionForAgent: %v", err) + } + if _, marked := got.Metadata[models.SessionMetaKeyOrigin]; marked { + t.Fatalf("new Office session metadata = %#v, must not claim task origin when another session exists", got.Metadata) + } +} + func TestEnsureSessionForAgent_RebindsExecutionProfileOnReuse(t *testing.T) { repo := newMockRepository() exec := newTestExecutor(t, &mockAgentManager{}, repo) diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 372be65a6f..18012598b6 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -169,6 +169,9 @@ func TestPrepareSession_Success(t *testing.T) { if !createdSession.IsPrimary { t.Error("Expected session to be primary") } + if !models.IsOriginalTaskSession(createdSession.Metadata) { + t.Fatalf("expected first session to carry immutable original marker, metadata = %#v", createdSession.Metadata) + } // Verify SetSessionPrimary was called if len(repo.setSessionPrimaryCalls) != 1 { diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 8a304e1722..a3017c7a96 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -798,6 +798,19 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s return nil, err } } + workflowSessionConfigStepID := workflowStepID + // Manual starts often omit workflow_step_id because the task is already + // bound to its current step. Resolve that canonical step before profile + // selection and launch-layer session configuration so a start-step rule is + // applied before the first prompt as well. + if workflowSessionConfigStepID == "" { + if dbTask, taskErr := s.repo.GetTask(ctx, taskID); taskErr != nil { + s.logger.Warn("failed to fetch task for workflow step fallback", + zap.String("task_id", taskID), zap.Error(taskErr)) + } else if dbTask != nil { + workflowSessionConfigStepID = dbTask.WorkflowStepID + } + } // Office tasks do NOT transition through SCHEDULING / IN_PROGRESS on // every run. Their lifecycle status (todo / in_review / done / @@ -871,6 +884,11 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s if err != nil { return nil, err } + // Seed a matching conditional session configuration before lifecycle + // startup. The ACP manager applies this durable runtime layer after the + // selected profile and before the first prompt, preserving the original + // session tab. + s.applyWorkflowSessionConfigBeforeLaunchForStep(ctx, taskID, sessionID, workflowSessionConfigStepID) // Surface the newly created session before LaunchPreparedSession performs // potentially slow environment setup (for example, a Docker health check). // The frontend adopts this CREATED session and can render @@ -988,6 +1006,33 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s return execution, nil } +func (s *Service) applyWorkflowSessionConfigBeforeLaunchForStep( + ctx context.Context, + taskID string, + sessionID string, + workflowStepID string, +) { + if s.workflowStepGetter == nil || workflowStepID == "" { + return + } + workflowStep, err := s.workflowStepGetter.GetStep(ctx, workflowStepID) + if err != nil { + s.logger.Warn("failed to load workflow step for session configuration", + zap.String("task_id", taskID), zap.String("step_id", workflowStepID), zap.Error(err)) + return + } + if workflowStep == nil { + return + } + preparedSession, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + s.logger.Warn("failed to reload session for session configuration", + zap.String("task_id", taskID), zap.String("session_id", sessionID), zap.Error(err)) + return + } + s.applyWorkflowSessionConfigBeforeLaunch(ctx, taskID, preparedSession, workflowStep) +} + // launchPromptContext carries what the first turn of a launch needs in order to // compose its system context. type launchPromptContext struct { @@ -1709,6 +1754,11 @@ func (s *Service) StartSessionForWorkflowStep(ctx context.Context, taskID, sessi return err } + // Apply conditional session settings after a manual resume and before the + // step prompt. The helper reloads the session so a resume-created runtime + // state cannot be overwritten by the stale request snapshot. + s.applyWorkflowSessionConfigOnEnter(ctx, taskID, session, step) + stepPlanMode := step.HasOnEnterAction(wfmodels.OnEnterEnablePlanMode) _, err = s.PromptTask(ctx, taskID, sessionID, effectivePrompt, "", stepPlanMode, nil, false) if err != nil { diff --git a/apps/backend/internal/orchestrator/workflow_session_config.go b/apps/backend/internal/orchestrator/workflow_session_config.go new file mode 100644 index 0000000000..087ad9a148 --- /dev/null +++ b/apps/backend/internal/orchestrator/workflow_session_config.go @@ -0,0 +1,359 @@ +package orchestrator + +import ( + "context" + "fmt" + "sort" + "strings" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// sessionConfigOptionSetter is intentionally narrower than the executor's +// agent-manager contract. Older test and plugin implementations can continue +// to run; providers that support ACP config options opt into this seam. +type sessionConfigOptionSetter interface { + SetSessionConfigOptionBySessionID(context.Context, string, string, string) error +} + +type sessionConfigurationTarget struct { + model string + configOptions map[string]string +} + +// applyWorkflowSessionConfigBeforeLaunch stores a matching rule as a runtime +// override before lifecycle initialization. The ACP lifecycle then applies it +// after the profile layer and before the first prompt. +func (s *Service) applyWorkflowSessionConfigBeforeLaunch( + ctx context.Context, + taskID string, + session *models.TaskSession, + step *wfmodels.WorkflowStep, +) { + s.applyWorkflowSessionConfig(ctx, taskID, session, step, false) +} + +// applyWorkflowSessionConfigOnEnter applies a matching rule to an already +// initialized original session. It runs after context reset and before the +// step's auto-start prompt. +func (s *Service) applyWorkflowSessionConfigOnEnter( + ctx context.Context, + taskID string, + session *models.TaskSession, + step *wfmodels.WorkflowStep, +) { + s.applyWorkflowSessionConfig(ctx, taskID, session, step, true) +} + +func (s *Service) applyWorkflowSessionConfig( + ctx context.Context, + taskID string, + session *models.TaskSession, + step *wfmodels.WorkflowStep, + preferLive bool, +) { + if session == nil || step == nil || s.repo == nil { + return + } + session = s.latestSession(ctx, session) + target, ok := s.resolveWorkflowSessionConfigTarget(ctx, taskID, session, step) + if !ok { + return + } + + // A launch path has no ready ACP session. Persisting the target is enough; + // lifecycle initialization consumes it as the runtime layer. On-enter paths + // prefer the live ACP session, but fall back to the same durable layer when + // the process is still booting or has been stopped. + if !preferLive || !s.agentManager.IsAgentReadyForPrompt(ctx, session.ID) { + s.persistWorkflowSessionConfigBeforeStart(ctx, taskID, session, step, target) + return + } + + failed := s.applyLiveWorkflowSessionConfig(ctx, session.ID, target) + if len(failed) > 0 { + s.warnWorkflowSessionConfig(ctx, taskID, session.ID, step.ID, + fmt.Sprintf("Some session settings could not be applied: %s.", strings.Join(failed, ", "))) + } +} + +func (s *Service) latestSession(ctx context.Context, session *models.TaskSession) *models.TaskSession { + if session == nil || s.repo == nil { + return session + } + latest, err := s.repo.GetTaskSession(ctx, session.ID) + if err == nil && latest != nil { + return latest + } + return session +} + +func (s *Service) resolveWorkflowSessionConfigTarget( + ctx context.Context, + taskID string, + session *models.TaskSession, + step *wfmodels.WorkflowStep, +) (sessionConfigurationTarget, bool) { + action, ok := configureSessionAction(step) + if !ok { + return sessionConfigurationTarget{}, false + } + rules, err := wfmodels.ParseConfigureSessionRules(action) + if err != nil { + s.logger.Warn("skipping invalid configure_session action", + zap.String("task_id", taskID), zap.String("step_id", step.ID), zap.Error(err)) + return sessionConfigurationTarget{}, false + } + original, err := s.originalTaskSession(ctx, taskID) + if err != nil { + s.logger.Warn("failed to resolve original task session for configure_session", + zap.String("task_id", taskID), zap.Error(err)) + return sessionConfigurationTarget{}, false + } + if original == nil { + return sessionConfigurationTarget{}, false + } + if original.ID != session.ID { + s.warnWorkflowSessionConfig(ctx, taskID, session.ID, step.ID, + "This step's session settings were not applied because workflow routing is using a different session tab.") + return sessionConfigurationTarget{}, false + } + if session.IsPassthrough { + s.warnWorkflowSessionConfig(ctx, taskID, session.ID, step.ID, + "This step's ACP session settings were not applied because the original agent uses passthrough mode.") + return sessionConfigurationTarget{}, false + } + agentName := sessionAgentFamily(session) + if agentName == "" { + s.warnWorkflowSessionConfig(ctx, taskID, session.ID, step.ID, + "This step's session settings were not applied because the original agent family is unknown.") + return sessionConfigurationTarget{}, false + } + rule := matchingConfigureSessionRule(rules, agentName) + if rule == nil || rule.Operation == wfmodels.ConfigureSessionKeep { + return sessionConfigurationTarget{}, false + } + target, ok := sessionConfigurationTargetForRule(session, *rule) + if !ok { + s.warnWorkflowSessionConfig(ctx, taskID, session.ID, step.ID, + "This step requested restoring the original session settings, but no immutable original configuration is available.") + return sessionConfigurationTarget{}, false + } + return target, true +} + +func matchingConfigureSessionRule(rules []wfmodels.ConfigureSessionRule, agentName string) *wfmodels.ConfigureSessionRule { + for index := range rules { + if rules[index].AgentName == agentName { + return &rules[index] + } + } + return nil +} + +func (s *Service) persistWorkflowSessionConfigBeforeStart( + ctx context.Context, + taskID string, + session *models.TaskSession, + step *wfmodels.WorkflowStep, + target sessionConfigurationTarget, +) { + if err := s.persistWorkflowSessionConfigOverride(ctx, session.ID, target); err != nil { + s.warnWorkflowSessionConfig(ctx, taskID, session.ID, step.ID, + "The requested session settings could not be saved for the next agent start.") + } +} + +func (s *Service) applyLiveWorkflowSessionConfig( + ctx context.Context, + sessionID string, + target sessionConfigurationTarget, +) []string { + failed := make([]string, 0) + applied := sessionConfigurationTarget{configOptions: make(map[string]string)} + if target.model != "" { + if err := s.agentManager.SetSessionModelBySessionID(ctx, sessionID, target.model); err != nil { + failed = append(failed, "model") + } else { + applied.model = target.model + } + } + setter, supported := s.agentManager.(sessionConfigOptionSetter) + for configID, value := range target.configOptions { + if !supported { + failed = append(failed, configID) + continue + } + if err := setter.SetSessionConfigOptionBySessionID(ctx, sessionID, configID, value); err != nil { + failed = append(failed, configID) + continue + } + applied.configOptions[configID] = value + } + if applied.model != "" || len(applied.configOptions) > 0 { + if err := s.persistWorkflowSessionConfigOverride(ctx, sessionID, applied); err != nil { + failed = append(failed, "settings persistence") + } + } + return failed +} + +func configureSessionAction(step *wfmodels.WorkflowStep) (wfmodels.OnEnterAction, bool) { + for _, action := range step.Events.OnEnter { + if action.Type == wfmodels.OnEnterConfigureSession { + return action, true + } + } + return wfmodels.OnEnterAction{}, false +} + +func sessionAgentFamily(session *models.TaskSession) string { + if session == nil || session.AgentProfileSnapshot == nil { + return "" + } + return strings.TrimSpace(stringFromAny(session.AgentProfileSnapshot["agent_name"])) +} + +func sessionConfigurationTargetForRule(session *models.TaskSession, rule wfmodels.ConfigureSessionRule) (sessionConfigurationTarget, bool) { + if rule.Operation == wfmodels.ConfigureSessionSet { + return sessionConfigurationTarget{model: strings.TrimSpace(rule.Model), configOptions: cleanSessionConfigOptions(rule.ConfigOptions)}, true + } + original, ok := models.LoadOriginalSessionEffectiveConfiguration(session.Metadata) + if !ok { + return sessionConfigurationTarget{}, false + } + return sessionConfigurationTarget{model: original.Model, configOptions: cleanSessionConfigOptions(original.ConfigOptions)}, true +} + +func cleanSessionConfigOptions(options map[string]string) map[string]string { + if len(options) == 0 { + return nil + } + clean := make(map[string]string, len(options)) + for key, value := range options { + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if key == "" || value == "" || key == "model" || key == "mode" { + continue + } + clean[key] = value + } + if len(clean) == 0 { + return nil + } + return clean +} + +func (s *Service) persistWorkflowSessionConfigOverride(ctx context.Context, sessionID string, target sessionConfigurationTarget) error { + if s.repo == nil { + return fmt.Errorf("session repository is unavailable") + } + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return err + } + if session == nil { + return fmt.Errorf("session %q not found", sessionID) + } + overrides, _ := models.LoadSessionRuntimeConfigOverrides(session.Metadata) + if target.model != "" { + overrides.Model = target.model + } + if len(target.configOptions) > 0 { + if overrides.ConfigOptions == nil { + overrides.ConfigOptions = make(map[string]string) + } + for key, value := range target.configOptions { + overrides.ConfigOptions[key] = value + } + } + return s.repo.SetSessionMetadataKey(ctx, sessionID, models.SessionMetaKeyRuntimeConfigOverrides, overrides) +} + +// originalTaskSession returns the immutable task-initial session. The marker +// is authoritative; the timestamp fallback is deliberately conservative for +// sessions created before the marker existed. +func (s *Service) originalTaskSession(ctx context.Context, taskID string) (*models.TaskSession, error) { + sessions, err := s.repo.ListTaskSessions(ctx, taskID) + if err != nil { + return nil, err + } + if original, resolved := markedOriginalTaskSession(sessions); resolved { + return original, nil + } + return legacyOriginalTaskSession(sessions), nil +} + +func markedOriginalTaskSession(sessions []*models.TaskSession) (*models.TaskSession, bool) { + marked := make([]*models.TaskSession, 0, 1) + for _, candidate := range sessions { + if candidate != nil && models.IsOriginalTaskSession(candidate.Metadata) { + marked = append(marked, candidate) + } + } + if len(marked) == 1 { + return marked[0], true + } + if len(marked) > 1 { + return nil, true + } + return nil, false +} + +func legacyOriginalTaskSession(sessions []*models.TaskSession) *models.TaskSession { + legacy := make([]*models.TaskSession, 0, len(sessions)) + for _, candidate := range sessions { + if candidate == nil || candidate.Metadata[models.SessionMetaKeyCreatedBy] == models.SessionCreatedByWorkflowSwitch { + continue + } + legacy = append(legacy, candidate) + } + if len(legacy) == 0 { + return nil + } + sort.SliceStable(legacy, func(i, j int) bool { + left, right := legacy[i].StartedAt, legacy[j].StartedAt + if left.Equal(right) { + return legacy[i].ID < legacy[j].ID + } + if left.IsZero() { + return false + } + if right.IsZero() { + return true + } + return left.Before(right) + }) + if len(legacy) > 1 && legacy[0].StartedAt.Equal(legacy[1].StartedAt) { + return nil + } + return legacy[0] +} + +func (s *Service) warnWorkflowSessionConfig(ctx context.Context, taskID, sessionID, stepID, content string) { + s.logger.Warn("workflow session configuration warning", + zap.String("task_id", taskID), zap.String("session_id", sessionID), zap.String("step_id", stepID), zap.String("message", content)) + if s.messageCreator == nil { + return + } + metadata := map[string]interface{}{ + "variant": "warning", + "workflow_session_config": true, + "workflow_step_id": stepID, + } + if err := s.messageCreator.CreateSessionMessage( + ctx, taskID, content, sessionID, string(v1.MessageTypeStatus), s.getActiveTurnID(sessionID), metadata, false, + ); err != nil { + s.logger.Warn("failed to persist workflow session configuration warning", + zap.String("task_id", taskID), zap.String("session_id", sessionID), zap.Error(err)) + } +} + +func stringFromAny(value interface{}) string { + valueString, _ := value.(string) + return valueString +} diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 95b881409e..7e0c3eaba3 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -143,8 +143,12 @@ func IsAgentTitleOwner(metadata map[string]interface{}, sessionID string) bool { // workflow_switch means the session profile was selected by workflow routing // rather than direct user selection. const ( - SessionMetaKeyCreatedBy = "created_by" - SessionCreatedByWorkflowSwitch = "workflow_switch" + SessionMetaKeyCreatedBy = "created_by" + SessionCreatedByWorkflowSwitch = "workflow_switch" + // SessionMetaKeyOrigin identifies immutable task-session provenance. Unlike + // IsPrimary, it never changes when the user selects another conversation tab. + SessionMetaKeyOrigin = "origin" + SessionOriginTaskInitial = "task_initial" SessionMetaKeyContextWindow = "context_window" SessionMetaKeyContextCompactionCount = "context_compaction_count" ) @@ -164,6 +168,12 @@ const SessionMetaKeyRuntimeConfig = "runtime_config" // intent. Overrides are applied after SessionMetaKeyRuntimeConfig. const SessionMetaKeyRuntimeConfigOverrides = "runtime_config_overrides" +// SessionMetaKeyOriginalEffectiveConfig stores the write-once effective model +// and selectable ACP option values after profile settings settle. It is the +// restore source for workflow rules and is intentionally separate from the +// provider-default comparison baseline and mutable runtime state. +const SessionMetaKeyOriginalEffectiveConfig = "original_effective_config" + // SessionMetaKeyACPConfigBaseline records the write-once effective ACP select // values with which a task session started. It is comparison metadata only; // runtime restoration continues to use SessionMetaKeyRuntimeConfig. @@ -208,6 +218,47 @@ type SessionRuntimeConfig struct { ConfigOptions map[string]string `json:"config_options,omitempty"` } +// SessionOriginalEffectiveConfiguration is the immutable configuration a task +// session had after provider defaults and its profile settings settled. +type SessionOriginalEffectiveConfiguration struct { + Model string `json:"model,omitempty"` + ConfigOptions map[string]string `json:"config_options,omitempty"` +} + +// LoadOriginalSessionEffectiveConfiguration decodes the original configuration +// from typed or JSON-rehydrated session metadata. +func LoadOriginalSessionEffectiveConfiguration(metadata map[string]interface{}) (SessionOriginalEffectiveConfiguration, bool) { + if metadata == nil { + return SessionOriginalEffectiveConfiguration{}, false + } + raw, ok := metadata[SessionMetaKeyOriginalEffectiveConfig] + if !ok || raw == nil { + return SessionOriginalEffectiveConfiguration{}, false + } + if config, ok := raw.(SessionOriginalEffectiveConfiguration); ok { + config.ConfigOptions = maps.Clone(config.ConfigOptions) + return config, config.Model != "" || len(config.ConfigOptions) > 0 + } + data, err := json.Marshal(raw) + if err != nil { + return SessionOriginalEffectiveConfiguration{}, false + } + var config SessionOriginalEffectiveConfiguration + if err := json.Unmarshal(data, &config); err != nil { + return SessionOriginalEffectiveConfiguration{}, false + } + return config, config.Model != "" || len(config.ConfigOptions) > 0 +} + +// IsOriginalTaskSession reports whether immutable task-session provenance marks +// this row as the session created when the task first started. +func IsOriginalTaskSession(metadata map[string]interface{}) bool { + if metadata == nil { + return false + } + return StringFromAny(metadata[SessionMetaKeyOrigin]) == SessionOriginTaskInitial +} + // TurnRuntimeConfigSnapshot is the minimal display state captured when a turn // starts. It intentionally excludes the provider's complete option catalog. type TurnRuntimeConfigSnapshot struct { diff --git a/apps/backend/internal/task/models/models_test.go b/apps/backend/internal/task/models/models_test.go index 9e0bcedf9c..1e2cc162ed 100644 --- a/apps/backend/internal/task/models/models_test.go +++ b/apps/backend/internal/task/models/models_test.go @@ -4,6 +4,8 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" + "github.com/kandev/kandev/internal/agentruntime" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -52,6 +54,31 @@ func TestLoadSessionRuntimeConfigOverridesUsesDedicatedKey(t *testing.T) { } } +func TestLoadOriginalSessionEffectiveConfiguration(t *testing.T) { + metadata := map[string]interface{}{ + SessionMetaKeyOriginalEffectiveConfig: map[string]interface{}{ + "model": "gpt-5.6-sol", + "config_options": map[string]interface{}{ + "reasoning_effort": "high", + }, + }, + } + + config, ok := LoadOriginalSessionEffectiveConfiguration(metadata) + require.True(t, ok) + require.Equal(t, "gpt-5.6-sol", config.Model) + require.Equal(t, map[string]string{"reasoning_effort": "high"}, config.ConfigOptions) +} + +func TestIsOriginalTaskSessionUsesImmutableOriginMarker(t *testing.T) { + require.True(t, IsOriginalTaskSession(map[string]interface{}{ + SessionMetaKeyOrigin: SessionOriginTaskInitial, + })) + require.False(t, IsOriginalTaskSession(map[string]interface{}{ + SessionMetaKeyCreatedBy: SessionCreatedByWorkflowSwitch, + })) +} + func TestLoadSessionACPConfigBaselinePreservesEmptyJSONValues(t *testing.T) { baseline, ok := LoadSessionACPConfigBaseline(map[string]interface{}{ SessionMetaKeyACPConfigBaseline: map[string]interface{}{ diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index 81ce5ea92a..6c353cb08f 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -213,6 +213,49 @@ const taskSessionFromClause = `FROM task_sessions ts // CreateTaskSession creates a new agent session func (r *Repository) CreateTaskSession(ctx context.Context, session *models.TaskSession) error { + return r.createTaskSession(ctx, r.db, session) +} + +// CreateOfficeTaskSession creates an Office session and atomically marks it as +// the task's initial session when no earlier session exists. The task row lock +// serializes callers across PostgreSQL connections; SQLite's single writer +// connection serializes the transaction. +func (r *Repository) CreateOfficeTaskSession(ctx context.Context, session *models.TaskSession) error { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if dialect.IsPostgres(r.db.DriverName()) { + var lockedTaskID string + if err := tx.QueryRowContext(ctx, r.db.Rebind( + `SELECT id FROM tasks WHERE id = ? FOR UPDATE`, + ), session.TaskID).Scan(&lockedTaskID); err != nil { + return fmt.Errorf("lock task for office session: %w", err) + } + } + + var sessionCount int + if err := tx.QueryRowContext(ctx, r.db.Rebind( + `SELECT COUNT(*) FROM task_sessions WHERE task_id = ?`, + ), session.TaskID).Scan(&sessionCount); err != nil { + return fmt.Errorf("check task sessions before office session: %w", err) + } + if sessionCount == 0 { + if session.Metadata == nil { + session.Metadata = make(map[string]interface{}) + } + session.Metadata[models.SessionMetaKeyOrigin] = models.SessionOriginTaskInitial + } + + if err := r.createTaskSession(ctx, tx, session); err != nil { + return err + } + return tx.Commit() +} + +func (r *Repository) createTaskSession(ctx context.Context, exec taskSessionExecutor, session *models.TaskSession) error { if session.ID == "" { session.ID = uuid.New().String() } @@ -259,7 +302,7 @@ func (r *Repository) CreateTaskSession(ctx context.Context, session *models.Task if session.AgentProfileID != "" { agentProfileID = session.AgentProfileID } - _, err = r.db.ExecContext(ctx, r.db.Rebind(` + _, err = exec.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_sessions ( id, task_id, agent_profile_id, execution_profile_id, executor_id, executor_profile_id, environment_id, repository_id, base_branch, base_commit_sha, workspace_path, diff --git a/apps/backend/internal/task/repository/sqlite/session_test.go b/apps/backend/internal/task/repository/sqlite/session_test.go index 0afb8b79c3..b50b0933a9 100644 --- a/apps/backend/internal/task/repository/sqlite/session_test.go +++ b/apps/backend/internal/task/repository/sqlite/session_test.go @@ -8,6 +8,7 @@ import ( "errors" "path/filepath" "strings" + "sync" "testing" "time" @@ -16,6 +17,7 @@ import ( "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/db/dialect" "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/testutil" "github.com/stretchr/testify/require" ) @@ -126,6 +128,106 @@ func TestTaskSessionWorkspacePathFallsBackWithoutEnvironment(t *testing.T) { } } +func TestCreateOfficeTaskSessionMarksOnlyTheFirstConcurrentSessionAsOrigin(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + const taskID = "task-office-origin-race" + if err := repo.CreateTask(ctx, &models.Task{ID: taskID, Title: "Office origin race"}); err != nil { + t.Fatalf("CreateTask: %v", err) + } + + sessions := []*models.TaskSession{ + {ID: "office-origin-agent-1", TaskID: taskID, AgentProfileID: "agent-1", State: models.TaskSessionStateCreated}, + {ID: "office-origin-agent-2", TaskID: taskID, AgentProfileID: "agent-2", State: models.TaskSessionStateCreated}, + } + errs := make([]error, len(sessions)) + var wg sync.WaitGroup + for i, session := range sessions { + wg.Add(1) + go func(i int, session *models.TaskSession) { + defer wg.Done() + errs[i] = repo.CreateOfficeTaskSession(ctx, session) + }(i, session) + } + wg.Wait() + for i, err := range errs { + if err != nil { + t.Fatalf("CreateOfficeTaskSession(%d): %v", i, err) + } + } + + created, err := repo.ListTaskSessions(ctx, taskID) + if err != nil { + t.Fatalf("ListTaskSessions: %v", err) + } + if len(created) != len(sessions) { + t.Fatalf("created sessions = %d, want %d", len(created), len(sessions)) + } + originCount := 0 + for _, session := range created { + if models.IsOriginalTaskSession(session.Metadata) { + originCount++ + } + } + if originCount != 1 { + t.Fatalf("origin-marked sessions = %d, want exactly one", originCount) + } +} + +func TestPostgresCreateOfficeTaskSessionMarksOnlyTheFirstConcurrentSessionAsOrigin(t *testing.T) { + db := openIsolatedPostgresMultiConn(t, testutil.PostgresDSNFromEnv(t), 2) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + const taskID = "task-office-origin-race-postgres" + now := time.Now().UTC() + if _, err := db.Exec(db.Rebind(` + INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `), taskID, "", "Office origin race (Postgres)", now, now); err != nil { + t.Fatalf("seed task: %v", err) + } + + sessions := []*models.TaskSession{ + {ID: "office-origin-postgres-agent-1", TaskID: taskID, AgentProfileID: "agent-1", State: models.TaskSessionStateCreated}, + {ID: "office-origin-postgres-agent-2", TaskID: taskID, AgentProfileID: "agent-2", State: models.TaskSessionStateCreated}, + } + errs := make([]error, len(sessions)) + var wg sync.WaitGroup + for i, session := range sessions { + wg.Add(1) + go func(i int, session *models.TaskSession) { + defer wg.Done() + errs[i] = repo.CreateOfficeTaskSession(ctx, session) + }(i, session) + } + wg.Wait() + for i, err := range errs { + if err != nil { + t.Fatalf("CreateOfficeTaskSession(%d): %v", i, err) + } + } + + created, err := repo.ListTaskSessions(ctx, taskID) + if err != nil { + t.Fatalf("ListTaskSessions: %v", err) + } + if len(created) != len(sessions) { + t.Fatalf("created sessions = %d, want %d", len(created), len(sessions)) + } + originCount := 0 + for _, session := range created { + if models.IsOriginalTaskSession(session.Metadata) { + originCount++ + } + } + if originCount != 1 { + t.Fatalf("origin-marked sessions = %d, want exactly one", originCount) + } +} + // seedForMsgTest seeds task, session, and turn rows so that all FK constraints // on task_session_messages are satisfied. Returns the turn ID for use in inserts. func seedForMsgTest(t *testing.T, repo *Repository, taskID, sessionID, turnID string) { diff --git a/apps/backend/internal/workflow/models/export.go b/apps/backend/internal/workflow/models/export.go index 163754d6a6..9e525b2e8c 100644 --- a/apps/backend/internal/workflow/models/export.go +++ b/apps/backend/internal/workflow/models/export.go @@ -223,13 +223,8 @@ func (s StepPortable) PullFromStepID(posToID map[int]string) string { // an import would "succeed" with an inert action. See issue #1183. This mirrors // the embedded-YAML loader's allow-list check. func validateOnEnterActions(step StepPortable) error { - for _, a := range step.Events.OnEnter { - if a.Type != OnEnterSetSessionMode { - continue - } - if mode, _ := a.Config["mode"].(string); mode == "" { - return fmt.Errorf("step %q on_enter: set_session_mode requires a non-empty string \"mode\" config", step.Name) - } + if err := ValidateStepEvents(step.Events, step.AgentProfile != nil); err != nil { + return fmt.Errorf("step %q on_enter: %w", step.Name, err) } return nil } diff --git a/apps/backend/internal/workflow/models/session_config.go b/apps/backend/internal/workflow/models/session_config.go new file mode 100644 index 0000000000..fe8d5d79bb --- /dev/null +++ b/apps/backend/internal/workflow/models/session_config.go @@ -0,0 +1,207 @@ +package models + +import ( + "fmt" + "strings" +) + +// OnEnterConfigureSession applies a conditional configuration to the task's +// original session without selecting or creating another session. +const OnEnterConfigureSession OnEnterActionType = "configure_session" + +// ConfigureSessionOperation describes the effect of a conditional session +// configuration rule. +type ConfigureSessionOperation string + +const ( + ConfigureSessionSet ConfigureSessionOperation = "set" + ConfigureSessionKeep ConfigureSessionOperation = "keep" + ConfigureSessionRestoreOriginal ConfigureSessionOperation = "restore_original" +) + +// ConfigureSessionRule is the typed form of a configure_session action rule. +// The persisted workflow contract remains the existing action Config JSON map; +// this type is used after validation at runtime boundaries. +type ConfigureSessionRule struct { + AgentName string + Operation ConfigureSessionOperation + Model string + ConfigOptions map[string]string +} + +// ParseConfigureSessionRules validates and decodes a configure_session action. +func ParseConfigureSessionRules(action OnEnterAction) ([]ConfigureSessionRule, error) { + if action.Type != OnEnterConfigureSession { + return nil, fmt.Errorf("action type %q is not configure_session", action.Type) + } + if action.Config == nil { + return nil, fmt.Errorf("configure_session requires a rules array") + } + rawRules, ok := action.Config["rules"] + if !ok { + return nil, fmt.Errorf("configure_session requires a rules array") + } + + var items []interface{} + switch typed := rawRules.(type) { + case []interface{}: + items = typed + case []map[string]interface{}: + items = make([]interface{}, len(typed)) + for i := range typed { + items[i] = typed[i] + } + case []ConfigureSessionRule: + items = make([]interface{}, len(typed)) + for i := range typed { + items[i] = typed[i] + } + default: + return nil, fmt.Errorf("configure_session rules has unexpected type %T", rawRules) + } + if len(items) == 0 { + return nil, fmt.Errorf("configure_session requires at least one rule") + } + + rules := make([]ConfigureSessionRule, 0, len(items)) + seenFamilies := make(map[string]struct{}, len(items)) + for index, item := range items { + rule, err := parseConfigureSessionRule(item) + if err != nil { + return nil, fmt.Errorf("configure_session rule %d: %w", index, err) + } + if _, exists := seenFamilies[rule.AgentName]; exists { + return nil, fmt.Errorf("configure_session has duplicate agent_name %q", rule.AgentName) + } + seenFamilies[rule.AgentName] = struct{}{} + rules = append(rules, rule) + } + return rules, nil +} + +// ValidateWorkflowStep validates all on-enter invariants that must hold for +// both API writes and workflow imports. +func ValidateWorkflowStep(step *WorkflowStep) error { + if step == nil { + return fmt.Errorf("workflow step is required") + } + return ValidateStepEvents(step.Events, step.AgentProfileID != "") +} + +// ValidateStepEvents validates on-enter action invariants for a step shape +// that does not carry a full WorkflowStep, such as a portable export. +func ValidateStepEvents(events StepEvents, hasAgentProfile bool) error { + configureCount := 0 + for _, action := range events.OnEnter { + switch action.Type { + case OnEnterConfigureSession: + configureCount++ + if hasAgentProfile { + return fmt.Errorf("configure_session cannot be combined with agent_profile_id") + } + if _, err := ParseConfigureSessionRules(action); err != nil { + return err + } + case OnEnterSetSessionMode: + if mode, _ := action.Config["mode"].(string); strings.TrimSpace(mode) == "" { + return fmt.Errorf("set_session_mode requires a non-empty string \"mode\" config") + } + } + } + if configureCount > 1 { + return fmt.Errorf("workflow step may contain at most one configure_session action") + } + return nil +} + +func parseConfigureSessionRule(raw interface{}) (ConfigureSessionRule, error) { + if typed, ok := raw.(ConfigureSessionRule); ok { + return validateConfigureSessionRule(typed, typed.Model != "", typed.ConfigOptions != nil) + } + values, ok := raw.(map[string]interface{}) + if !ok { + return ConfigureSessionRule{}, fmt.Errorf("must be an object") + } + + agentName, ok := values["agent_name"].(string) + if !ok || strings.TrimSpace(agentName) == "" { + return ConfigureSessionRule{}, fmt.Errorf("agent_name must be a non-empty string") + } + operationValue, ok := values["operation"].(string) + if !ok || strings.TrimSpace(operationValue) == "" { + return ConfigureSessionRule{}, fmt.Errorf("operation must be a non-empty string") + } + + rule := ConfigureSessionRule{ + AgentName: strings.TrimSpace(agentName), + Operation: ConfigureSessionOperation(operationValue), + } + modelValue, hasModel := values["model"] + if hasModel { + model, ok := modelValue.(string) + if !ok { + return ConfigureSessionRule{}, fmt.Errorf("model must be a string") + } + rule.Model = model + } + optionsValue, hasOptions := values["config_options"] + if hasOptions { + options, err := parseConfigureSessionOptions(optionsValue) + if err != nil { + return ConfigureSessionRule{}, err + } + rule.ConfigOptions = options + } + return validateConfigureSessionRule(rule, hasModel, hasOptions) +} + +func parseConfigureSessionOptions(raw interface{}) (map[string]string, error) { + options := make(map[string]string) + switch typed := raw.(type) { + case map[string]string: + for key, value := range typed { + options[key] = value + } + case map[string]interface{}: + for key, value := range typed { + stringValue, ok := value.(string) + if !ok { + return nil, fmt.Errorf("config_options[%q] must be a string", key) + } + options[key] = stringValue + } + default: + return nil, fmt.Errorf("config_options must be an object") + } + return options, nil +} + +func validateConfigureSessionRule(rule ConfigureSessionRule, hasModel, hasOptions bool) (ConfigureSessionRule, error) { + rule.AgentName = strings.TrimSpace(rule.AgentName) + rule.Model = strings.TrimSpace(rule.Model) + if rule.AgentName == "" { + return ConfigureSessionRule{}, fmt.Errorf("agent_name must be a non-empty string") + } + if rule.ConfigOptions == nil { + rule.ConfigOptions = map[string]string{} + } + for key, value := range rule.ConfigOptions { + if strings.TrimSpace(key) == "" || strings.TrimSpace(value) == "" { + return ConfigureSessionRule{}, fmt.Errorf("config_options keys and values must be non-empty strings") + } + } + + switch rule.Operation { + case ConfigureSessionSet: + if strings.TrimSpace(rule.Model) == "" && len(rule.ConfigOptions) == 0 { + return ConfigureSessionRule{}, fmt.Errorf("set requires a non-empty model or config_options") + } + case ConfigureSessionKeep, ConfigureSessionRestoreOriginal: + if hasModel || hasOptions || strings.TrimSpace(rule.Model) != "" || len(rule.ConfigOptions) > 0 { + return ConfigureSessionRule{}, fmt.Errorf("%s does not accept model or config_options", rule.Operation) + } + default: + return ConfigureSessionRule{}, fmt.Errorf("operation %q must be set, keep, or restore_original", rule.Operation) + } + return rule, nil +} diff --git a/apps/backend/internal/workflow/models/session_config_test.go b/apps/backend/internal/workflow/models/session_config_test.go new file mode 100644 index 0000000000..2309831fbf --- /dev/null +++ b/apps/backend/internal/workflow/models/session_config_test.go @@ -0,0 +1,149 @@ +package models + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidateWorkflowStepConfigureSession(t *testing.T) { + tests := []struct { + name string + step *WorkflowStep + wantErr string + }{ + { + name: "accepts multiple families and a set rule", + step: &WorkflowStep{Events: StepEvents{OnEnter: []OnEnterAction{{ + Type: OnEnterConfigureSession, + Config: map[string]interface{}{"rules": []interface{}{ + map[string]interface{}{ + "agent_name": "codex", + "operation": "set", + "model": "gpt-5.6-luna", + "config_options": map[string]interface{}{ + "reasoning_effort": "max", + }, + }, + map[string]interface{}{ + "agent_name": "claude", + "operation": "restore_original", + }, + }}}, + }}}, + }, + { + name: "rejects duplicate family", + step: &WorkflowStep{Events: StepEvents{OnEnter: []OnEnterAction{{ + Type: OnEnterConfigureSession, + Config: map[string]interface{}{"rules": []interface{}{ + map[string]interface{}{"agent_name": "codex", "operation": "keep"}, + map[string]interface{}{"agent_name": "codex", "operation": "restore_original"}, + }}}, + }}}, + wantErr: "duplicate agent_name", + }, + { + name: "rejects empty set", + step: &WorkflowStep{Events: StepEvents{OnEnter: []OnEnterAction{{ + Type: OnEnterConfigureSession, + Config: map[string]interface{}{"rules": []interface{}{ + map[string]interface{}{"agent_name": "codex", "operation": "set"}, + }}}, + }}}, + wantErr: "set requires a non-empty model or config_options", + }, + { + name: "rejects values on restore", + step: &WorkflowStep{Events: StepEvents{OnEnter: []OnEnterAction{{ + Type: OnEnterConfigureSession, + Config: map[string]interface{}{"rules": []interface{}{ + map[string]interface{}{ + "agent_name": "codex", + "operation": "restore_original", + "model": "gpt-5.6-sol", + }, + }}}, + }}}, + wantErr: "does not accept model or config_options", + }, + { + name: "rejects fixed profile combination", + step: &WorkflowStep{ + AgentProfileID: "profile-1", + Events: StepEvents{OnEnter: []OnEnterAction{{ + Type: OnEnterConfigureSession, + Config: map[string]interface{}{"rules": []interface{}{ + map[string]interface{}{"agent_name": "codex", "operation": "keep"}, + }}}, + }}, + }, + wantErr: "cannot be combined with agent_profile_id", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateWorkflowStep(tt.step) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func TestConfigureSessionActionConfig(t *testing.T) { + action := OnEnterAction{ + Type: OnEnterConfigureSession, + Config: map[string]interface{}{"rules": []interface{}{ + map[string]interface{}{ + "agent_name": "codex", + "operation": "set", + "model": "gpt-5.6-luna", + "config_options": map[string]interface{}{ + "reasoning_effort": "max", + }, + }, + }}, + } + + rules, err := ParseConfigureSessionRules(action) + require.NoError(t, err) + require.Equal(t, []ConfigureSessionRule{ + { + AgentName: "codex", + Operation: ConfigureSessionSet, + Model: "gpt-5.6-luna", + ConfigOptions: map[string]string{ + "reasoning_effort": "max", + }, + }, + }, rules) +} + +func TestWorkflowExportRejectsConfigureSessionWithPortableProfile(t *testing.T) { + profile := &AgentProfilePortable{AgentName: "codex", Model: "gpt-5.6-sol"} + export := &WorkflowExport{ + Version: ExportVersion, + Type: ExportType, + Workflows: []WorkflowPortable{{ + Name: "workflow", + Steps: []StepPortable{{ + Name: "work", + Position: 0, + Color: "blue", + AgentProfile: profile, + Events: StepEvents{OnEnter: []OnEnterAction{{ + Type: OnEnterConfigureSession, + Config: map[string]interface{}{"rules": []interface{}{ + map[string]interface{}{"agent_name": "codex", "operation": "keep"}, + }}}, + }}, + }}, + }}, + } + + require.ErrorContains(t, export.Validate(), "cannot be combined with agent_profile") +} diff --git a/apps/backend/internal/workflow/service/service.go b/apps/backend/internal/workflow/service/service.go index 3f5244b688..462a684dc3 100644 --- a/apps/backend/internal/workflow/service/service.go +++ b/apps/backend/internal/workflow/service/service.go @@ -204,7 +204,9 @@ func (s *Service) CreateStepsFromTemplate(ctx context.Context, workflowID, templ idMap[stepDef.ID] = uuid.New().String() } - // Create each step from the template, remapping step_id references in events + steps := make([]*models.WorkflowStep, 0, len(template.Steps)) + // Build and validate every step before the first write. A malformed later + // step must not leave a partially-created workflow behind. for _, stepDef := range template.Steps { events := models.RemapStepEvents(stepDef.Events, idMap) step := &models.WorkflowStep{ @@ -225,6 +227,13 @@ func (s *Service) CreateStepsFromTemplate(ctx context.Context, workflowID, templ StageType: stepDef.StageType, } + if err := models.ValidateWorkflowStep(step); err != nil { + return fmt.Errorf("validate template step %q: %w", step.Name, err) + } + steps = append(steps, step) + } + + for _, step := range steps { if err := s.repo.CreateStep(ctx, step); err != nil { s.logger.Error("failed to create step from template", zap.String("workflow_id", workflowID), @@ -280,6 +289,9 @@ func (s *Service) CreateStep(ctx context.Context, step *models.WorkflowStep) err // CreateStepWithStartStepUpdates creates a new workflow step and returns any // other workflow steps whose start-step flag was cleared. func (s *Service) CreateStepWithStartStepUpdates(ctx context.Context, step *models.WorkflowStep) ([]*models.WorkflowStep, error) { + if err := models.ValidateWorkflowStep(step); err != nil { + return nil, err + } if step.ID == "" { step.ID = uuid.New().String() } @@ -301,6 +313,9 @@ func (s *Service) UpdateStep(ctx context.Context, step *models.WorkflowStep) err // UpdateStepWithStartStepUpdates updates a workflow step and returns any other // workflow steps whose start-step flag was cleared. func (s *Service) UpdateStepWithStartStepUpdates(ctx context.Context, step *models.WorkflowStep) ([]*models.WorkflowStep, error) { + if err := models.ValidateWorkflowStep(step); err != nil { + return nil, err + } demoted, err := s.repo.UpdateStepWithDemotedStartSteps(ctx, step) if err != nil { s.logger.Error("failed to update step", zap.String("step_id", step.ID), zap.Error(err)) @@ -515,6 +530,23 @@ func (s *Service) ImportWorkflows(ctx context.Context, workspaceID string, expor } func (s *Service) importSingleWorkflow(ctx context.Context, workspaceID string, pw models.WorkflowPortable) (*taskmodels.Workflow, error) { + // Generate UUIDs for all steps and build position→ID map. + posToID := make(map[int]string, len(pw.Steps)) + for _, sp := range pw.Steps { + posToID[sp.Position] = uuid.New().String() + } + + // Build and validate every step before creating the workflow or persisting + // any step. This keeps imports atomic with respect to validation failures. + steps := make([]*models.WorkflowStep, 0, len(pw.Steps)) + for _, sp := range pw.Steps { + step := s.stepFromPortable("pending-workflow", sp, posToID) + if err := models.ValidateWorkflowStep(step); err != nil { + return nil, fmt.Errorf("validate step %q: %w", sp.Name, err) + } + steps = append(steps, step) + } + wf, err := s.workflowProvider.CreateWorkflow(ctx, workspaceID, pw.Name, pw.Description) if err != nil { return nil, fmt.Errorf("create workflow: %w", err) @@ -530,17 +562,10 @@ func (s *Service) importSingleWorkflow(ctx context.Context, workspaceID string, } } - // Generate UUIDs for all steps and build position→ID map. - posToID := make(map[int]string, len(pw.Steps)) - for _, sp := range pw.Steps { - posToID[sp.Position] = uuid.New().String() - } - - // Create each step with remapped events. - for _, sp := range pw.Steps { - step := s.stepFromPortable(wf.ID, sp, posToID) + for _, step := range steps { + step.WorkflowID = wf.ID if err := s.repo.CreateStep(ctx, step); err != nil { - return nil, fmt.Errorf("create step %q: %w", sp.Name, err) + return nil, fmt.Errorf("create step %q: %w", step.Name, err) } } return wf, nil diff --git a/apps/backend/internal/workflow/service/service_test.go b/apps/backend/internal/workflow/service/service_test.go index 7326294db1..a7e3abd01a 100644 --- a/apps/backend/internal/workflow/service/service_test.go +++ b/apps/backend/internal/workflow/service/service_test.go @@ -113,6 +113,30 @@ func createStep(t *testing.T, svc *Service, step *models.WorkflowStep) { require.NoError(t, err) } +func TestCreateStepRejectsConfigureSessionWithProfile(t *testing.T) { + svc, db := setupTestService(t) + insertWorkflow(t, db, "wf-1", "Test Workflow") + + step := &models.WorkflowStep{ + WorkflowID: "wf-1", + Name: "Work", + AgentProfileID: "profile-1", + Events: models.StepEvents{OnEnter: []models.OnEnterAction{{ + Type: models.OnEnterConfigureSession, + Config: map[string]interface{}{"rules": []interface{}{ + map[string]interface{}{"agent_name": "codex", "operation": "keep"}, + }}}, + }}, + } + + err := svc.CreateStep(context.Background(), step) + require.ErrorContains(t, err, "cannot be combined with agent_profile_id") + + steps, err := svc.repo.ListStepsByWorkflow(context.Background(), "wf-1") + require.NoError(t, err) + require.Empty(t, steps) +} + // TestListTemplates_FiltersHidden verifies that templates marked // `hidden: true` in their embedded YAML are excluded from the picker shown by // the create-workflow dialog and the settings UI. @@ -455,6 +479,37 @@ func TestCreateStepsFromTemplate_NormalizesDuplicateStartSteps(t *testing.T) { assert.Equal(t, []string{"Latest Start"}, startStepNames(steps)) } +func TestCreateStepsFromTemplateRejectsInvalidConfigureSession(t *testing.T) { + svc, db := setupTestService(t) + ctx := context.Background() + + insertWorkflow(t, db, "wf-1", "Test Workflow") + err := svc.repo.CreateTemplate(ctx, &models.WorkflowTemplate{ + ID: "invalid-session-config", + Name: "Invalid Session Config", + Steps: []models.StepDefinition{{ + ID: "work", + Name: "Work", + Position: 0, + AgentProfileID: "profile-1", + Events: models.StepEvents{OnEnter: []models.OnEnterAction{{ + Type: models.OnEnterConfigureSession, + Config: map[string]interface{}{"rules": []interface{}{ + map[string]interface{}{"agent_name": "codex", "operation": "keep"}, + }}, + }}}, + }}, + }) + require.NoError(t, err) + + err = svc.CreateStepsFromTemplate(ctx, "wf-1", "invalid-session-config") + require.ErrorContains(t, err, "cannot be combined with agent_profile_id") + + steps, err := svc.repo.ListStepsByWorkflow(ctx, "wf-1") + require.NoError(t, err) + require.Empty(t, steps) +} + func findStepByName(steps []*models.WorkflowStep, name string) *models.WorkflowStep { for _, s := range steps { if s.Name == name { diff --git a/apps/backend/internal/workflow/service/sync_apply.go b/apps/backend/internal/workflow/service/sync_apply.go index e50ca0113d..e20a65cc66 100644 --- a/apps/backend/internal/workflow/service/sync_apply.go +++ b/apps/backend/internal/workflow/service/sync_apply.go @@ -156,6 +156,16 @@ func (s *Service) updateSyncedWorkflow(ctx context.Context, wf *taskmodels.Workf posToID[sp.Position] = uuid.New().String() } } + // Validate the complete desired step set before mutating workflow fields or + // persisting any step. A malformed later step must leave the synced workflow + // untouched so the next sync can retry after the source is fixed. + for _, sp := range pw.Steps { + step := s.stepFromPortable(wf.ID, sp, posToID) + if err := models.ValidateWorkflowStep(step); err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("workflow %q: invalid step %q: %v", wf.Name, sp.Name, err)) + return + } + } changed, err := s.applyWorkflowFields(ctx, wf, pw) if err != nil { diff --git a/apps/web/components/settings/agent-generated-task-title-settings.tsx b/apps/web/components/settings/agent-generated-task-title-settings.tsx index 88a9c83a2e..62824b9586 100644 --- a/apps/web/components/settings/agent-generated-task-title-settings.tsx +++ b/apps/web/components/settings/agent-generated-task-title-settings.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card"; import { Label } from "@kandev/ui/label"; import { Switch } from "@kandev/ui/switch"; @@ -10,6 +11,7 @@ import { SettingsCard } from "./settings-card"; import { useSettingsSaveContributor } from "./settings-save-provider"; export function AgentGeneratedTaskTitleSettings() { + const { t } = useTranslation(); const preference = useAppStore((state) => state.userSettings.agentGeneratedTaskTitles); const setUserSettings = useAppStore((state) => state.setUserSettings); const storeApi = useAppStoreApi(); @@ -43,21 +45,17 @@ export function AgentGeneratedTaskTitleSettings() { return ( - Agent-generated task titles - - When enabled, new task and subtask forms hide the manual title field and require a prompt. - Kandev shows a provisional title using the prompt's first six words immediately, then - asks the agent to replace it with a concise title targeting about three words. If the - agent cannot rename the task, the provisional title remains available for you to edit. - Existing and edited tasks keep their normal title field. - + {t("settings:agentGeneratedTaskTitles")} + {t("settings:agentGeneratedTaskTitlesDescription")}
- +

- Disabled by default; save this setting before opening a creation dialog. + {t("settings:agentGeneratedTaskTitlesDisabledHint")}

) => void; readOnly: boolean; }) { + const { t } = useTranslation(); const healthyProfiles = useHealthyAgentProfiles(step.agent_profile_id); + const hasConditionalSessionConfig = hasOnEnterAction(step, "configure_session"); return ( -
+
- + +
); } @@ -172,8 +183,6 @@ function StepConfigHeader({ ); } -// --- StepAutoArchiveRow --- - type StepAutoArchiveRowProps = { step: WorkflowStep; savedStep?: WorkflowStep; @@ -227,8 +236,6 @@ function StepAutoArchiveRow({ step, savedStep, onUpdate, readOnly }: StepAutoArc ); } -// --- StepCheckboxRow --- - type StepCheckboxRowProps = { id: string; checked: boolean; @@ -472,86 +479,6 @@ function StepTransitionsSection({ ); } -// --- StepPromptSection --- - -type StepPromptSectionProps = { - step: WorkflowStep; - savedStep?: WorkflowStep; - localPrompt: string; - onLocalPromptChange: (prompt: string) => void; - debouncedUpdatePrompt: (prompt: string) => void; - readOnly: boolean; -}; - -function StepPromptSection({ - step, - savedStep, - localPrompt, - onLocalPromptChange, - debouncedUpdatePrompt, - readOnly, -}: StepPromptSectionProps) { - const { prompts } = useCustomPrompts(); - return ( -
-
- - -
- {!readOnly && ( -
- Templates: - {PROMPT_TEMPLATES.map((template) => ( - - ))} -
- )} -
- { - if (readOnly) return; - onLocalPromptChange(v); - debouncedUpdatePrompt(v); - }} - language="markdown" - height={computeEditorHeight(localPrompt)} - lineNumbers="off" - readOnly={readOnly} - placeholders={STEP_PROMPT_PLACEHOLDERS} - mentionPrompts={prompts} - /> -
-

- Type {"{{"} for placeholders ( - {"{{task_prompt}}"}{" "} - inserts the task description) or {"@"} to reference a saved prompt by name — its content is - attached as hidden context, and editing the saved prompt updates every step that references - it. Note: {"{{task_prompt}}"} only expands in the step prompt itself, not inside a - referenced saved prompt. -

-
- ); -} - type StepConfigPanelProps = { step: WorkflowStep; savedStep?: WorkflowStep; @@ -608,6 +535,7 @@ export function StepConfigPanel({ toggleOnEnterAction={actions.toggleOnEnterAction} readOnly={readOnly} /> + void; + readOnly: boolean; + disabled?: boolean; +}) { + const { t } = useTranslation(); + return ( +
+
+ + {t("settings:sessionConfigCarryForwardWarning")} +
+
+ {warnings.map((warning) => ( +
+ {warning.message} +
+ + + +
+
+ ))} +
+
+ ); +} diff --git a/apps/web/components/settings/workflow-session-config-editor.test.tsx b/apps/web/components/settings/workflow-session-config-editor.test.tsx new file mode 100644 index 0000000000..0a8c415c59 --- /dev/null +++ b/apps/web/components/settings/workflow-session-config-editor.test.tsx @@ -0,0 +1,190 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { WorkflowStep } from "@/lib/types/http"; +import { SessionConfigEditor, SessionConfigToggle } from "./workflow-session-config-editor"; + +const CODEX_AGENT = "codex"; +const GROK_AGENT = "grok"; +const EFFORT_OPTION = "reasoning_effort"; +const MODEL_ID = "gpt-5.6-sol"; + +vi.mock("@/hooks/domains/settings/use-healthy-agent-profiles", () => ({ + useHealthyAgentProfiles: () => [ + { + id: "codex-profile", + label: "Codex • Default", + agent_id: "codex-agent", + agent_name: CODEX_AGENT, + cli_passthrough: false, + }, + ], +})); + +vi.mock("@/hooks/domains/settings/use-available-agents", () => ({ + useAvailableAgents: () => ({ + items: [ + { + name: CODEX_AGENT, + display_name: "Codex", + model_config: { + default_model: MODEL_ID, + current_model_id: MODEL_ID, + available_models: [{ id: MODEL_ID, name: "5.6 Sol" }], + config_options: [ + { + type: "select", + id: EFFORT_OPTION, + name: "Effort", + current_value: "high", + options: [ + { value: "high", name: "High" }, + { value: "max", name: "Max" }, + ], + }, + ], + supports_dynamic_models: true, + }, + }, + { + name: GROK_AGENT, + display_name: "Grok", + model_config: { + default_model: "grok-4", + current_model_id: "grok-4", + available_models: [{ id: "grok-4", name: "Grok 4" }], + config_options: [], + supports_dynamic_models: true, + }, + }, + ], + }), +})); + +afterEach(cleanup); + +function step(id: string, position: number, events?: WorkflowStep["events"]): WorkflowStep { + return { + id, + workflow_id: "workflow-1" as WorkflowStep["workflow_id"], + name: id, + position, + color: "bg-muted", + events, + created_at: "", + updated_at: "", + }; +} + +function SessionConfigEditorHarness({ + currentStep, + steps, + onUpdate, +}: { + currentStep: WorkflowStep; + steps: WorkflowStep[]; + onUpdate: (updates: Partial) => void; +}) { + return ( + <> + + + + ); +} + +describe("SessionConfigEditor", () => { + it("keeps conditional settings hidden until the header toggle is enabled", () => { + const onUpdate = vi.fn(); + const currentStep = step("work", 0); + const { rerender } = render( + , + ); + + expect(screen.queryByTestId("work-session-config-editor")).toBeNull(); + const toggle = screen.getByLabelText("Override original session options"); + expect(toggle).toBeTruthy(); + + fireEvent.click(toggle); + const updates = onUpdate.mock.calls[0]?.[0] as Partial; + expect(updates.events?.on_enter?.[0]).toEqual( + expect.objectContaining({ + type: "configure_session", + config: { + rules: [expect.objectContaining({ agent_name: CODEX_AGENT, operation: "set" })], + }, + }), + ); + + const enabledStep = { ...currentStep, ...updates }; + rerender( + , + ); + expect(screen.getByTestId("work-session-config-editor")).toBeTruthy(); + expect(screen.getByText("Configure original settings options")).toBeTruthy(); + fireEvent.click(screen.getByTestId("session-config-agent-0")); + expect(screen.getAllByText("Codex").length).toBeGreaterThan(0); + expect(screen.queryByText("Grok")).toBeNull(); + }); + + it("offers keep, restore, and set-new choices for carried settings", () => { + const onUpdate = vi.fn(); + const source = step("work", 0, { + on_enter: [ + { + type: "configure_session", + config: { rules: [{ agent_name: CODEX_AGENT, operation: "set", model: MODEL_ID }] }, + }, + ], + on_turn_complete: [{ type: "move_to_next" }], + }); + const reviewStep = step("review", 1); + render( + , + ); + + expect(screen.getByLabelText("Override original session options")).toBeTruthy(); + expect(screen.getByTestId("session-config-carry-warning")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Restore" })); + expect(onUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + events: expect.objectContaining({ + on_enter: [ + expect.objectContaining({ + type: "configure_session", + config: { + rules: [{ agent_name: CODEX_AGENT, operation: "restore_original" }], + }, + }), + ], + }), + }), + ); + }); + + it("disables conditional configuration while a fixed profile override is selected", () => { + const fixedStep = { ...step("work", 0), agent_profile_id: "codex-profile" }; + render( + <> + + + , + ); + + expect( + screen.getByLabelText("Override original session options").getAttribute("disabled"), + ).not.toBeNull(); + expect(screen.queryByTestId("work-session-config-editor")).toBeNull(); + }); +}); diff --git a/apps/web/components/settings/workflow-session-config-editor.tsx b/apps/web/components/settings/workflow-session-config-editor.tsx new file mode 100644 index 0000000000..64d33dc115 --- /dev/null +++ b/apps/web/components/settings/workflow-session-config-editor.tsx @@ -0,0 +1,350 @@ +"use client"; + +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { IconPlus } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Checkbox } from "@kandev/ui/checkbox"; +import { Label } from "@kandev/ui/label"; +import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; +import { useHealthyAgentProfiles } from "@/hooks/domains/settings/use-healthy-agent-profiles"; +import type { WorkflowStep } from "@/lib/types/http"; +import type { ConfigureSessionOperation, ConfigureSessionRule } from "@/lib/types/workflow-actions"; +import { + analyzeSessionConfigCarryForward, + type SessionConfigCarryWarning, +} from "@/lib/workflows/session-config-carry-analysis"; +import { HelpTip } from "./workflow-pipeline-editor-helpers"; +import { isWorkflowStepValueDirty } from "./workflow-dirty-state"; +import { SessionConfigCarryWarningPanel } from "./workflow-session-config-carry-warning"; +import { SessionConfigRuleCard } from "./workflow-session-config-rule-card"; +import { + buildAgentChoices, + configureSessionAction, + defaultConfigureSessionRule, + defaultModelForAgent, + withoutConfigureSession, + withConfigureSessionRules, + type AgentChoice, +} from "./workflow-session-config-shared"; + +type SessionConfigEditorProps = { + step: WorkflowStep; + savedStep?: WorkflowStep; + steps: WorkflowStep[]; + onUpdate: (updates: Partial) => void; + readOnly: boolean; +}; + +export function SessionConfigToggle({ + step, + savedStep, + onUpdate, + readOnly, +}: { + step: WorkflowStep; + savedStep?: WorkflowStep; + onUpdate: (updates: Partial) => void; + readOnly: boolean; +}) { + const { t } = useTranslation(); + const profiles = useHealthyAgentProfiles(step.agent_profile_id); + const availableAgents = useAvailableAgents(); + const enabled = !!configureSessionAction(step); + const isDirty = isWorkflowStepValueDirty(step, savedStep, (item) => + JSON.stringify(configureSessionAction(item)?.config?.rules ?? []), + ); + const disabled = readOnly || !!step.agent_profile_id || (!enabled && profiles.length === 0); + + const setEnabled = (nextEnabled: boolean) => { + if (readOnly) return; + if (!nextEnabled) { + onUpdate(withoutConfigureSession(step)); + return; + } + const rule = defaultConfigureSessionRule(profiles, availableAgents.items); + if (!rule) return; + onUpdate(withConfigureSessionRules(step, [rule])); + }; + + return ( +
+ setEnabled(checked === true)} + disabled={disabled} + data-settings-dirty={isDirty} + data-testid={`${step.id}-override-original-session`} + /> + + +
+ ); +} + +export function SessionConfigEditor({ + step, + savedStep, + steps, + onUpdate, + readOnly, +}: SessionConfigEditorProps) { + const profiles = useHealthyAgentProfiles(step.agent_profile_id); + const availableAgents = useAvailableAgents(); + const action = configureSessionAction(step); + const rules = action?.config?.rules ?? []; + const warnings = analyzeSessionConfigCarryForward(steps, step.id); + const choices = useMemo(() => buildAgentChoices(profiles), [profiles]); + const availableChoices = choices.filter( + (choice) => !rules.some((rule) => rule.agent_name === choice.name), + ); + const isDirty = isWorkflowStepValueDirty(step, savedStep, (item) => + JSON.stringify(configureSessionAction(item)?.config?.rules ?? []), + ); + + const updateRules = (nextRules: ConfigureSessionRule[]) => { + onUpdate(withConfigureSessionRules(step, nextRules)); + }; + + const addRule = (agentName?: string, operation: ConfigureSessionOperation = "set") => { + const selectedAgent = + availableChoices.find((choice) => choice.name === agentName)?.name ?? + availableChoices[0]?.name ?? + ""; + if (!selectedAgent) return; + updateRules([ + ...rules, + { + agent_name: selectedAgent, + operation, + ...(operation === "set" + ? { model: defaultModelForAgent(selectedAgent, availableAgents.items) } + : {}), + }, + ]); + }; + + const createCarryRule = ( + warning: SessionConfigCarryWarning, + operation: ConfigureSessionOperation, + ) => { + if (rules.some((rule) => rule.agent_name === warning.agentName)) return; + addRule(warning.agentName, operation); + }; + + if (!action && warnings.length === 0) return null; + + return ( +
+ {action && ( + addRule()} + canAddRule={availableChoices.length > 0} + /> + )} + +
+ ); +} + +function SessionConfigBody({ + step, + action, + rules, + warnings, + choices, + availableAgents, + readOnly, + fixedProfile, + onChooseCarryRule, + onUpdateRules, +}: { + step: WorkflowStep; + action: ReturnType; + rules: ConfigureSessionRule[]; + warnings: SessionConfigCarryWarning[]; + choices: AgentChoice[]; + availableAgents: ReturnType["items"]; + readOnly: boolean; + fixedProfile: boolean; + onChooseCarryRule: ( + warning: SessionConfigCarryWarning, + operation: ConfigureSessionOperation, + ) => void; + onUpdateRules: (rules: ConfigureSessionRule[]) => void; +}) { + const { t } = useTranslation(); + return ( + <> + {step.agent_profile_id && action && ( +

+ {t("settings:sessionConfigFixedProfileWarning")} +

+ )} + {!action && warnings.length > 0 && !step.agent_profile_id && ( + + )} + {action && ( + + )} + + ); +} + +function SessionConfigRuleList({ + rules, + warnings, + choices, + availableAgents, + readOnly, + fixedProfile, + onChooseCarryRule, + onUpdateRules, +}: { + rules: ConfigureSessionRule[]; + warnings: SessionConfigCarryWarning[]; + choices: AgentChoice[]; + availableAgents: ReturnType["items"]; + readOnly: boolean; + fixedProfile: boolean; + onChooseCarryRule: ( + warning: SessionConfigCarryWarning, + operation: ConfigureSessionOperation, + ) => void; + onUpdateRules: (rules: ConfigureSessionRule[]) => void; +}) { + const { t } = useTranslation(); + return ( +
+ {warnings.length > 0 && ( + + )} + {rules.map((rule, index) => ( + + choice.name === rule.agent_name || + !rules.some( + (candidate, candidateIndex) => + candidateIndex !== index && candidate.agent_name === choice.name, + ), + )} + availableAgents={availableAgents} + readOnly={readOnly} + onChange={(nextRule) => { + if ( + nextRule.agent_name !== rule.agent_name && + rules.some( + (candidate, candidateIndex) => + candidateIndex !== index && candidate.agent_name === nextRule.agent_name, + ) + ) { + return; + } + const nextRules = rules.slice(); + nextRules[index] = nextRule; + onUpdateRules(nextRules); + }} + onRemove={() => + onUpdateRules(rules.filter((_, candidateIndex) => candidateIndex !== index)) + } + /> + ))} + {rules.length === 0 && ( +

{t("settings:sessionConfigEmptyRules")}

+ )} +
+ ); +} + +function SessionConfigOptionsHeader({ + step, + isDirty, + readOnly, + onAddRule, + canAddRule, +}: { + step: WorkflowStep; + isDirty: boolean; + readOnly: boolean; + onAddRule: () => void; + canAddRule: boolean; +}) { + const { t } = useTranslation(); + const disabled = readOnly || !!step.agent_profile_id; + return ( +
+
+
+ +

+ {t("settings:configureOriginalSessionOptionsDescription")} +

+
+ +
+ {!readOnly && !step.agent_profile_id && ( + + )} +
+ ); +} diff --git a/apps/web/components/settings/workflow-session-config-rule-card.tsx b/apps/web/components/settings/workflow-session-config-rule-card.tsx new file mode 100644 index 0000000000..2fada8c5e8 --- /dev/null +++ b/apps/web/components/settings/workflow-session-config-rule-card.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { IconTrash } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@kandev/ui/button"; +import { Label } from "@kandev/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { + configOptionToModelOptions, + isModelConfigOption, + ModelConfigSelector, + type SelectConfigOption, +} from "@/components/model-config-selector"; +import type { AvailableAgent } from "@/lib/types/http"; +import type { ConfigureSessionOperation, ConfigureSessionRule } from "@/lib/types/workflow-actions"; +import { + type AgentChoice, + defaultModelForAgent, + modelConfigOptions, + operationRule, +} from "./workflow-session-config-shared"; + +export function SessionConfigRuleCard({ + rule, + index, + choices, + availableAgents, + readOnly, + onChange, + onRemove, +}: { + rule: ConfigureSessionRule; + index: number; + choices: AgentChoice[]; + availableAgents: AvailableAgent[]; + readOnly: boolean; + onChange: (rule: ConfigureSessionRule) => void; + onRemove: () => void; +}) { + const selectedAgent = availableAgents.find((agent) => agent.name === rule.agent_name); + const configOptions = modelConfigOptions(selectedAgent?.model_config, rule); + const modelConfigOption = configOptions.find(isModelConfigOption); + const modelOptions = modelConfigOption + ? configOptionToModelOptions(modelConfigOption) + : (selectedAgent?.model_config?.available_models ?? []).map((model) => ({ + id: model.id, + name: model.name, + description: model.description ?? (model.id !== model.name ? model.id : undefined), + usageMultiplier: + typeof model.meta?.copilotUsage === "string" ? model.meta.copilotUsage : undefined, + })); + const currentModel = currentModelForRule(rule, modelConfigOption, selectedAgent); + + return ( +
+ + + {rule.operation === "set" && ( + + )} +
+ ); +} + +function SessionConfigRuleHeader({ + rule, + index, + choices, + availableAgents, + readOnly, + onChange, + onRemove, +}: { + rule: ConfigureSessionRule; + index: number; + choices: AgentChoice[]; + availableAgents: AvailableAgent[]; + readOnly: boolean; + onChange: (rule: ConfigureSessionRule) => void; + onRemove: () => void; +}) { + const { t } = useTranslation(); + return ( +
+ + + + +
+ ); +} + +function currentModelForRule( + rule: ConfigureSessionRule, + modelConfigOption: SelectConfigOption | undefined, + selectedAgent: AvailableAgent | undefined, +): string | null { + return ( + (rule.operation === "set" ? rule.model : undefined) || + modelConfigOption?.currentValue || + selectedAgent?.model_config?.current_model_id || + selectedAgent?.model_config?.default_model || + null + ); +} + +function SessionConfigRuleSettings({ + rule, + modelOptions, + currentModel, + configOptions, + readOnly, + onChange, +}: { + rule: Extract; + modelOptions: { id: string; name: string; description?: string; usageMultiplier?: string }[]; + currentModel: string | null; + configOptions: SelectConfigOption[]; + readOnly: boolean; + onChange: (rule: ConfigureSessionRule) => void; +}) { + const { t } = useTranslation(); + return ( +
+ {modelOptions.length > 0 || configOptions.length > 0 ? ( + onChange({ ...rule, model })} + onConfigChange={(configId, value) => + onChange({ + ...rule, + config_options: { ...(rule.config_options ?? {}), [configId]: value }, + }) + } + disabled={readOnly} + placeholder={t("settings:chooseModelAndSessionSettings")} + ariaLabel={t("settings:settingsForAgent", { agent: rule.agent_name })} + triggerClassName="min-h-11 w-full sm:min-h-10" + /> + ) : ( +

+ {t("settings:sessionConfigModelOptionsUnavailable")} +

+ )} +
+ ); +} diff --git a/apps/web/components/settings/workflow-session-config-shared.ts b/apps/web/components/settings/workflow-session-config-shared.ts new file mode 100644 index 0000000000..3eb8448e0e --- /dev/null +++ b/apps/web/components/settings/workflow-session-config-shared.ts @@ -0,0 +1,122 @@ +import type { AgentProfileOption } from "@/lib/state/slices"; +import type { AvailableAgent, WorkflowStep } from "@/lib/types/http"; +import type { + ConfigureSessionOperation, + ConfigureSessionRule, + OnEnterAction, +} from "@/lib/types/workflow-actions"; +import { + isModelConfigOption, + type DynamicConfigOption, + type SelectConfigOption, +} from "@/components/model-config-selector"; + +export type AgentChoice = { name: string; label: string }; + +export type ConfigureSessionAction = Extract; + +export function configureSessionAction(step: WorkflowStep): ConfigureSessionAction | undefined { + return step.events?.on_enter?.find( + (candidate): candidate is ConfigureSessionAction => candidate.type === "configure_session", + ); +} + +export function buildAgentChoices(profiles: AgentProfileOption[]): AgentChoice[] { + const choices = new Map(); + for (const profile of profiles) { + choices.set(profile.agent_name, profile.label.split(" • ")[0] || profile.agent_name); + } + return [...choices.entries()].map(([name, label]) => ({ name, label })); +} + +export function withConfigureSessionRules( + step: WorkflowStep, + rules: ConfigureSessionRule[], +): Partial { + const events = step.events ?? {}; + const onEnter = events.on_enter ?? []; + const nextAction: OnEnterAction = { + type: "configure_session", + config: { rules }, + }; + const nextOnEnter = onEnter.some((candidate) => candidate.type === "configure_session") + ? onEnter.map((candidate) => (candidate.type === "configure_session" ? nextAction : candidate)) + : [...onEnter, nextAction]; + return { events: { ...events, on_enter: nextOnEnter } }; +} + +export function withoutConfigureSession(step: WorkflowStep): Partial { + const events = step.events ?? {}; + return { + events: { + ...events, + on_enter: (events.on_enter ?? []).filter( + (candidate) => candidate.type !== "configure_session", + ), + }, + }; +} + +export function defaultConfigureSessionRule( + profiles: AgentProfileOption[], + availableAgents: AvailableAgent[], +): ConfigureSessionRule | undefined { + const selectedAgent = buildAgentChoices(profiles)[0]?.name; + if (!selectedAgent) return undefined; + const model = defaultModelForAgent(selectedAgent, availableAgents); + return { + agent_name: selectedAgent, + operation: "set", + ...(model ? { model } : {}), + }; +} + +export function defaultModelForAgent( + agentName: string, + availableAgents: AvailableAgent[], +): string | undefined { + const config = availableAgents.find((agent) => agent.name === agentName)?.model_config; + return config?.current_model_id || config?.default_model || config?.available_models?.[0]?.id; +} + +export function modelConfigOptions( + modelConfig: AvailableAgent["model_config"] | undefined, + rule: ConfigureSessionRule, +): SelectConfigOption[] { + return (modelConfig?.config_options ?? []).map((option) => { + const dynamicOption: DynamicConfigOption = { + type: option.type, + id: option.id, + name: option.name, + description: option.description, + currentValue: isModelConfigOption(option) + ? (rule.operation === "set" ? rule.model : undefined) || option.current_value + : (rule.operation === "set" ? rule.config_options?.[option.id] : undefined) || + option.current_value, + category: option.category, + options: option.options, + }; + return { ...dynamicOption, options: option.options ?? [] }; + }); +} + +export function operationRule( + rule: ConfigureSessionRule, + operation: ConfigureSessionOperation, + availableAgents: AvailableAgent[] = [], +): ConfigureSessionRule { + if (operation === "set") { + const model = + (rule.operation === "set" ? rule.model : undefined) || + defaultModelForAgent(rule.agent_name, availableAgents); + return { + agent_name: rule.agent_name, + operation, + ...(model ? { model } : {}), + ...(rule.operation === "set" && rule.config_options + ? { config_options: rule.config_options } + : {}), + }; + } + return { agent_name: rule.agent_name, operation }; +} diff --git a/apps/web/components/settings/workflow-step-prompt-section.tsx b/apps/web/components/settings/workflow-step-prompt-section.tsx new file mode 100644 index 0000000000..bb548ccb9a --- /dev/null +++ b/apps/web/components/settings/workflow-step-prompt-section.tsx @@ -0,0 +1,90 @@ +"use client"; + +import type { WorkflowStep } from "@/lib/types/http"; +import { useTranslation } from "react-i18next"; +import { Label } from "@kandev/ui/label"; +import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts"; +import { + ScriptEditor, + computeEditorHeight, +} from "@/components/settings/profile-edit/script-editor"; +import { + HelpTip, + STEP_PROMPT_PLACEHOLDERS, + PROMPT_TEMPLATES, +} from "./workflow-pipeline-editor-helpers"; + +export function StepPromptSection({ + step, + savedStep, + localPrompt, + onLocalPromptChange, + debouncedUpdatePrompt, + readOnly, +}: { + step: WorkflowStep; + savedStep?: WorkflowStep; + localPrompt: string; + onLocalPromptChange: (prompt: string) => void; + debouncedUpdatePrompt: (prompt: string) => void; + readOnly: boolean; +}) { + const { t } = useTranslation(); + const { prompts } = useCustomPrompts(); + return ( +
+
+ + +
+ {!readOnly && ( +
+ {t("settings:templates")} + {PROMPT_TEMPLATES.map((template) => ( + + ))} +
+ )} +
+ { + if (readOnly) return; + onLocalPromptChange(value); + debouncedUpdatePrompt(value); + }} + language="markdown" + height={computeEditorHeight(localPrompt)} + lineNumbers="off" + readOnly={readOnly} + placeholders={STEP_PROMPT_PLACEHOLDERS} + mentionPrompts={prompts} + /> +
+

+ {t("settings:stepPromptUsageHint", { + open: "{{", + taskPrompt: "{{task_prompt}}", + })} +

+
+ ); +} diff --git a/apps/web/components/task/new-subtask-form-parts.tsx b/apps/web/components/task/new-subtask-form-parts.tsx index 73361688ae..f3ac04c40c 100644 --- a/apps/web/components/task/new-subtask-form-parts.tsx +++ b/apps/web/components/task/new-subtask-form-parts.tsx @@ -26,6 +26,7 @@ import { } from "./session-dialog-shared"; import { ContextZone } from "./chat/context-items/context-zone"; import { clampTaskTitleInput } from "@/lib/task-title"; +import { useTranslation } from "react-i18next"; export function WorktreeBadge({ show, branch }: { show: boolean; branch: string | null }) { if (!show || !branch) return null; @@ -419,6 +420,7 @@ export function SubtaskFormBody({ onClose, onSubmit, }: SubtaskFormBodyProps) { + const { t } = useTranslation(); const showWorktreeBadge = shouldShowWorktreeBadge(fs, worktreeBranch, parentRepositoryId); const inheritParent = workspaceMode === "inherit_parent"; return ( @@ -429,13 +431,13 @@ export function SubtaskFormBody({ htmlFor="subtask-title-input" className="text-xs font-medium text-muted-foreground" > - Title + {t("common:title")} setTitle(clampTaskTitleInput(e.target.value))} - placeholder="Subtask title" + placeholder={t("common:subtaskTitle")} className="min-w-0 max-w-full text-sm" data-testid="subtask-title-input" disabled={isCreating} diff --git a/apps/web/e2e/pages/workflow-settings-page.ts b/apps/web/e2e/pages/workflow-settings-page.ts index f31bbf43d4..acfeee8fb9 100644 --- a/apps/web/e2e/pages/workflow-settings-page.ts +++ b/apps/web/e2e/pages/workflow-settings-page.ts @@ -124,8 +124,8 @@ export class WorkflowSettingsPage { } /** Save every dirty workflow contributor through the route-level action. */ - async saveChanges(): Promise { - await this.submitSaveChanges(); + async saveChanges(touch = false): Promise { + await this.submitSaveChanges(touch); await expect .poll( async () => diff --git a/apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts b/apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts index 32968011c0..3a0cce0b98 100644 --- a/apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts +++ b/apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts @@ -2,6 +2,76 @@ import { test, expect } from "../../fixtures/test-base"; import { WorkflowSettingsPage } from "../../pages/workflow-settings-page"; test.describe("Workflow settings on mobile", () => { + test("configures original-session rules with touch-sized controls", async ({ + testPage, + apiClient, + seedData, + prCapture, + }) => { + const workflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Mobile Session Settings", + ); + const workStep = await apiClient.createWorkflowStep(workflow.id, "Work", 0, { + is_start_step: true, + }); + + const page = new WorkflowSettingsPage(testPage); + await page.goto(seedData.workspaceId); + const card = await page.findWorkflowCard("Mobile Session Settings"); + await expect(card).toBeVisible(); + await page.selectStep(card, "Work", true); + + const agentProfileHelpId = `${workStep.id}-agent-profile-help`; + const originalSessionHelpId = `${workStep.id}-override-original-session-help`; + const helpOrder = await card + .locator(`[data-testid="${agentProfileHelpId}"], [data-testid="${originalSessionHelpId}"]`) + .evaluateAll((elements) => elements.map((element) => element.getAttribute("data-testid"))); + expect(helpOrder).toEqual([agentProfileHelpId, originalSessionHelpId]); + + await card.getByLabel("Override original session options").tap(); + const editor = card.getByTestId(`${workStep.id}-session-config-editor`); + const rule = editor.getByTestId("session-config-rule-0"); + await expect(rule).toBeVisible(); + + const settings = rule.getByRole("button", { name: /Settings for/ }); + await settings.tap(); + await testPage.getByText("Mock Smart", { exact: true }).tap(); + await testPage.getByTestId("config-option-trigger-effort").tap(); + await testPage.getByRole("button", { name: "High", exact: true }).tap(); + await testPage.keyboard.press("Escape"); + await prCapture.screenshot("mobile-original-session-editor", { + caption: "Mobile workflow step editor with touch-sized original-session controls.", + }); + + const viewportWidth = await testPage.evaluate(() => window.innerWidth); + for (const control of [editor, rule, settings]) { + const box = await control.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.height).toBeGreaterThanOrEqual(44); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(viewportWidth); + } + expect( + await testPage.evaluate(() => document.documentElement.scrollWidth > window.innerWidth), + ).toBe(false); + + await page.saveChanges(true); + const { steps } = await apiClient.listWorkflowSteps(workflow.id); + expect(steps.find((step) => step.id === workStep.id)?.events?.on_enter?.[0]).toMatchObject({ + type: "configure_session", + config: { + rules: [ + { + operation: "set", + model: "mock-smart", + config_options: { effort: "high" }, + }, + ], + }, + }); + }); + test("configures an all child tasks complete transition", async ({ testPage, apiClient, diff --git a/apps/web/e2e/tests/workflow/workflow-settings.spec.ts b/apps/web/e2e/tests/workflow/workflow-settings.spec.ts index d8dcb0b660..d782f1cbfc 100644 --- a/apps/web/e2e/tests/workflow/workflow-settings.spec.ts +++ b/apps/web/e2e/tests/workflow/workflow-settings.spec.ts @@ -38,6 +38,81 @@ test.describe("Workflow settings", () => { } }); + test("configures the original session with the shared model settings picker", async ({ + testPage, + backend, + apiClient, + seedData, + prCapture, + }) => { + const available = await testPage.request.get(`${backend.baseUrl}/api/v1/agents/available`); + expect(available.ok()).toBe(true); + const availablePayload = (await available.json()) as { + agents?: Array<{ + name: string; + model_config?: { config_options?: Array<{ id: string }> }; + }>; + }; + const agent = availablePayload.agents?.find((item) => + item.model_config?.config_options?.some((option) => option.id === "effort"), + ); + expect(agent).toBeDefined(); + + const workflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Conditional Session Settings", + ); + const workStep = await apiClient.createWorkflowStep(workflow.id, "Work", 0, { + is_start_step: true, + }); + + const page = new WorkflowSettingsPage(testPage); + await page.goto(seedData.workspaceId); + const card = await page.findWorkflowCard("Conditional Session Settings"); + await expect(card).toBeVisible(); + await page.selectStep(card, "Work"); + + const agentProfileHelpId = `${workStep.id}-agent-profile-help`; + const originalSessionHelpId = `${workStep.id}-override-original-session-help`; + const helpOrder = await card + .locator(`[data-testid="${agentProfileHelpId}"], [data-testid="${originalSessionHelpId}"]`) + .evaluateAll((elements) => elements.map((element) => element.getAttribute("data-testid"))); + expect(helpOrder).toEqual([agentProfileHelpId, originalSessionHelpId]); + + await card.getByLabel("Override original session options").click(); + const editor = card.getByTestId(`${workStep.id}-session-config-editor`); + await expect(editor.getByTestId("session-config-rule-0")).toBeVisible(); + await expect(page.stepAgentProfileSelect(card)).toBeDisabled(); + + const settings = editor.getByRole("button", { name: `Settings for ${agent!.name}` }); + await settings.click(); + await testPage.getByText("Mock Smart", { exact: true }).click(); + await testPage.getByTestId("config-option-trigger-effort").click(); + await testPage.getByRole("button", { name: "High", exact: true }).click(); + await testPage.keyboard.press("Escape"); + await prCapture.screenshot("desktop-original-session-editor", { + caption: "Workflow step editor with a conditional original-session model and effort rule.", + }); + + await page.saveChanges(); + const { steps } = await apiClient.listWorkflowSteps(workflow.id); + expect(steps.find((step) => step.id === workStep.id)?.events?.on_enter).toEqual([ + { + type: "configure_session", + config: { + rules: [ + { + agent_name: agent!.name, + operation: "set", + model: "mock-smart", + config_options: { effort: "high" }, + }, + ], + }, + }, + ]); + }); + test("creates a workflow from template only after Save", async ({ testPage, apiClient, diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index c1d3b20b57..614ab442c3 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -47,6 +47,9 @@ export type { MoveToStepConfig, OnEnterAction, OnEnterActionType, + ConfigureSessionActionConfig, + ConfigureSessionOperation, + ConfigureSessionRule, OnExitAction, OnExitActionType, OnTurnCompleteAction, diff --git a/apps/web/lib/types/workflow-actions.ts b/apps/web/lib/types/workflow-actions.ts index d558162e2c..769fbe59e3 100644 --- a/apps/web/lib/types/workflow-actions.ts +++ b/apps/web/lib/types/workflow-actions.ts @@ -11,7 +11,31 @@ */ // On Enter action types -export type OnEnterActionType = "enable_plan_mode" | "auto_start_agent" | "reset_agent_context"; +export type OnEnterActionType = + | "enable_plan_mode" + | "auto_start_agent" + | "reset_agent_context" + | "configure_session"; + +export type ConfigureSessionOperation = "set" | "keep" | "restore_original"; + +export type ConfigureSessionSetRule = { + agent_name: string; + operation: "set"; + model?: string; + config_options?: Record; +}; + +export type ConfigureSessionCarryRule = { + agent_name: string; + operation: "keep" | "restore_original"; +}; + +export type ConfigureSessionRule = ConfigureSessionSetRule | ConfigureSessionCarryRule; + +export type ConfigureSessionActionConfig = { + rules: ConfigureSessionRule[]; +}; // On Turn Start action types export type OnTurnStartActionType = "move_to_next" | "move_to_previous" | "move_to_step"; @@ -72,7 +96,8 @@ export type OnEnterAction = queue_if_busy?: boolean; }; } - | { type: "reset_agent_context" }; + | { type: "reset_agent_context" } + | { type: "configure_session"; config: ConfigureSessionActionConfig }; export type OnTurnStartAction = | { type: "move_to_next"; config?: TransitionConfig } diff --git a/apps/web/lib/workflows/session-config-carry-analysis.test.ts b/apps/web/lib/workflows/session-config-carry-analysis.test.ts new file mode 100644 index 0000000000..2ebb3b93e4 --- /dev/null +++ b/apps/web/lib/workflows/session-config-carry-analysis.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import type { WorkflowStep } from "@/lib/types/http"; +import type { ConfigureSessionRule, OnTurnCompleteAction } from "@/lib/types/workflow-actions"; +import { analyzeSessionConfigCarryForward } from "./session-config-carry-analysis"; + +function step(id: string, position: number, action?: Record): WorkflowStep { + return { + id, + workflow_id: "workflow-1" as WorkflowStep["workflow_id"], + name: id, + position, + color: "bg-muted", + events: action + ? { + on_enter: [ + { + type: "configure_session", + config: action as { rules: ConfigureSessionRule[] }, + }, + ], + } + : {}, + created_at: "", + updated_at: "", + }; +} + +function moveToNext(source: WorkflowStep): WorkflowStep { + return withTurnComplete(source, [{ type: "move_to_next" }]); +} + +function withTurnComplete(source: WorkflowStep, actions: OnTurnCompleteAction[]): WorkflowStep { + return { + ...source, + events: { ...source.events, on_turn_complete: actions }, + }; +} + +describe("analyzeSessionConfigCarryForward", () => { + it("warns when a later step can inherit a changed family configuration", () => { + const warnings = analyzeSessionConfigCarryForward( + [ + moveToNext( + step("work", 0, { + rules: [{ agent_name: "codex", operation: "set", model: "gpt-5.6-sol" }], + }), + ), + step("review", 1), + ], + "review", + ); + + expect(warnings).toEqual([ + expect.objectContaining({ + agentName: "codex", + sourceStepId: "work", + model: "gpt-5.6-sol", + }), + ]); + }); + + it("does not warn when the later step explicitly keeps, restores, or sets values", () => { + const base = moveToNext( + step("work", 0, { + rules: [{ agent_name: "codex", operation: "set", model: "gpt-5.6-sol" }], + }), + ); + expect( + analyzeSessionConfigCarryForward( + [base, step("keep", 1, { rules: [{ agent_name: "codex", operation: "keep" }] })], + "keep", + ), + ).toEqual([]); + expect( + analyzeSessionConfigCarryForward( + [ + base, + step("restore", 1, { rules: [{ agent_name: "codex", operation: "restore_original" }] }), + ], + "restore", + ), + ).toEqual([]); + expect( + analyzeSessionConfigCarryForward( + [ + base, + step("set", 1, { + rules: [ + { + agent_name: "codex", + operation: "set", + config_options: { reasoning_effort: "max" }, + }, + ], + }), + ], + "set", + ), + ).toEqual([]); + }); + + it("keeps a warning when another family is configured because the original family may still be active", () => { + expect( + analyzeSessionConfigCarryForward( + [ + moveToNext( + step("work", 0, { rules: [{ agent_name: "codex", operation: "set", model: "sol" }] }), + ), + step("review", 1, { rules: [{ agent_name: "claude", operation: "keep" }] }), + ], + "review", + ), + ).toEqual([expect.objectContaining({ agentName: "codex" })]); + }); + + it("follows explicit branches and converges through cycles", () => { + const work = moveToNext( + step("work", 0, { rules: [{ agent_name: "codex", operation: "set", model: "sol" }] }), + ); + const review = withTurnComplete(step("review", 1), [ + { type: "move_to_step", config: { step_id: "done" } }, + { type: "move_to_previous" }, + ]); + const done = step("done", 2); + + expect(analyzeSessionConfigCarryForward([work, review, done], "done")).toEqual([ + expect.objectContaining({ agentName: "codex", sourceStepId: "work" }), + ]); + }); + + it("does not warn when every reachable branch restores before the target", () => { + const work = moveToNext( + step("work", 0, { rules: [{ agent_name: "codex", operation: "set", model: "sol" }] }), + ); + const restoreBase = step("restore", 1, { + rules: [{ agent_name: "codex", operation: "restore_original" }], + }); + const restore = withTurnComplete(restoreBase, [{ type: "move_to_next" }]); + expect(analyzeSessionConfigCarryForward([work, restore, step("done", 2)], "done")).toEqual([]); + }); +}); diff --git a/apps/web/lib/workflows/session-config-carry-analysis.ts b/apps/web/lib/workflows/session-config-carry-analysis.ts new file mode 100644 index 0000000000..e3daafcb03 --- /dev/null +++ b/apps/web/lib/workflows/session-config-carry-analysis.ts @@ -0,0 +1,186 @@ +import type { WorkflowStep } from "@/lib/types/http"; +import type { + ConfigureSessionOperation, + ConfigureSessionRule, + OnTurnCompleteAction, + OnTurnStartAction, +} from "@/lib/types/workflow-actions"; + +export type SessionConfigCarryWarning = { + agentName: string; + sourceStepId: string; + sourceStepName: string; + model?: string; + configOptions: Record; + message: string; +}; + +type CarryState = "original" | "changed"; + +type ChangedSource = { + step: WorkflowStep; + rule: ConfigureSessionRule; +}; + +type CarryVisit = { + step: WorkflowStep; + incoming: CarryState; + changedSource?: ChangedSource; +}; + +type CarryEdge = { + destination: WorkflowStep; +}; + +type TransitionAction = OnTurnStartAction | OnTurnCompleteAction; + +/** + * Finds session settings that can flow into a step because runtime overrides + * intentionally persist on the original conversation tab. + * + * The analysis follows the configured workflow transition graph (including + * relative moves, explicit moves, joins, and cycles). It tracks an original / + * changed lattice per agent family and warns whenever any reachable path enters + * the target with changed values and the target has no explicit decision. + * Manual card moves are intentionally outside this graph. + */ +export function analyzeSessionConfigCarryForward( + steps: WorkflowStep[], + targetStepId: string, +): SessionConfigCarryWarning[] { + const ordered = [...steps].sort((left, right) => left.position - right.position); + const target = ordered.find((step) => step.id === targetStepId); + if (!target || ordered.length === 0) return []; + + const targetFamilies = new Set(configureRules(target).map((rule) => rule.agent_name)); + const families = new Set( + ordered.flatMap((step) => configureRules(step).map((rule) => rule.agent_name)), + ); + const graph = buildTransitionGraph(ordered); + const starts = ordered.filter((step) => step.is_start_step); + const startSteps = starts.length > 0 ? starts : [ordered[0]]; + const warnings = new Map(); + + for (const agentName of families) { + if (targetFamilies.has(agentName)) continue; + analyzeFamily({ ordered, graph, target, agentName, startSteps, warnings }); + } + + return [...warnings.values()]; +} + +function analyzeFamily({ + graph, + target, + agentName, + startSteps, + warnings, +}: { + ordered: WorkflowStep[]; + graph: Map; + target: WorkflowStep; + agentName: string; + startSteps: WorkflowStep[]; + warnings: Map; +}) { + const queue: CarryVisit[] = startSteps.map((step) => ({ step, incoming: "original" })); + const visited = new Set(); + + for (let index = 0; index < queue.length; index += 1) { + const visit = queue[index]; + const key = [visit.step.id, visit.incoming, visit.changedSource?.step.id ?? ""].join("|"); + if (visited.has(key)) continue; + visited.add(key); + + if (visit.step.id === target.id && visit.incoming === "changed") { + const source = visit.changedSource; + if (source) { + warnings.set(`${agentName}|${source.step.id}`, carryWarning(agentName, source)); + } + } + + const outgoing = applyRule(visit.step, agentName, visit.incoming, visit.changedSource); + for (const edge of graph.get(visit.step.id) ?? []) { + queue.push({ + step: edge.destination, + incoming: outgoing.state, + changedSource: outgoing.changedSource, + }); + } + } +} + +function applyRule( + step: WorkflowStep, + agentName: string, + incoming: CarryState, + changedSource: ChangedSource | undefined, +): { state: CarryState; changedSource?: ChangedSource } { + const rule = configureRules(step).find((candidate) => candidate.agent_name === agentName); + if (!rule || rule.operation === "keep") return { state: incoming, changedSource }; + if (rule.operation === "restore_original") return { state: "original" }; + return { state: "changed", changedSource: { step, rule } }; +} + +function carryWarning(agentName: string, source: ChangedSource): SessionConfigCarryWarning { + return { + agentName, + sourceStepId: source.step.id, + sourceStepName: source.step.name, + model: source.rule.operation === "set" ? source.rule.model : undefined, + configOptions: source.rule.operation === "set" ? { ...(source.rule.config_options ?? {}) } : {}, + message: `Settings changed in ${source.step.name} may carry into this step for ${agentName}. Choose keep, restore original, or set new values.`, + }; +} + +function buildTransitionGraph(steps: WorkflowStep[]): Map { + const stepsById = new Map(steps.map((step) => [step.id, step])); + const graph = new Map(); + + steps.forEach((step, index) => { + const destinations: WorkflowStep[] = []; + const actions: TransitionAction[] = [ + ...(step.events?.on_turn_start ?? []), + ...(step.events?.on_turn_complete ?? []), + ]; + for (const action of actions) { + const destination = resolveDestination(action, index, steps, stepsById); + if (destination && !destinations.some((candidate) => candidate.id === destination.id)) { + destinations.push(destination); + } + } + graph.set( + step.id, + destinations.map((destination) => ({ destination })), + ); + }); + return graph; +} + +function resolveDestination( + action: TransitionAction, + sourceIndex: number, + steps: WorkflowStep[], + stepsById: Map, +): WorkflowStep | undefined { + if (action.type === "move_to_next") return steps[sourceIndex + 1]; + if (action.type === "move_to_previous") return steps[sourceIndex - 1]; + if (action.type === "move_to_step") return stepsById.get(action.config?.step_id ?? ""); + return undefined; +} + +function configureRules(step: WorkflowStep): ConfigureSessionRule[] { + const action = (step.events?.on_enter ?? []).find( + (candidate) => candidate.type === "configure_session", + ); + const rules = action?.type === "configure_session" ? action.config?.rules : undefined; + if (!Array.isArray(rules)) return []; + return rules.filter((rule): rule is ConfigureSessionRule => { + if (!rule || typeof rule.agent_name !== "string" || rule.agent_name === "") return false; + return isOperation(rule.operation); + }); +} + +function isOperation(value: unknown): value is ConfigureSessionOperation { + return value === "set" || value === "keep" || value === "restore_original"; +} diff --git a/apps/web/src/locales/en/common.json b/apps/web/src/locales/en/common.json index 83190755d7..962ed7a738 100644 --- a/apps/web/src/locales/en/common.json +++ b/apps/web/src/locales/en/common.json @@ -30,5 +30,7 @@ "status": "Status", "system": "System", "workspace": "Workspace", - "workspaces": "Workspaces" + "workspaces": "Workspaces", + "title": "Title", + "subtaskTitle": "Subtask title" } diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index 23040b136e..730ac32b49 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -382,6 +382,37 @@ "voiceMode": "Voice Mode", "vsCodeRemoteSsh": "VS Code Remote SSH", "whenEnabledInstallYourProjectS": "When enabled, install your project's dependencies (e.g. <2>{{command}} via repository setup scripts) to avoid missing type errors.", + "agentGeneratedTaskTitles": "Agent-generated task titles", + "agentGeneratedTaskTitlesDescription": "When enabled, new task and subtask forms hide the manual title field and require a prompt. Kandev shows a provisional title using the prompt's first six words immediately, then asks the agent to replace it with a concise title targeting about three words. If the agent cannot rename the task, the provisional title remains available for you to edit. Existing and edited tasks keep their normal title field.", + "useAgentForNewTaskTitles": "Use the agent for new task titles", + "agentGeneratedTaskTitlesDisabledHint": "Disabled by default; save this setting before opening a creation dialog.", + "sessionConfigCarryForwardWarning": "Earlier workflow settings may carry into this step because the original conversation tab is reused.", + "sessionConfigKeep": "Keep", + "sessionConfigRestore": "Restore", + "sessionConfigSetNew": "Set new", + "sessionConfigFixedProfileWarning": "Remove the fixed profile override before configuring the original session. The two behaviors are mutually exclusive.", + "sessionConfigEmptyRules": "Add a condition to choose which initial agent receives settings.", + "sessionConfigRulesHelp": "Rules apply to the task's first agent session. They never create or switch conversation tabs.", + "addCondition": "Add condition", + "whenAgentIs": "When agent is", + "chooseAgentFamily": "Choose agent family", + "setSettings": "Set settings", + "keepCurrent": "Keep current", + "restoreOriginal": "Restore original", + "removeAgentCondition": "Remove agent condition {{index}}", + "chooseModelAndSessionSettings": "Choose model and session settings", + "settingsForAgent": "Settings for {{agent}}", + "sessionConfigModelOptionsUnavailable": "Model options are unavailable for this agent. Refresh agent capabilities before saving this condition.", + "stepPrompt": "Step Prompt", + "stepPromptHelp": "Custom instructions for the agent on this step. Use {{taskPrompt}} to include the task description.", + "templates": "Templates:", + "stepPromptUsageHint": "Type {{open}} for placeholders ({{taskPrompt}} inserts the task description) or @ to reference a saved prompt by name — its content is attached as hidden context, and editing the saved prompt updates every step that references it. Note: {{taskPrompt}} only expands in the step prompt itself, not inside a referenced saved prompt.", + "removeConditionalSessionConfigBeforeProfile": "Remove the conditional session configuration before choosing a fixed agent profile for this step.", + "overrideAgentProfileHelp": "Override the agent profile for this step. A different profile creates a new session with fresh context when entering this step.", + "overrideOriginalSessionOptions": "Override original session options", + "overrideOriginalSessionOptionsHelp": "Keep the original conversation tab while changing its session settings for this step. For example, start with model 5.6 Sol and switch to 5.6 Luna for implementation work.", + "configureOriginalSessionOptions": "Configure original settings options", + "configureOriginalSessionOptionsDescription": "Change the existing conversation tab only when its initial agent family matches a rule.", "logsPageTitle": "Logs", "logsPageDescription": "Download a bounded diagnostic ZIP containing frontend and backend logs.", "diagnosticReviewBeforeSharing": "Review before sharing", diff --git a/apps/web/src/locales/pseudo/common.json b/apps/web/src/locales/pseudo/common.json index d113e3c690..1846d65b78 100644 --- a/apps/web/src/locales/pseudo/common.json +++ b/apps/web/src/locales/pseudo/common.json @@ -30,5 +30,7 @@ "status": "Śţàţũś", "system": "Śŷśţēḿ", "workspace": "Ŵōŕķśƥàćē", - "workspaces": "Ŵōŕķśƥàćēś" + "workspaces": "Ŵōŕķśƥàćēś", + "title": "Ţĩţĺē", + "subtaskTitle": "Śũƀţàśķ ţĩţĺē" } diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json index 28cfd1f59f..6b877fa593 100644 --- a/apps/web/src/locales/pseudo/settings.json +++ b/apps/web/src/locales/pseudo/settings.json @@ -382,6 +382,37 @@ "voiceMode": "Vōĩćē Ḿōďē", "vsCodeRemoteSsh": "VŚ Ćōďē Ŕēḿōţē ŚŚĤ", "whenEnabledInstallYourProjectS": "Ŵĥēń ēńàƀĺēď, ĩńśţàĺĺ ŷōũŕ ƥŕōĵēćţ'ś ďēƥēńďēńćĩēś (ē.ĝ. <2>{{command}} vĩà ŕēƥōśĩţōŕŷ śēţũƥ śćŕĩƥţś) ţō àvōĩď ḿĩśśĩńĝ ţŷƥē ēŕŕōŕś.", + "agentGeneratedTaskTitles": "Àĝēńţ-ĝēńēŕàţēď ţàśķ ţĩţĺēś", + "agentGeneratedTaskTitlesDescription": "Ŵĥēń ēńàƀĺēď, ńēŵ ţàśķ àńď śũƀţàśķ ƒōŕḿś ĥĩďē ţĥē ḿàńũàĺ ţĩţĺē ƒĩēĺď àńď ŕēqũĩŕē à ƥŕōḿƥţ. Ķàńďēv śĥōŵś à ƥŕōvĩśĩōńàĺ ţĩţĺē ũśĩńĝ ţĥē ƥŕōḿƥţ'ś ƒĩŕśţ śĩx ŵōŕďś ĩḿḿēďĩàţēĺŷ, ţĥēń àśķś ţĥē àĝēńţ ţō ŕēƥĺàćē ĩţ ŵĩţĥ à ćōńćĩśē ţĩţĺē ţàŕĝēţĩńĝ àƀōũţ ţĥŕēē ŵōŕďś. Ĩƒ ţĥē àĝēńţ ćàńńōţ ŕēńàḿē ţĥē ţàśķ, ţĥē ƥŕōvĩśĩōńàĺ ţĩţĺē ŕēḿàĩńś àvàĩĺàƀĺē ƒōŕ ŷōũ ţō ēďĩţ. Ēxĩśţĩńĝ àńď ēďĩţēď ţàśķś ķēēƥ ţĥēĩŕ ńōŕḿàĺ ţĩţĺē ƒĩēĺď.", + "useAgentForNewTaskTitles": "Ũśē ţĥē àĝēńţ ƒōŕ ńēŵ ţàśķ ţĩţĺēś", + "agentGeneratedTaskTitlesDisabledHint": "Ďĩśàƀĺēď ƀŷ ďēƒàũĺţ; śàvē ţĥĩś śēţţĩńĝ ƀēƒōŕē ōƥēńĩńĝ à ćŕēàţĩōń ďĩàĺōĝ.", + "sessionConfigCarryForwardWarning": "Ēàŕĺĩēŕ ŵōŕķƒĺōŵ śēţţĩńĝś ḿàŷ ćàŕŕŷ ĩńţō ţĥĩś śţēƥ ƀēćàũśē ţĥē ōŕĩĝĩńàĺ ćōńvēŕśàţĩōń ţàƀ ĩś ŕēũśēď.", + "sessionConfigKeep": "Ķēēƥ", + "sessionConfigRestore": "Ŕēśţōŕē", + "sessionConfigSetNew": "Śēţ ńēŵ", + "sessionConfigFixedProfileWarning": "Ŕēḿōvē ţĥē ƒĩxēď ƥŕōƒĩĺē ōvēŕŕĩďē ƀēƒōŕē ćōńƒĩĝũŕĩńĝ ţĥē ōŕĩĝĩńàĺ śēśśĩōń. Ţĥē ţŵō ƀēĥàvĩōŕś àŕē ḿũţũàĺĺŷ ēxćĺũśĩvē.", + "sessionConfigEmptyRules": "Àďď à ćōńďĩţĩōń ţō ćĥōōśē ŵĥĩćĥ ĩńĩţĩàĺ àĝēńţ ŕēćēĩvēś śēţţĩńĝś.", + "sessionConfigRulesHelp": "Ŕũĺēś àƥƥĺŷ ţō ţĥē ţàśķ'ś ƒĩŕśţ àĝēńţ śēśśĩōń. Ţĥēŷ ńēvēŕ ćŕēàţē ōŕ śŵĩţćĥ ćōńvēŕśàţĩōń ţàƀś.", + "addCondition": "Àďď ćōńďĩţĩōń", + "whenAgentIs": "Ŵĥēń àĝēńţ ĩś", + "chooseAgentFamily": "Ćĥōōśē àĝēńţ ƒàḿĩĺŷ", + "setSettings": "Śēţ śēţţĩńĝś", + "keepCurrent": "Ķēēƥ ćũŕŕēńţ", + "restoreOriginal": "Ŕēśţōŕē ōŕĩĝĩńàĺ", + "removeAgentCondition": "Ŕēḿōvē àĝēńţ ćōńďĩţĩōń {{index}}", + "chooseModelAndSessionSettings": "Ćĥōōśē ḿōďēĺ àńď śēśśĩōń śēţţĩńĝś", + "settingsForAgent": "Śēţţĩńĝś ƒōŕ {{agent}}", + "sessionConfigModelOptionsUnavailable": "Ḿōďēĺ ōƥţĩōńś àŕē ũńàvàĩĺàƀĺē ƒōŕ ţĥĩś àĝēńţ. Ŕēƒŕēśĥ àĝēńţ ćàƥàƀĩĺĩţĩēś ƀēƒōŕē śàvĩńĝ ţĥĩś ćōńďĩţĩōń.", + "stepPrompt": "Śţēƥ Ƥŕōḿƥţ", + "stepPromptHelp": "Ćũśţōḿ ĩńśţŕũćţĩōńś ƒōŕ ţĥē àĝēńţ ōń ţĥĩś śţēƥ. Ũśē {{taskPrompt}} ţō ĩńćĺũďē ţĥē ţàśķ ďēśćŕĩƥţĩōń.", + "templates": "Ţēḿƥĺàţēś:", + "stepPromptUsageHint": "Ţŷƥē {{open}} ƒōŕ ƥĺàćēĥōĺďēŕś ({{taskPrompt}} ĩńśēŕţś ţĥē ţàśķ ďēśćŕĩƥţĩōń) ōŕ @ ţō ŕēƒēŕēńćē à śàvēď ƥŕōḿƥţ ƀŷ ńàḿē — ĩţś ćōńţēńţ ĩś àţţàćĥēď àś ĥĩďďēń ćōńţēxţ, àńď ēďĩţĩńĝ ţĥē śàvēď ƥŕōḿƥţ ũƥďàţēś ēvēŕŷ śţēƥ ţĥàţ ŕēƒēŕēńćēś ĩţ. Ńōţē: {{taskPrompt}} ōńĺŷ ēxƥàńďś ĩń ţĥē śţēƥ ƥŕōḿƥţ ĩţśēĺƒ, ńōţ ĩńśĩďē à ŕēƒēŕēńćēď śàvēď ƥŕōḿƥţ.", + "removeConditionalSessionConfigBeforeProfile": "Ŕēḿōvē ţĥē ćōńďĩţĩōńàĺ śēśśĩōń ćōńƒĩĝũŕàţĩōń ƀēƒōŕē ćĥōōśĩńĝ à ƒĩxēď àĝēńţ ƥŕōƒĩĺē ƒōŕ ţĥĩś śţēƥ.", + "overrideAgentProfileHelp": "Ōvēŕŕĩďē ţĥē àĝēńţ ƥŕōƒĩĺē ƒōŕ ţĥĩś śţēƥ. À ďĩƒƒēŕēńţ ƥŕōƒĩĺē ćŕēàţēś à ńēŵ śēśśĩōń ŵĩţĥ ƒŕēśĥ ćōńţēxţ ŵĥēń ēńţēŕĩńĝ ţĥĩś śţēƥ.", + "overrideOriginalSessionOptions": "Ōvēŕŕĩďē ōŕĩĝĩńàĺ śēśśĩōń ōƥţĩōńś", + "overrideOriginalSessionOptionsHelp": "Ķēēƥ ţĥē ōŕĩĝĩńàĺ ćōńvēŕśàţĩōń ţàƀ ŵĥĩĺē ćĥàńĝĩńĝ ĩţś śēśśĩōń śēţţĩńĝś ƒōŕ ţĥĩś śţēƥ. Ƒōŕ ēxàḿƥĺē, śţàŕţ ŵĩţĥ ḿōďēĺ 5.6 Śōĺ àńď śŵĩţćĥ ţō 5.6 Ĺũńà ƒōŕ ĩḿƥĺēḿēńţàţĩōń ŵōŕķ.", + "configureOriginalSessionOptions": "Ćōńƒĩĝũŕē ōŕĩĝĩńàĺ śēţţĩńĝś ōƥţĩōńś", + "configureOriginalSessionOptionsDescription": "Ćĥàńĝē ţĥē ēxĩśţĩńĝ ćōńvēŕśàţĩōń ţàƀ ōńĺŷ ŵĥēń ĩţś ĩńĩţĩàĺ àĝēńţ ƒàḿĩĺŷ ḿàţćĥēś à ŕũĺē.", "logsPageTitle": "Ĺōĝś", "logsPageDescription": "Ďōŵńĺōàď à ƀōũńďēď ďĩàĝńōśţĩć ŹĨƤ ćōńţàĩńĩńĝ ƒŕōńţēńď àńď ƀàćķēńď ĺōĝś.", "diagnosticReviewBeforeSharing": "Ŕēvĩēŵ ƀēƒōŕē śĥàŕĩńĝ", diff --git a/docs/decisions/2026-08-01-workflow-session-original-configuration.md b/docs/decisions/2026-08-01-workflow-session-original-configuration.md new file mode 100644 index 0000000000..d1eb417d12 --- /dev/null +++ b/docs/decisions/2026-08-01-workflow-session-original-configuration.md @@ -0,0 +1,34 @@ +# ADR-2026-08-01-workflow-session-original-configuration: Keep Workflow Restoration Separate From Provider Defaults + +**Status:** accepted +**Date:** 2026-08-01 +**Area:** backend, frontend, workflow + +## Context + +Workflow steps need to change an existing ACP session's model and select options and later restore the values with which that task session originally started. The existing ACP provider-default baseline is intentionally comparison-only: it is captured before profile and runtime overrides, so it cannot restore a profile-selected model while also restoring provider defaults for unspecified options. Profile rows and the mutable session snapshot are also unsuitable because either may change after the session starts. + +## Decision + +Each original task session records a separate, immutable original effective configuration after provider defaults and agent-profile selections settle but before workflow session-setting rules apply. The snapshot contains the effective model and every advertised selectable ACP option's raw value. Workflow `restore_original` rules are the only behavior that reads this snapshot to mutate provider state. + +The existing provider-default baseline remains comparison-only. Mutable provider state remains in `runtime_config`, and explicit user/workflow selections remain in `runtime_config_overrides`. A successful restore writes the captured original values back as explicit runtime overrides so resume behavior does not depend on later provider-default or profile changes. + +Workflow rules match the original session's stable agent name and never activate a different session. Sessions that predate the immutable snapshot are not reconstructed from mutable data; restoration fails visibly and leaves them unchanged when no trustworthy snapshot exists. + +## Consequences + +- Restoration represents what the task actually started with, including profile-selected values and provider defaults the profile left untouched. +- Compact changed-value summaries keep their existing provider-default comparison semantics. +- Session metadata gains another intentionally immutable configuration snapshot with a distinct lifecycle from display baselines, live state, and overrides. +- New-session initialization must preserve ordering so the original effective snapshot is captured before start-step workflow overrides. +- Legacy sessions may carry settings forward but cannot always restore automatically. +- Provider or profile changes after task creation do not redefine that task's original settings. + +## Alternatives Considered + +- Reuse the provider-default baseline: rejected because it omits the effective profile layer and could restore the wrong model. +- Re-read the current agent profile during restore: rejected because profile edits would retroactively redefine a running task's original settings. +- Use the mutable agent-profile or runtime snapshot: rejected because model changes and provider events update those values during the session. +- Clear runtime overrides and let launch defaults re-resolve: rejected because it would not actively set default-valued options and later provider/profile changes could alter the result. +- Reconstruct legacy originals heuristically: rejected because a plausible but incorrect model or effort setting is worse than a visible no-op. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 2a9eb76be7..49c96aabdf 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -96,6 +96,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-07-31-isolate-manual-pr-review-content | [Isolate Manual PR Review Content](2026-07-31-isolate-manual-pr-review-content.md) | accepted | infra, workflow, security | 2026-07-31 | | 2026-07-31-agent-generated-task-titles | [Bind Agent Title Generation to Pending Tasks](2026-07-31-agent-generated-task-titles.md) | accepted | backend, frontend, protocol | 2026-07-31 | | 2026-07-31-system-service-user-continuity | [Preserve System Service Identity Across Reinstallation](2026-07-31-system-service-user-continuity.md) | accepted | backend, cli, security, operations | 2026-07-31 | +| 2026-08-01-workflow-session-original-configuration | [Keep Workflow Restoration Separate From Provider Defaults](2026-08-01-workflow-session-original-configuration.md) | accepted | backend, frontend, workflow | 2026-08-01 | | 2026-08-01-repository-task-executor-defaults | [Resolve Task Executor Policy Before Last-Used Profile](2026-08-01-repository-task-executor-defaults.md) | accepted | frontend | 2026-08-01 | | 2026-08-01-validate-mcp-tool-arguments | [Validate MCP Tool Arguments at the Shared Server Boundary](2026-08-01-validate-mcp-tool-arguments.md) | accepted | backend, protocol | 2026-08-01 | | 2026-08-01-separate-task-summary-session-stream-traffic | [Separate Task Summary and Session Stream Traffic](2026-08-01-separate-task-summary-session-stream-traffic.md) | accepted | backend, frontend, protocol | 2026-08-01 | diff --git a/docs/plans/workflow-session-settings/plan.md b/docs/plans/workflow-session-settings/plan.md new file mode 100644 index 0000000000..3aa5bb129b --- /dev/null +++ b/docs/plans/workflow-session-settings/plan.md @@ -0,0 +1,197 @@ +--- +spec: docs/specs/workflow-session-settings/spec.md +decision: docs/decisions/2026-08-01-workflow-session-original-configuration.md +created: 2026-08-01 +status: complete +--- + +# Implementation Plan: Conditional Workflow Session Settings + +## Overview + +Add a validated `configure_session` workflow action that selects one rule by the original session's immutable agent family, mutates that same session without changing tabs, and preserves successful values as explicit runtime overrides. New-session initialization gains a separate profile-settled checkpoint so Kandev can capture the immutable original effective configuration before a start-step rule is applied. The workflow editor reuses `ModelConfigSelector`, explains the mutually exclusive agent behaviors, and performs conservative family-specific carry-forward analysis over configured workflow transitions. + +No workflow table migration or new HTTP/WebSocket endpoint is required. Rules continue to live in `workflow_steps.events`; original-session identity, original effective configuration, and any launch-time application bookkeeping live in task-session metadata. + +## Architecture + +### Workflow contract and validation + +Add `configure_session` to the backend and frontend workflow action unions. Decode its config into typed rule helpers at the validation boundary rather than spreading `map[string]interface{}` assertions across the orchestrator. One reusable validator must cover step create, step update, and workflow import, including: + +- one action per step and one rule per `agent_name`; +- `set`, `keep`, and `restore_original` payload rules; +- non-empty set values; and +- mutual exclusion with `workflow_step.agent_profile_id`. + +Export remains a lossless JSON round trip. Family names, models, and ACP option IDs are not remapped through workspace profile matching. + +### Original session and immutable configuration + +Mark the first session created for a task with immutable original-session provenance in `TaskSession.Metadata`; do not use mutable `IsPrimary` as identity. Preserve the existing workflow-switch provenance marker for later sessions and add a conservative legacy resolver that accepts an earliest non-workflow-switch session only when the choice is unambiguous. + +Add a metadata helper for the original effective configuration. Its write-once compare-and-set behavior must remain separate from: + +- `acp_config_baseline` (provider-default comparison baseline); +- `runtime_config` (latest provider state); and +- `runtime_config_overrides` (explicit durable selections). + +Refactor ACP initialization to carry profile settings and explicit runtime overrides as separate layers. The required order is: + +1. initialize/load the ACP session and observe provider defaults; +2. apply profile model and profile select options; +3. read the settled model and every advertised select option and synchronously record the original snapshot for the original task session; +4. apply durable runtime overrides and any matching start-step workflow operation, field by field; and +5. dispatch the first prompt. + +The runtime-facing API should use typed layer/result structures and narrow injected persistence callbacks. It must not make the lifecycle package depend directly on workflow models or task-service implementations. + +### Runtime rule application + +Resolve the original family from the immutable original session/profile snapshot, never from the currently selected step profile. Add a narrow session-runtime configuration writer to the orchestrator, implemented by the task service and wired in `backendapp`, so workflow changes use the same durable override semantics as chat configuration changes. Extend the existing runtime/agent-manager seam for dynamic ACP config options instead of importing lifecycle internals into workflow code. + +For a running original session, apply model/options individually and persist each successful value. For a not-yet-running original session, carry the typed operation through launch initialization so it runs after original capture and before the prompt. `restore_original` expands to the captured model and all still-advertised select options. `keep` and no-match rules produce no provider or metadata mutation. + +Collect field-level outcomes. One sanitized session warning reports rejected/unsupported fields and persistence failures while preserving successful fields and allowing auto-start to continue. A non-original active session produces a visible warning and no tab activation or mutation. Missing legacy restore data also warns and no-ops. + +### Editor and carry-forward analysis + +Replace the step header's single profile selector with an explicit agent-behavior choice: + +- keep current behavior; +- use fixed agent profile; or +- configure original session by agent family. + +The fixed-profile and conditional modes are mutually exclusive in local editor state as well as backend validation. Conditional rules use available-agent capability data grouped by stable `agent_name`. `set` embeds the shared `ModelConfigSelector`; `keep` and `restore_original` show explanatory copy and no value controls. Existing persisted values remain readable/removable if discovery is unavailable, but a new unverifiable `set` rule cannot be saved. + +Implement carry-forward analysis as a pure frontend utility. Reuse or extract the configured transition-graph resolution from `replay-cycle-analysis.ts` so next/previous/explicit edges have one interpretation. For each family, run a fixed-point analysis from the workflow start step with an `original | changed | maybe_changed` lattice: + +- `set` outputs changed; +- `restore_original` outputs original; +- `keep` preserves incoming state and records explicit intent; +- no rule preserves incoming state; and +- joins and cycles conservatively produce `maybe_changed` when paths differ. + +Warn only when changed settings can enter a step and that step has no explicit rule for the family. Manual card moves remain outside the graph. + +The step header keeps the fixed profile selector and adds an `Override original +session options` checkbox beside it. The checkbox's help copy includes the +Sol-to-Luna implementation example and is mutually exclusive with a fixed +profile. The conditional editor renders below WIP controls only while the +checkbox is enabled. Family choices are derived solely from configured agent +profiles; capability entries without a profile remain unavailable as new rule +targets. + +### Mobile contract + +Desktop and mobile use the same expanded workflow-step panel and the same rule data/helpers. Desktop may place family, operation, and selector controls in a compact row; below the existing phone breakpoint each rule becomes a one-column card. The page/document remains the single vertical scroll owner, with no nested full-height surface and no horizontal overflow. Add/remove/operation controls must retain at least 44px touch targets, and the shared selector's existing touch disclosure remains responsible for model/option selection. + +## Tests + +- Go model/controller/import tests cover every valid operation, duplicate families/actions, empty sets, profile/action conflicts, and export/import round trips. +- Go task/runtime tests cover immutable original-session provenance, write-once snapshot semantics, profile/default capture, restart/resume preservation, launch ordering, and legacy ambiguity. +- Go orchestrator tests cover family match/no-match, original-session enforcement, partial success, persistence failure, unsupported restore options, missing snapshots, start-step ordering, and auto-start continuation. +- TypeScript unit/component tests cover editor serialization, mutual exclusion, discovery failure, shared selector reuse, fixed-point path analysis, joins, explicit and relative transitions, and cycles. +- Playwright covers desktop creation/edit/reload/import behavior, an actual same-tab runtime switch before auto-start, visible partial-failure warnings, read-only synced display, and phone rule editing without overflow. + +## E2E and documentation + +Likely files: + +- `apps/web/e2e/tests/workflow/workflow-agent-profile.spec.ts` +- `apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts` +- a focused workflow session-settings runtime spec under `apps/web/e2e/tests/workflow/` +- `apps/web/e2e/pages/workflow-settings-page.ts` +- `docs/public/tasks-and-workflows.md` + +Update public workflow documentation with the three agent behaviors, family matching, carry-forward/restore semantics, failure behavior, and the fact that conditional settings reuse the original conversation tab. + +## Waves + +Wave 1: + +- [x] [task-01-workflow-contract-and-validation](task-01-workflow-contract-and-validation.md) + +Wave 2 (can proceed independently after Task 01): + +- [x] [task-02-original-session-initialization](task-02-original-session-initialization.md) +- [x] [task-04-editor-and-carry-analysis](task-04-editor-and-carry-analysis.md) + +Wave 3: + +- [x] [task-03-runtime-rule-application](task-03-runtime-rule-application.md) + +Wave 4: + +- [x] [task-05-e2e-docs-and-verification](task-05-e2e-docs-and-verification.md) + +Wave 5: + +- [x] [task-06-workflow-session-options-toggle](task-06-workflow-session-options-toggle.md) + +## Verification + +Targeted backend: + +```bash +cd apps/backend && go test ./internal/workflow/... ./internal/task/models ./internal/task/service ./internal/agent/runtime/... ./internal/orchestrator/... ./internal/backendapp/... +``` + +Targeted frontend: + +```bash +cd apps && pnpm --filter @kandev/web test -- --run lib/workflows components/settings +cd apps/web && pnpm run typecheck +``` + +Desktop and mobile E2E after rebuilding the web assets required by the fixture: + +```bash +cd apps/web && pnpm e2e:run --project chromium tests/workflow/workflow-agent-profile.spec.ts tests/workflow/workflow-session-settings.spec.ts +cd apps/web && pnpm e2e:run --project mobile-chrome tests/workflow/mobile-workflow-settings.spec.ts +``` + +Documentation and final repository checks: + +```bash +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +make fmt +make typecheck +make test +make lint +``` + +Recorded results for the implementation: + +- `cd apps/backend && go test ./internal/workflow/models ./internal/workflow/service ./internal/agent/runtime/lifecycle ./internal/orchestrator ./internal/backendapp` — 2751 tests passed in 5 packages. +- `cd apps && pnpm --filter @kandev/web exec vitest run lib/workflows components/settings` — 62 files and 321 tests passed. +- `cd apps/web && pnpm run typecheck` — passed. +- `cd apps && pnpm --filter @kandev/web run lint` — passed with zero warnings. +- `cd apps/web && pnpm e2e:run --project chromium tests/workflow/workflow-settings.spec.ts` — 14 tests passed with production-build setup. +- `cd apps/web && pnpm e2e:run --project mobile-chrome tests/workflow/mobile-workflow-settings.spec.ts` — 3 tests passed with production-build setup. +- `cd apps/web && pnpm run i18n:check` — passed with zero orphan keys and pseudo-locale in sync. +- `cd apps/web && pnpm run i18n:ratchet` — passed; all added and modified files clean. +- `node --test scripts/validate-public-docs.test.mjs` — 58 tests passed. +- `node scripts/validate-public-docs.mjs` — 41 published pages validated. +- Public workflow docs updated in `docs/public/tasks-and-workflows.md` for the new toggle and profile-backed family choices. +- `git diff --check` and backend `gofmt -l` checks — clean. + +The repository-wide backend `make test` target was started but stopped after it exceeded the useful local verification window; the focused packages above cover every changed backend subsystem. + +Task 06 verification for the header toggle and profile-backed family choices is complete. + +PR conflict fixup: merged `origin/main` at `332353f647cf8ae157db893f529dfc4cb3516ba2` into +`feature/workflow-step-change-rkm` with merge commit `72bd08c0d116d513e1c63ba274ec94c17c49bbb1`. +The only conflict was `apps/backend/internal/task/models/models.go`; both original-session +provenance and context-window metadata keys were retained. At the new PR head, +`checks_snapshot_complete=true`, `failed_checks=[]`, `pending_checks` contains 15 queued or +pending checks, and unresolved review threads are zero; GitHub reports the PR as mergeable. + +## Risks + +- ACP initialization currently receives only the final effective configuration. Splitting profile and runtime layers must preserve existing chat-selected override, reset, resume, and compact-summary behavior. +- Provider config updates may arrive asynchronously. Original capture must wait for the profile-settled state and still complete before the first prompt; provider-default baseline events must not be repurposed. +- Start-step partial failures need field results without turning best-effort configuration into a launch failure. Keep configuration outcome reporting separate from fatal session initialization errors. +- Workflow cycles can make a family both original and changed at the same step. The editor must converge with a bounded lattice/fixed-point algorithm and avoid warning churn based on step ordering. +- Multiple profiles may share one agent family. Rules intentionally target the family; capability grouping must retain stable IDs and make unavailable persisted values visible instead of silently translating them. diff --git a/docs/plans/workflow-session-settings/task-01-workflow-contract-and-validation.md b/docs/plans/workflow-session-settings/task-01-workflow-contract-and-validation.md new file mode 100644 index 0000000000..d5d6bb875f --- /dev/null +++ b/docs/plans/workflow-session-settings/task-01-workflow-contract-and-validation.md @@ -0,0 +1,56 @@ +--- +id: "01-workflow-contract-and-validation" +title: "Add and validate the workflow session-settings contract" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/workflow-session-settings/spec.md" +--- + +# Task 01: Add and Validate the Workflow Session-settings Contract + +## Acceptance + +- Backend and frontend workflow action types represent `configure_session` rules without provider-specific fields. +- One shared backend validator covers create, update, and import and rejects duplicate actions/families, invalid operations or payloads, empty `set` rules, and coexistence with `agent_profile_id`. +- Valid rules survive snapshot, export, and import byte-for-byte at the semantic JSON level without agent-profile matching. +- Existing workflows without the action retain their current behavior and serialized shape. + +## TDD sequence + +1. Add failing model/import/controller tests for the accepted and rejected shapes. +2. Add typed parsing/validation helpers and call them from every persistence boundary. +3. Add the frontend discriminated union and serialization tests. +4. Refactor duplicate validation logic only after the focused tests pass. + +## Verification + +```bash +cd apps/backend && go test ./internal/workflow/models ./internal/workflow/controller ./internal/workflow/service +cd apps && pnpm --filter @kandev/web test -- --run lib/types lib/api/domains/workflow-api +``` + +## Files likely touched + +- `apps/backend/internal/workflow/models/models.go` +- `apps/backend/internal/workflow/models/export.go` +- `apps/backend/internal/workflow/models/export_test.go` +- `apps/backend/internal/workflow/controller/controller.go` +- Related workflow controller/service tests +- `apps/web/lib/types/workflow-actions.ts` +- `apps/web/lib/types/http.ts` +- Related frontend workflow serialization tests + +## Dependencies + +None. + +## Inputs + +- Spec sections `Data model`, `API surface`, and `Persistence guarantees`. +- Existing `OnEnterAction`, workflow-step create/update, and import validation paths. + +## Output contract + +Report the final typed rule shape, all persistence boundaries using the validator, import/export behavior, files changed, tests run, and any compatibility concern. diff --git a/docs/plans/workflow-session-settings/task-02-original-session-initialization.md b/docs/plans/workflow-session-settings/task-02-original-session-initialization.md new file mode 100644 index 0000000000..c15b565424 --- /dev/null +++ b/docs/plans/workflow-session-settings/task-02-original-session-initialization.md @@ -0,0 +1,60 @@ +--- +id: "02-original-session-initialization" +title: "Capture the original session configuration before workflow overrides" +status: done +wave: 2 +depends_on: ["01-workflow-contract-and-validation"] +plan: "plan.md" +spec: "../../specs/workflow-session-settings/spec.md" +--- + +# Task 02: Capture the Original Session Configuration Before Workflow Overrides + +## Acceptance + +- The first task session receives immutable original-session provenance; later primary/session switches cannot change that identity. +- A conservative legacy resolver identifies an original only when the earliest non-workflow-switch session is unambiguous. +- The original effective model and every advertised select option are persisted once after profile settings settle and before any workflow/runtime override layer applies. +- The new snapshot remains distinct from `acp_config_baseline`, `runtime_config`, `runtime_config_overrides`, and mutable `AgentProfileSnapshot` fields. +- ACP initialization accepts separate profile and runtime/workflow layers, preserves existing override/resume behavior, records the original snapshot synchronously, and applies later layers before the first prompt. +- Process recreation, reset, and backend restart never replace the snapshot. + +## TDD sequence + +1. Add failing metadata helper, executor provenance, legacy-resolution, and write-once tests. +2. Add failing lifecycle tests that assert provider default -> profile -> original capture -> override -> prompt ordering. +3. Split the initialization inputs into typed layers and add a narrow recorder callback/interface. +4. Re-run existing model-selection, config-option, reset, and resume tests to catch ordering regressions. + +## Verification + +```bash +cd apps/backend && go test ./internal/task/models ./internal/task/service ./internal/orchestrator/executor ./internal/agent/runtime/... +``` + +## Files likely touched + +- `apps/backend/internal/task/models/models.go` +- `apps/backend/internal/task/models/models_test.go` +- `apps/backend/internal/task/service/service_turns.go` +- `apps/backend/internal/orchestrator/executor/executor_execute.go` +- `apps/backend/internal/agent/runtime/lifecycle/manager_profile.go` +- `apps/backend/internal/agent/runtime/lifecycle/session.go` +- `apps/backend/internal/agent/runtime/lifecycle/event_types.go` +- `apps/backend/internal/orchestrator/event_handlers_streaming.go` +- `apps/backend/internal/backendapp/orchestrator.go` +- Related lifecycle, executor, service, and streaming tests + +## Dependencies + +Task 01. + +## Inputs + +- ADR `2026-08-01-workflow-session-original-configuration`. +- Existing provider baseline and runtime override metadata helpers. +- Existing `PrepareSession`, `effectiveSessionRuntimeConfig`, `InitializeAndPrompt`, and `publishSettledConfigOptions` flow. + +## Output contract + +Implemented with `origin=task_initial` provenance and the write-once `original_effective_config` snapshot. The first task session is identified by creation order rather than mutable primary ownership; legacy sessions use the earliest unambiguous non-workflow-switch row. ACP initialization now applies profile settings, emits the profile-settled original snapshot, then applies runtime overrides before the first prompt. Snapshot persistence is compare-and-set and survives later provider events and restarts. Focused lifecycle, executor, streaming, and orchestrator tests passed. diff --git a/docs/plans/workflow-session-settings/task-03-runtime-rule-application.md b/docs/plans/workflow-session-settings/task-03-runtime-rule-application.md new file mode 100644 index 0000000000..b2a5e68d49 --- /dev/null +++ b/docs/plans/workflow-session-settings/task-03-runtime-rule-application.md @@ -0,0 +1,64 @@ +--- +id: "03-runtime-rule-application" +title: "Apply matching workflow rules to the original session" +status: done +wave: 3 +depends_on: ["01-workflow-contract-and-validation", "02-original-session-initialization"] +plan: "plan.md" +spec: "../../specs/workflow-session-settings/spec.md" +--- + +# Task 03: Apply Matching Workflow Rules to the Original Session + +## Acceptance + +- Runtime matching uses the original session's immutable `agent_name` and applies at most one rule. +- No match and `keep` are silent no-ops; neither writes a runtime override. +- `set` changes only named fields, while `restore_original` attempts the captured model and every still-advertised select option. +- Rules never create, switch, reactivate, or rename a session. A non-original active session is left untouched and receives a visible workflow warning. +- Running-session fields apply independently through ACP and successful values persist through the task service as explicit overrides. +- Start-step rules run through the layered initialization path after original capture and before the first prompt. +- Transition rules run after any context reset and before plan/mode setup and auto-start prompt dispatch. +- Unsupported/rejected fields and persistence failures produce one sanitized visible warning with retained values; successful fields remain applied and auto-start continues. +- Missing legacy snapshots and removed restore options warn and no-op/partially restore exactly as specified. + +## TDD sequence + +1. Add failing orchestrator tests for match, no-match, keep, set, restore, wrong active session, and start-step ordering. +2. Add the narrow runtime setter/result and task-service override-writer seams, then wire them in `backendapp`. +3. Implement best-effort field application and sanitized warning aggregation. +4. Add failure, persistence, restart, reset-before-config, and auto-start-continuation tests. + +## Verification + +```bash +cd apps/backend && go test ./internal/orchestrator/... ./internal/agent/runtime/... ./internal/task/service ./internal/backendapp/... +``` + +## Files likely touched + +- `apps/backend/internal/orchestrator/event_handlers_workflow.go` +- `apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go` +- `apps/backend/internal/orchestrator/service.go` +- `apps/backend/internal/orchestrator/executor/executor.go` +- `apps/backend/internal/orchestrator/executor/executor_execute.go` +- `apps/backend/internal/agent/runtime/runtime.go` +- `apps/backend/internal/agent/runtime/facade.go` +- `apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go` +- `apps/backend/internal/task/service/service_turns.go` +- `apps/backend/internal/backendapp/adapters.go` +- `apps/backend/internal/backendapp/orchestrator.go` +- Related mocks and integration tests + +## Dependencies + +Tasks 01 and 02. + +## Inputs + +- Spec sections `State machine`, `Failure modes`, and `Scenarios`. +- Existing `processOnEnter`, `StartCreatedSession`, ACP model/config setters, runtime override persistence, routing-error sanitization, and session-message patterns. + +## Output contract + +Implemented family matching against the immutable original session, launch-time durable layering, live ACP model/option updates, best-effort field persistence, and sanitized warning messages. `keep` and no-match are no-ops; restore uses the immutable snapshot; non-original and passthrough sessions are never switched. Start-step and on-enter ordering are covered by orchestrator tests, including persistence and restore behavior. No provider-specific branches were added; providers without the optional ACP option setter receive a visible warning for those fields. diff --git a/docs/plans/workflow-session-settings/task-04-editor-and-carry-analysis.md b/docs/plans/workflow-session-settings/task-04-editor-and-carry-analysis.md new file mode 100644 index 0000000000..a46ab15701 --- /dev/null +++ b/docs/plans/workflow-session-settings/task-04-editor-and-carry-analysis.md @@ -0,0 +1,61 @@ +--- +id: "04-editor-and-carry-analysis" +title: "Build the conditional rule editor and carry-forward diagnostics" +status: done +wave: 2 +depends_on: ["01-workflow-contract-and-validation"] +plan: "plan.md" +spec: "../../specs/workflow-session-settings/spec.md" +--- + +# Task 04: Build the Conditional Rule Editor and Carry-forward Diagnostics + +## Acceptance + +- The step editor presents keep-current, fixed-profile, and conditional-original-session behaviors with visible explanatory copy and mutually exclusive state. +- Authors can add at most one rule per discovered agent family and select `set`, `keep`, or `restore_original`. +- `set` reuses `ModelConfigSelector` with that family's advertised models and all ACP select options; no option names or values are hard-coded. +- Existing unavailable rules remain readable and removable, while capability discovery failure prevents saving a new unverifiable `set` rule. +- A pure fixed-point utility analyzes configured next/previous/explicit transitions, joins, and cycles per family and warns when changed values may carry into a step with no explicit decision. +- Warning actions create `keep`, `restore_original`, or `set` intent and resolve the diagnostic for that family. +- Read-only synced workflows display rules and warnings without editable controls. +- On phones, rule cards use one-column composition, one document scroll owner, 44px touch targets, and no horizontal overflow; desktop keeps the denser row treatment. + +## TDD sequence + +1. Add failing pure utility tests for straight paths, joins, relative/explicit edges, cycles, restore, and keep. +2. Add failing editor component tests for mode exclusivity, rule uniqueness, serialization, discovery failure, and read-only state. +3. Implement the graph utility by extracting/reusing existing transition resolution, then build small rule editor components around the shared selector. +4. Add responsive component assertions and keep business logic shared across desktop/mobile composition. + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web test -- --run lib/workflows components/settings +cd apps/web && pnpm run typecheck +``` + +## Files likely touched + +- `apps/web/components/settings/workflow-pipeline-editor-panels.tsx` +- New focused components under `apps/web/components/settings/` +- `apps/web/components/model-config-selector.tsx` only if a small reusable API is missing +- `apps/web/hooks/domains/settings/use-available-agents.ts` +- `apps/web/lib/types/http-agents.ts` +- `apps/web/lib/workflows/replay-cycle-analysis.ts` +- New `apps/web/lib/workflows/session-config-carry-analysis.ts` +- Related utility and component tests + +## Dependencies + +Task 01. This task can proceed in parallel with Task 02. + +## Inputs + +- Spec sections `What`, `State machine`, and mobile/read-only scenarios. +- Existing `StepAgentProfileSelect`, `ModelConfigSelector`, `useAvailableAgents`, workflow cycle diagnostics, and responsive breakpoint/touch patterns. +- Mobile reference: current workflow settings phone layout and shared model-selector touch disclosure. + +## Output contract + +Implemented the mutually exclusive conditional editor, stable agent-family choices, shared `ModelConfigSelector` reuse, read-only rendering, and 44px mobile controls. Carry analysis now follows configured on-turn start/complete transition edges with an original/changed fixed-point traversal that handles joins, cycles, restore, and explicit keep decisions; manual card moves remain outside the graph. Component and utility tests passed, as did the full affected frontend test slice and typecheck. Fresh desktop and mobile screenshots show the editor and shared picker without horizontal overflow. diff --git a/docs/plans/workflow-session-settings/task-05-e2e-docs-and-verification.md b/docs/plans/workflow-session-settings/task-05-e2e-docs-and-verification.md new file mode 100644 index 0000000000..f2970cddd8 --- /dev/null +++ b/docs/plans/workflow-session-settings/task-05-e2e-docs-and-verification.md @@ -0,0 +1,56 @@ +--- +id: "05-e2e-docs-and-verification" +title: "Verify workflow session settings and document the feature" +status: done +wave: 4 +depends_on: ["01-workflow-contract-and-validation", "02-original-session-initialization", "03-runtime-rule-application", "04-editor-and-carry-analysis"] +plan: "plan.md" +spec: "../../specs/workflow-session-settings/spec.md" +--- + +# Task 05: Verify Workflow Session Settings and Document the Feature + +## Acceptance + +- Desktop E2E creates Codex and Claude family rules, reloads them, exercises carry-forward warning choices, and proves export/import preservation. +- Runtime E2E proves a matching rule changes model/options on the original conversation tab before auto-start, a non-match is silent, and a partial provider failure warns without blocking the prompt. +- Read-only synced workflow coverage proves rules and diagnostics are visible but immutable. +- Mobile E2E edits multiple rule cards using touch controls with no document-level horizontal overflow or competing scroll owner. +- Public workflow documentation explains the three agent behaviors, family matching, carry-forward/restore, original-tab reuse, persistence, and partial-failure behavior. +- Targeted, documentation, typecheck, test, lint, and formatting commands are recorded with accurate outcomes. + +## Verification + +```bash +cd apps/web && pnpm e2e:run --project chromium tests/workflow/workflow-agent-profile.spec.ts tests/workflow/workflow-session-settings.spec.ts +cd apps/web && pnpm e2e:run --project mobile-chrome tests/workflow/mobile-workflow-settings.spec.ts +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +make fmt +make typecheck +make test +make lint +``` + +## Files likely touched + +- `apps/web/e2e/pages/workflow-settings-page.ts` +- `apps/web/e2e/tests/workflow/workflow-agent-profile.spec.ts` +- New `apps/web/e2e/tests/workflow/workflow-session-settings.spec.ts` +- `apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts` +- Backend/mock-agent E2E fixtures needed for field success/failure +- `docs/public/tasks-and-workflows.md` +- Plan/task status and verification-result updates + +## Dependencies + +Tasks 01 through 04. + +## Inputs + +- Every scenario and failure mode in the approved spec. +- Existing workflow settings page object, workflow agent-profile E2E, mobile workflow settings E2E, and container/mock-agent capability fixtures. + +## Output contract + +Desktop and mobile editor E2E scenarios passed, including persistence through the workflow API, shared model/option selection, touch-sized controls, save behavior, and horizontal-overflow checks. Runtime matching, restore, launch ordering, persistence, non-match, and warning behavior are covered by the focused Go orchestrator/lifecycle tests; the existing E2E fixture does not yet expose a full multi-provider runtime-failure or synced-read-only workflow scenario, so those browser paths remain intentionally unverified. Public documentation was updated in `docs/public/tasks-and-workflows.md`, and both documentation validators passed. Fresh compressed PR screenshots are in `.pr-assets/` and are kept out of the feature branch. diff --git a/docs/plans/workflow-session-settings/task-06-workflow-session-options-toggle.md b/docs/plans/workflow-session-settings/task-06-workflow-session-options-toggle.md new file mode 100644 index 0000000000..d2d4129492 --- /dev/null +++ b/docs/plans/workflow-session-settings/task-06-workflow-session-options-toggle.md @@ -0,0 +1,64 @@ +--- +id: "06-workflow-session-options-toggle" +title: "Refine the workflow session-options toggle and family choices" +status: done +wave: 5 +depends_on: ["04-editor-and-carry-analysis"] +plan: "plan.md" +spec: "../../specs/workflow-session-settings/spec.md" +--- + +# Task 06: Refine the workflow session-options toggle and family choices + +## Acceptance + +- The step header shows an `Override original session options` checkbox and an info hover with the Sol-to-Luna example beside the fixed profile selector. +- The conditional editor is hidden when unchecked and appears below WIP controls when checked; fixed profile selection disables the checkbox. +- Conditional family choices include only families represented by available agent profiles, while persisted unavailable rules remain readable. +- Desktop and mobile workflow editor coverage verifies the toggle, visibility, touch sizing, and no horizontal overflow. + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web test -- --run components/settings/workflow-session-config-editor.test.tsx +cd apps/web && pnpm run typecheck +cd apps && pnpm --filter @kandev/web lint +cd apps/web && pnpm run i18n:ratchet +``` + +## Files likely touched + +- `apps/web/components/settings/workflow-pipeline-editor-panels.tsx` +- `apps/web/components/settings/workflow-session-config-editor.tsx` +- `apps/web/components/settings/workflow-session-config-shared.ts` +- `apps/web/components/settings/workflow-session-config-editor.test.tsx` +- `apps/web/e2e/tests/workflow/workflow-settings.spec.ts` +- `apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts` +- `apps/web/src/locales/en/settings.json` +- `apps/web/src/locales/pseudo/settings.json` + +## Dependencies + +Task 04. Sequential. + +## Inputs + +The approved workflow-session-settings spec, the existing editor/carry-analysis +implementation, and the mobile workflow settings scenario. + +## Results + +- `cd apps && pnpm --filter @kandev/web test -- --run components/settings/workflow-session-config-editor.test.tsx` — 3 tests passed. +- `cd apps && pnpm --filter @kandev/web test -- --run lib/workflows components/settings` — 69 files and 360 tests passed. +- `cd apps/web && pnpm run typecheck` — passed. +- `cd apps && pnpm --filter @kandev/web lint` — passed with zero warnings. +- `cd apps/web && pnpm run i18n:check` — passed with zero orphan keys and pseudo-locale in sync. +- `cd apps/web && pnpm run i18n:ratchet` — passed; all added and modified files clean. +- `cd apps && make build-web` — passed. +- `cd apps/web && pnpm e2e:run --project chromium tests/workflow/workflow-settings.spec.ts` — 14 tests passed. +- `cd apps/web && pnpm e2e:run --project mobile-chrome tests/workflow/mobile-workflow-settings.spec.ts` — 3 tests passed. +- `node --test scripts/validate-public-docs.test.mjs` — 58 tests passed. +- `node scripts/validate-public-docs.mjs` — 41 published pages validated. +- `git diff --check` — passed. +- PR #2137 conflict fixup — merged `origin/main` at `332353f647cf8ae157db893f529dfc4cb3516ba2`; resolved `apps/backend/internal/task/models/models.go` by retaining both metadata-key groups; merge commit `72bd08c0d116d513e1c63ba274ec94c17c49bbb1`. +- Post-push PR snapshot — mergeable, zero failed checks, 15 queued/pending checks, and zero unresolved review threads. diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index e0f0d67217..810d487e7b 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -243,7 +243,8 @@ New steps allow manual moves by default. **Show in command panel** also defaults | Setting | Effect | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Start step** | Makes this the normal starting step. Only one step per workflow should be selected. If none is selected, Kandev falls back to the first positional step. | -| Agent profile | Overrides the workflow/task profile when entering this step. A different profile creates a new session with fresh conversation context. | +| Agent profile | Overrides the workflow/task profile when entering this step. A different profile creates a new session with fresh conversation context. The fixed profile override and original-session options are mutually exclusive. | +| **Override original session options** | Keeps the original conversation tab while applying model and ACP configuration rules for the task's starting agent family. The options editor appears below WIP settings only when this is checked. | | **Auto-start agent** | Starts an agent whenever a task enters the step. | | **Plan mode** | Enables plan mode when the task enters the step. | | **Reset agent context** | Starts with fresh conversation context on entry. It is disabled when the step has a profile override because the profile switch already creates a fresh session. | @@ -294,6 +295,36 @@ review turn completes, not during startup. Plan mode can be disabled when the turn completes and/or when the task exits the step. A step prompt is Markdown and can include `{{task_prompt}}` to insert the original task description. +#### Override original session options + +Check **Override original session options** when a workflow should keep one +conversation while changing its model settings between steps. For example, a +task can start with session model **5.6 Sol** and switch to **5.6 Luna** for an +implementation step. The options editor appears below WIP settings after the +checkbox is enabled; selecting a fixed **Agent profile** disables this option. + +Add one rule per agent family; the rule is ignored when the task started with +another family. The family picker lists only families represented by configured +agent profiles, while existing persisted rules remain visible if capability +data later becomes unavailable. The editor uses the same model and ACP option +picker as the chat input, so provider-specific models and options are selected +from the agent's advertised capabilities. + +Each rule can **Set** a model and any selected options, **Keep** the settings +already active, or **Restore original** to reapply the immutable model and +option values captured when the original session finished initializing, after +profile settings were applied. +Rules are best-effort: a rejected field produces a warning, while successful +fields remain active and the step continues. The settings are applied before +an auto-start prompt and persist as the session's runtime overrides. + +This behavior is mutually exclusive with the step's fixed **Agent profile** +override. A fixed profile intentionally creates a separate session; conditional +rules never activate or mutate that replacement tab. If an earlier rule may +carry changed values into a later step, the editor shows a warning with **Keep**, +**Restore**, and **Set new** choices. Read-only synced workflows display these +rules and warnings but cannot edit them. + ### Build a human gate For a Review or Approval step: diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index a7ad5f6a91..88c3344806 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -80,6 +80,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su | [agent-generated-titles](tasks/agent-generated-titles.md) | approved | | [task-create-executor-default](tasks/task-create-executor-default.md) | approved | | [explicit-completion-signal](workflow/explicit-completion-signal/spec.md) | shipped | +| [conditional-session-settings](workflow-session-settings/spec.md) | approved | ## agents/ — agent governance diff --git a/docs/specs/ui/acp-model-configuration-summary.md b/docs/specs/ui/acp-model-configuration-summary.md index 3fca32e8b0..8b665470f2 100644 --- a/docs/specs/ui/acp-model-configuration-summary.md +++ b/docs/specs/ui/acp-model-configuration-summary.md @@ -14,6 +14,7 @@ ACP agents can advertise an arbitrary ordered set of model-adjacent session conf - Kandev records a write-once baseline of the initial ACP select-option values advertised by the provider before agent-profile or runtime overrides are applied. The baseline is stored in the task-session database metadata and survives backend restart, process recreation, and ACP session resume. - The provider-default baseline is separate from mutable runtime configuration and is never used to restore provider state. A profile-selected value that differs from the provider default is therefore shown as changed as soon as the task session starts. +- Workflow restoration uses a separate immutable original effective configuration captured after profile settings settle. It does not change the comparison-only ownership of the provider-default baseline. See [Conditional Workflow Session Settings](../workflow-session-settings/spec.md) and [ADR-2026-08-01-workflow-session-original-configuration](../../decisions/2026-08-01-workflow-session-original-configuration.md). - In the task chat input and task context surfaces, the closed model selector always shows the current model name followed by every non-model config value whose raw current value differs from its baseline value, in ACP-provided option order. Values are joined by a slash with surrounding spaces; option names are omitted. Example: `GPT-5.6-Sol / Low / On`. - Until a session baseline is available, the closed task selector shows every current non-model value rather than hiding options whose changed state cannot yet be determined. - A value that returns to its baseline disappears from the closed summary. A currently advertised option with no baseline entry is treated as changed. Baseline entries for options the provider no longer advertises are ignored. diff --git a/docs/specs/workflow-session-settings/spec.md b/docs/specs/workflow-session-settings/spec.md new file mode 100644 index 0000000000..505d177b8e --- /dev/null +++ b/docs/specs/workflow-session-settings/spec.md @@ -0,0 +1,181 @@ +--- +status: approved +created: 2026-08-01 +owner: kandev +--- + +# Conditional Workflow Session Settings + +## Why + +Workflow authors can currently select a different agent profile for a step, but that creates or activates another task session and fragments the conversation across tabs. Authors need to route model and model-adjacent ACP settings by the agent family that started the task while preserving the original conversation context. + +## What + +- A workflow step can use one of three mutually exclusive agent behaviors: + - keep the current agent behavior; + - switch to a fixed agent profile using the existing step profile override; or + - conditionally control the original task session's ACP model settings. +- Conditional session settings contain at most one rule per original agent family, identified by the stable agent name such as `codex` or `claude`, rather than by a specific agent profile. +- A rule operation is one of: + - `set`: apply an explicitly selected model and zero or more ACP select configuration values; + - `keep`: acknowledge that settings inherited from an earlier step should remain active without changing them; or + - `restore_original`: actively reapply the original session configuration captured when the session first initialized. +- A step can contain rules for multiple agent families. Exactly one rule can match a task, because the task has one original session family. If no rule matches, Kandev changes no session settings. +- Rules apply only to the task's original session. They never create a session, activate another tab, change an agent profile, or apply one agent family's settings to a later profile-switched session. +- Kandev applies a matching `set` or `restore_original` rule before the step's auto-start prompt. A start-step rule applies before the task's first prompt. +- A successful rule updates the same durable session runtime overrides used by the task chat model selector. Its values survive agent-process recreation and backend restart and remain active in later workflow steps. +- Applying a later `set` rule replaces only the fields named by that rule. Unnamed fields retain their current values. +- `restore_original` reapplies the session's immutable original effective model and every captured selectable ACP configuration value. It therefore restores both explicit profile choices and provider-default values that the profile did not override. +- Restored values are persisted as explicit session runtime overrides. A later resume uses the restored values even if provider defaults or the agent profile have since changed. +- The workflow editor reuses the shared model/configuration selector used by the task chat input. It shows the selected model and all supported ACP select options supplied by that agent family's capability data; it does not hard-code provider-specific option names or values. +- The editor performs conservative workflow-path analysis by agent family. When a step can be reached with settings changed by an earlier rule and has no explicit `set`, `keep`, or `restore_original` decision for that family, the step shows a warning that the earlier values may carry forward. +- From that warning, the workflow author can explicitly keep the inherited values, restore the original values, or choose new values for the affected family. Saving any of those choices resolves the warning for that family and path. +- Workflow-path warnings consider configured relative and explicit transitions, including cycles. They are advisory and do not attempt to predict arbitrary manual card moves. +- The conditional rule editor is fully usable on desktop and mobile. On phones, rule cards form a one-column flow; family, operation, model, and option controls remain reachable by touch without document-level horizontal scrolling. +- The step header places an `Override original session options` checkbox beside the fixed agent-profile selector. Its help text explains that checking it keeps the original conversation tab while allowing a workflow step to change session settings (for example, starting with model 5.6 Sol and switching to 5.6 Luna for implementation work). +- The conditional rule editor is shown below the WIP controls only when `Override original session options` is checked. The fixed profile selector and this checkbox are mutually exclusive. +- Agent-family choices in conditional rules come only from agent families represented by configured agent profiles; capability data for an unconfigured family does not create a new choice. +- Read-only synced workflows display their rules and carry-forward warnings but do not allow edits, matching existing workflow settings behavior. + +Decision: [ADR-2026-08-01-workflow-session-original-configuration](../../decisions/2026-08-01-workflow-session-original-configuration.md). + +## Data model + +### Workflow step rule action + +Conditional rules are stored inside the existing `workflow_steps.events` JSON as one `on_enter` action. No workflow-step table column is added. + +```json +{ + "type": "configure_session", + "config": { + "rules": [ + { + "agent_name": "codex", + "operation": "set", + "model": "gpt-5.6-luna", + "config_options": { + "reasoning_effort": "max" + } + }, + { + "agent_name": "claude", + "operation": "restore_original" + } + ] + } +} +``` + +Constraints: + +- `agent_name` is non-empty and unique within the action. +- `operation` is `set`, `keep`, or `restore_original`. +- A `set` rule contains a non-empty `model` or at least one non-empty `config_options` entry. +- `keep` and `restore_original` rules do not contain `model` or `config_options`. +- A step contains at most one `configure_session` action. +- A step with `configure_session` has no step-level `agent_profile_id`; attempts to persist or import both are rejected. + +Workflow import/export keeps the family-based rule unchanged because agent names, model IDs, and ACP option IDs are portable provider contracts rather than workspace profile IDs. + +### Original session identity + +The first task session is marked as the task's original workflow session in `task_sessions.metadata`. Its stable family name comes from the session's profile snapshot `agent_name`. Existing sessions created before this feature may be treated as original only when Kandev can identify the task's earliest non-workflow-switch session unambiguously. + +### Original effective configuration + +After the original ACP session initializes and profile settings settle, `task_sessions.metadata` stores one immutable original effective configuration snapshot: + +```json +{ + "model": "gpt-5.6-sol", + "config_options": { + "reasoning_effort": "high" + } +} +``` + +The snapshot includes the effective profile-selected model and every provider-advertised selectable option's settled raw value, including unchanged provider defaults. It is distinct from: + +- the provider-default comparison baseline used by compact selector labels; +- the provider's latest mutable runtime state; and +- explicit session runtime overrides. + +The snapshot is written once and is never replaced by workflow rules, user selections, provider events, profile edits, process recreation, or session resume. + +## API surface + +Existing workflow create/update, snapshot, import, and export contracts carry the new `events.on_enter` action. No new HTTP or WebSocket endpoint is required. + +The backend validates the action on workflow-step create/update and import. Invalid rules return the existing validation response for the calling surface and do not partially persist the step. + +Existing agent capability responses provide the editor's family names, models, and ACP select options. Existing task-session model/configuration mechanisms apply and persist runtime changes. + +## State machine + +For each original agent family on each possible workflow path, the editor and runtime use these effective states: + +| Incoming state | Matching operation | Outgoing state | Runtime effect | +| --- | --- | --- | --- | +| original or changed | `set` | changed | Apply the named values. | +| original | `keep` | original | No mutation; intent is explicit. | +| changed | `keep` | changed | No mutation; carry-forward is explicit. | +| original or changed | `restore_original` | original | Apply every captured original value. | +| original or changed | no matching rule | unchanged | No mutation; warn in the editor when the incoming state may be changed. | + +When multiple configured paths reach a step with different states, the editor treats the incoming state as possibly changed and warns until that family has an explicit decision. + +## Permissions + +The same users who can edit a workspace workflow can create, change, or remove conditional session rules. Synced read-only workflows remain non-editable. Runtime application occurs as part of the authorized task workflow transition and does not add a separate user-facing mutation permission. + +## Failure modes + +- If the original session family does not match any rule, Kandev performs no configuration call, records no override, and proceeds with the step normally. +- If the active session is not the task's original session, Kandev does not activate or mutate another session. It proceeds with the step and adds a visible workflow warning to the conversation. +- A matching rule applies best-effort by field. Unsupported or rejected models/options do not roll back successful fields and do not block the step's auto-start prompt. +- Any failed field produces one visible warning that identifies the step, agent family, failed fields, and values actually retained. Provider errors are sanitized before display. +- If a live field applies but its durable override cannot be persisted, Kandev warns that the value may not survive restart; other fields continue. +- If the session is not running but can be launched or resumed, Kandev records the requested overrides first so initialization applies them before the next prompt. +- If `restore_original` has no trustworthy original effective snapshot, Kandev changes nothing, warns that restoration is unavailable for that legacy or incomplete session, and continues the step. +- If an option captured in the original snapshot is no longer advertised, restoration skips that option, restores the remaining fields, and reports the skipped option in the warning. +- Capability discovery failures keep the conditional editor readable, show the existing agent capability error, and prevent saving a new `set` rule whose values cannot be selected. Existing persisted rules remain visible and removable. + +## Persistence guarantees + +- Workflow rules survive reload, workflow export/import, and backend restart through the existing workflow-step `events` JSON. +- The original-session marker and original effective configuration survive backend restart and agent-process recreation in task-session metadata. +- Successful `set` and `restore_original` values survive restart through explicit session runtime overrides. +- `keep` stores workflow author intent only; it writes no task-session runtime state. +- A provider update or profile edit does not rewrite a task session's original effective configuration. +- Deleting the task session deletes its original snapshot with the session row. + +## Scenarios + +- **GIVEN** a task starts with any Codex profile and the next step has `Codex -> Luna / max`, **WHEN** the task enters that step, **THEN** the existing original session tab remains active and its model and reasoning effort change before the step prompt runs. +- **GIVEN** the same step also has a Claude rule and the task started with Claude, **WHEN** the task enters the step, **THEN** only the Claude rule applies to the same original Claude session. +- **GIVEN** rules for Codex and Claude and a task that started with Grok, **WHEN** the task enters the step, **THEN** no settings change and the step proceeds without a no-match warning. +- **GIVEN** a Codex profile originally selected Sol while reasoning effort settled to the provider default High, **WHEN** a later Codex rule restores original settings, **THEN** Kandev actively sets both Sol and High and persists both values. +- **GIVEN** a prior Codex rule changed settings and a reachable later step has no Codex decision, **WHEN** the author edits that later step, **THEN** the editor warns that the prior values may remain and offers keep, restore, and choose-new actions. +- **GIVEN** that warning, **WHEN** the author chooses keep, **THEN** the warning resolves and entering the step performs no runtime mutation. +- **GIVEN** that warning, **WHEN** the author chooses restore, **THEN** the warning resolves and entering the step reapplies the captured original values before any auto-start prompt. +- **GIVEN** a workflow cycle can re-enter a step through both changed and original configuration paths, **WHEN** the author edits the step, **THEN** the editor conservatively reports that changed values may enter until the family has an explicit rule. +- **GIVEN** a matching rule whose model succeeds but one config option is rejected, **WHEN** the task enters the step, **THEN** the model remains changed, the prior option value remains, one visible warning describes the partial result, and auto-start continues. +- **GIVEN** a task whose current session was activated by an earlier fixed profile override, **WHEN** it enters a conditional-settings step, **THEN** Kandev does not switch tabs or apply the rule to that session and adds a visible warning. +- **GIVEN** a new task starts directly in a step with a matching `set` rule, **WHEN** its original ACP session initializes, **THEN** Kandev captures the pre-rule original effective configuration, applies the rule, and sends the first prompt with the selected settings. +- **GIVEN** a legacy task session with no trustworthy original effective snapshot, **WHEN** a restore rule matches, **THEN** no value is guessed or changed and the conversation shows that restoration is unavailable. +- **GIVEN** a saved conditional-settings step, **WHEN** the workflow is exported and imported into another workspace with the same agent family capability, **THEN** the family rule, model, option values, and operations round-trip without profile matching. +- **GIVEN** a phone viewport, **WHEN** the author adds Codex and Claude rules and chooses model/config values, **THEN** every action is reachable by touch, the rule list has one vertical scroll owner, and the page has no horizontal overflow. +- **GIVEN** a step with no fixed profile override, **WHEN** the author leaves `Override original session options` unchecked, **THEN** the conditional rule editor is not shown below the WIP controls. +- **GIVEN** the same step, **WHEN** the author checks `Override original session options`, **THEN** the conditional rule editor appears below the WIP controls and the original-session help text explains the Sol-to-Luna example. +- **GIVEN** capability data for Codex and Grok but configured profiles only for Codex, **WHEN** the author adds a conditional rule, **THEN** the family selector offers Codex but not Grok. + +## Out of scope + +- Matching one specific agent profile rather than an agent family. +- Creating, switching, reactivating, or renaming task sessions. +- Changing agent credentials, CLI flags, permission mode, executor, environment, MCP configuration, or profile settings. +- Automatically restoring settings merely because a task leaves a step. +- Predicting arbitrary manual card moves in the editor's carry-forward analysis. +- Inferring, translating, or aliasing provider-specific model and option identifiers.