diff --git a/apps/backend/NEXT_STEPS.md b/apps/backend/NEXT_STEPS.md deleted file mode 100644 index 69c4d83134..0000000000 --- a/apps/backend/NEXT_STEPS.md +++ /dev/null @@ -1,108 +0,0 @@ -# Kandev Backend - Next Steps - -## Current State (2026-01-10) - -The backend is a unified Go binary (`cmd/kandev/main.go`) that runs: -- **Task Service**: Kanban board/column/task management (SQLite persistence) -- **Agent Manager**: Docker container lifecycle for AI agents -- **Orchestrator**: Coordinates task execution with agents - -### Working Features - -1. **SQLite Persistence** - - Database file: `./kandev.db` (configurable via `KANDEV_DB_PATH`) - - Stores boards, columns, and tasks with metadata - - WAL mode enabled for concurrent reads - - Data survives server restarts - -2. **Augment Agent Integration** - - Docker image: `kandev/augment-agent:latest` - - Uses Auggie CLI with `--print --output-format json` - - ACP (Agent Communication Protocol) message streaming - - Session ID extraction and storage in task metadata - -3. **Session Resumption** - - Sessions persist in `~/.augment/sessions/` (mounted into containers) - - Follow-up agents can resume with `AUGGIE_SESSION_ID` env var - -4. **Real-time Streaming** - - Container logs parsed for ACP JSON messages - - Events published to in-memory event bus - - WebSocket endpoints available for frontend - -## Immediate Next Steps - -### 1. Agent Run History -Track all agent executions: -- [ ] Add `agent_runs` table to SQLite schema -- [ ] Store task_id, session_id, started_at, completed_at, exit_code -- [ ] Link runs to tasks for history viewing -- [ ] Store ACP messages for replay - -### 2. Frontend Integration -- [ ] Create React/Vue frontend for Kanban board -- [ ] WebSocket client for real-time agent progress -- [ ] Task detail view with agent history and session resumption - -### 3. Automatic Task Orchestration -- [ ] Configure tasks with `agent_type` field -- [ ] Auto-launch agents when tasks move to specific columns -- [ ] Handle agent failures with retry logic - -## API Reference - -All communication happens over WebSocket at `ws://localhost:8080/ws`. -See `docs/WEBSOCKET_API.md` for complete API documentation. - -### Key Actions - -| Action | Description | -|--------|-------------| -| `board.create` | Create a new board | -| `task.create` | Create a task | -| `task.subscribe` | Subscribe to ACP streaming for a task | -| `orchestrator.start` | Start executing a task with an agent | -| `agent.prompt` | Send follow-up prompt to running agent | - -## Development Commands - -```bash -# Build -cd apps/backend && go build -o kandev ./cmd/kandev - -# Run server -./kandev - -# Build Docker image -cd apps/backend/dockerfiles/augment-agent && docker build -t kandev/augment-agent:latest . -``` - -## Key Files - -| File | Purpose | -|------|---------| -| `cmd/kandev/main.go` | Unified entry point, ACP handlers | -| `internal/task/repository/interface.go` | Repository interface definition | -| `internal/task/repository/sqlite.go` | SQLite implementation with schema | -| `internal/task/repository/memory.go` | In-memory implementation (for testing) | -| `internal/agent/lifecycle/manager.go` | Container lifecycle, mount templates | -| `internal/agent/registry/defaults.go` | Agent type definitions | -| `internal/agent/streaming/reader.go` | ACP message parsing from logs | -| `dockerfiles/augment-agent/agent.sh` | Container entrypoint script | -| `dockerfiles/augment-agent/Dockerfile` | Agent Docker image | - -## Architecture Decisions - -1. **SQLite with WAL mode** for simple, file-based persistence -2. **In-memory event bus** instead of NATS for simpler development -3. **One agent per task** constraint (prevents conflicts) -4. **Session files mounted** from host for persistence across container runs -5. **ACP protocol** for structured agent-to-server communication -6. **Repository interface** allows swapping storage backends (SQLite, Memory, PostgreSQL) - -## Configuration - -Environment variables: -- `KANDEV_DB_PATH` - SQLite database file path (default: `./kandev.db`) -- `KANDEV_LOGGING_LEVEL` - Log level: debug, info, warn, error (default: info) -- `KANDEV_CREDENTIALS_FILE` - Path to credentials file for agents diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index fe71c8d86b..8d4a7316a4 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -70,6 +70,7 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch Env: req.Env, ACPSessionID: req.ACPSessionID, Metadata: req.Metadata, + ModelOverride: req.ModelOverride, // Worktree configuration for concurrent agent execution UseWorktree: req.UseWorktree, RepositoryID: req.RepositoryID, @@ -209,14 +210,41 @@ func (a *lifecycleAdapter) CleanupStaleExecutionBySessionID(ctx context.Context, return a.mgr.CleanupStaleExecutionBySessionID(ctx, sessionID) } +// GetAgentType returns the agent type configuration +func (a *lifecycleAdapter) GetAgentType(ctx context.Context, agentID string) (*v1.AgentType, error) { + config, err := a.registry.Get(agentID) + if err != nil { + return nil, err + } + return config.ToAPIType(), nil +} + +// ResolveAgentProfile resolves an agent profile ID to profile information +func (a *lifecycleAdapter) ResolveAgentProfile(ctx context.Context, profileID string) (*executor.AgentProfileInfo, error) { + info, err := a.mgr.ResolveAgentProfile(ctx, profileID) + if err != nil { + return nil, err + } + return &executor.AgentProfileInfo{ + ProfileID: info.ProfileID, + ProfileName: info.ProfileName, + AgentID: info.AgentID, + AgentName: info.AgentName, + Model: info.Model, + AutoApprove: info.AutoApprove, + DangerouslySkipPermissions: info.DangerouslySkipPermissions, + Plan: info.Plan, + }, nil +} + // orchestratorAdapter adapts the orchestrator.Service to the taskhandlers.OrchestratorService interface type orchestratorAdapter struct { svc *orchestrator.Service } // PromptTask forwards to the orchestrator service and converts the result type -func (a *orchestratorAdapter) PromptTask(ctx context.Context, taskID, taskSessionID, prompt string) (*taskhandlers.PromptResult, error) { - result, err := a.svc.PromptTask(ctx, taskID, taskSessionID, prompt) +func (a *orchestratorAdapter) PromptTask(ctx context.Context, taskID, taskSessionID, prompt, model string) (*taskhandlers.PromptResult, error) { + result, err := a.svc.PromptTask(ctx, taskID, taskSessionID, prompt, model) if err != nil { return nil, err } diff --git a/apps/backend/internal/agent/lifecycle/command.go b/apps/backend/internal/agent/lifecycle/command.go index 39f23f1187..ceaf293da5 100644 --- a/apps/backend/internal/agent/lifecycle/command.go +++ b/apps/backend/internal/agent/lifecycle/command.go @@ -32,8 +32,12 @@ func (cb *CommandBuilder) BuildCommand(agentConfig *registry.AgentTypeConfig, op copy(cmd, agentConfig.Cmd) // Append model flag if agent supports it and model is specified + // ModelFlag supports {model} placeholder, e.g. "--model {model}" or "-c model=\"{model}\"" if agentConfig.ModelFlag != "" && opts.Model != "" { - cmd = append(cmd, agentConfig.ModelFlag, opts.Model) + expanded := strings.ReplaceAll(agentConfig.ModelFlag, "{model}", opts.Model) + // Split on first space to separate flag from value (if combined) + parts := strings.SplitN(expanded, " ", 2) + cmd = append(cmd, parts...) } // Append session resume flag if: diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index 652fcbec70..a32d344c26 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -497,6 +497,10 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti model = profileInfo.Model autoApprove = profileInfo.AutoApprove } + // Allow model override from request (for dynamic model switching) + if req.ModelOverride != "" { + model = req.ModelOverride + } cmdOpts := CommandOptions{ Model: model, SessionID: req.ACPSessionID, @@ -595,13 +599,17 @@ func (m *Manager) StartAgentProcess(ctx context.Context, executionID string) err return fmt.Errorf("failed to resolve MCP config: %w", err) } + // Capture the original ACP session ID before initialization overwrites it. + // This is used to determine if we're resuming an existing session or starting a new one. + providedACPSessionID := execution.ACPSessionID + // Initialize ACP session if err := m.initializeACPSession(ctx, execution, agentConfig, taskDescription, mcpServers); err != nil { m.updateExecutionError(executionID, "failed to initialize ACP: "+err.Error()) return fmt.Errorf("failed to initialize ACP: %w", err) } - m.emitSessionStatusEvent(execution, agentConfig) + m.emitSessionStatusEvent(execution, agentConfig, providedACPSessionID) return nil } @@ -732,6 +740,16 @@ func (m *Manager) getAgentConfigForExecution(execution *AgentExecution) (*regist return agentConfig, nil } +// ResolveAgentProfile resolves an agent profile ID to profile information. +// This is exposed for external callers (like the orchestrator executor) to get profile info. +// The profile's model is guaranteed to be non-empty as it's validated at creation time. +func (m *Manager) ResolveAgentProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) { + if m.profileResolver == nil { + return nil, fmt.Errorf("profile resolver not configured") + } + return m.profileResolver.ResolveProfile(ctx, profileID) +} + // resolveMcpServers centralizes MCP resolution for a session: // - loads per-agent MCP config, // - applies executor-scoped transport rules, allow/deny lists, URL rewrites, and env injection, @@ -816,9 +834,11 @@ func (m *Manager) initializeACPSession(ctx context.Context, execution *AgentExec } // emitSessionStatusEvent emits a session_status event indicating whether the session was resumed or new. -func (m *Manager) emitSessionStatusEvent(execution *AgentExecution, agentConfig *registry.AgentTypeConfig) { +// providedACPSessionID is the ACP session ID that was provided BEFORE initialization (for resumption). +// This must be captured before initializeACPSession runs, as that overwrites execution.ACPSessionID. +func (m *Manager) emitSessionStatusEvent(execution *AgentExecution, agentConfig *registry.AgentTypeConfig, providedACPSessionID string) { wasResumed := false - if execution.ACPSessionID != "" { + if providedACPSessionID != "" { if agentConfig.SessionConfig.ResumeViaACP { wasResumed = true } else if agentConfig.SessionConfig.ResumeFlag != "" && agentConfig.SessionConfig.SupportsRecovery() { diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index 6839a74a20..956ee00ae1 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -83,6 +83,7 @@ type LaunchRequest struct { Env map[string]string // Additional env vars ACPSessionID string // ACP session ID to resume, if available Metadata map[string]interface{} + ModelOverride string // If set, use this model instead of the profile's model // Worktree configuration UseWorktree bool // Whether to use a Git worktree for isolation diff --git a/apps/backend/internal/agent/registry/agents.json b/apps/backend/internal/agent/registry/agents.json index 4b59eb04a9..dda286b16e 100644 --- a/apps/backend/internal/agent/registry/agents.json +++ b/apps/backend/internal/agent/registry/agents.json @@ -25,7 +25,7 @@ "capabilities": ["code_generation", "code_review", "refactoring", "testing", "shell_execution"], "protocol": "acp", "protocol_config": {}, - "model_flag": "--model", + "model_flag": "--model {model}", "workspace_flag": "--workspace-root", "session_config": { "resume_via_acp": false, @@ -132,7 +132,7 @@ "capabilities": ["code_generation", "code_review", "refactoring", "testing", "shell_execution"], "protocol": "codex", "protocol_config": {}, - "model_flag": "--model", + "model_flag": "-c model=\"{model}\"", "workspace_flag": "", "session_config": { "resume_via_acp": true, @@ -169,6 +169,7 @@ { "id": "gpt-5.2-codex", "name": "GPT-5.2 Codex", + "description": "Latest frontier agentic coding model", "provider": "openai", "context_window": 200000, "is_default": true @@ -176,6 +177,7 @@ { "id": "gpt-5.1-codex-max", "name": "GPT-5.1 Codex Max", + "description": "Codex-optimized flagship for deep and fast reasoning", "provider": "openai", "context_window": 200000, "is_default": false @@ -183,6 +185,7 @@ { "id": "gpt-5.1-codex-mini", "name": "GPT-5.1 Codex Mini", + "description": "Optimized for codex. Cheaper, faster, but less capable", "provider": "openai", "context_window": 200000, "is_default": false @@ -190,6 +193,7 @@ { "id": "gpt-5.2", "name": "GPT-5.2", + "description": "Latest frontier model with improvements across knowledge, reasoning and coding", "provider": "openai", "context_window": 200000, "is_default": false diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index b5cdc5e145..f620333b49 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -22,6 +22,7 @@ var ( ErrAgentProfileNotFound = errors.New("agent profile not found") ErrAgentProfileInUse = errors.New("agent profile is used by an active agent session") ErrAgentMcpUnsupported = errors.New("mcp not supported by agent") + ErrModelRequired = errors.New("model is required for agent profiles") ) const defaultAgentProfileName = "default" @@ -150,6 +151,7 @@ func (c *Controller) EnsureInitialAgentProfiles(ctx context.Context) error { return err } displayNameByAgent := c.displayNameByAgent() + defaultModelByAgent := c.defaultModelByAgent() for _, result := range results { if !result.Available { continue @@ -158,6 +160,10 @@ func (c *Controller) EnsureInitialAgentProfiles(ctx context.Context) error { if !ok || displayName == "" { return fmt.Errorf("unknown agent display name: %s", result.Name) } + defaultModel, ok := defaultModelByAgent[result.Name] + if !ok || defaultModel == "" { + return fmt.Errorf("unknown agent default model: %s", result.Name) + } agent, err := c.repo.GetAgentByName(ctx, result.Name) if err != nil && !errors.Is(err, sql.ErrNoRows) { return err @@ -197,7 +203,7 @@ func (c *Controller) EnsureInitialAgentProfiles(ctx context.Context) error { defaultProfile := &models.AgentProfile{ AgentID: agent.ID, Name: defaultAgentProfileName, - Model: "", + Model: defaultModel, Plan: "", AgentDisplayName: displayName, } @@ -399,6 +405,10 @@ type CreateProfileRequest struct { } func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest) (*dto.AgentProfileDTO, error) { + // Validate that model is provided + if strings.TrimSpace(req.Model) == "" { + return nil, ErrModelRequired + } agent, err := c.repo.GetAgent(ctx, req.AgentID) if err != nil { return nil, err @@ -441,6 +451,10 @@ func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest profile.Name = *req.Name } if req.Model != nil { + // Validate that model is not empty + if strings.TrimSpace(*req.Model) == "" { + return nil, ErrModelRequired + } profile.Model = *req.Model } if req.AutoApprove != nil { @@ -542,3 +556,15 @@ func (c *Controller) mustDisplayName(agentName string) string { } return "" } + +func (c *Controller) defaultModelByAgent() map[string]string { + definitions := c.discovery.Definitions() + mapped := make(map[string]string, len(definitions)) + for _, def := range definitions { + if def.Name == "" || def.ModelConfig.DefaultModel == "" { + continue + } + mapped[def.Name] = def.ModelConfig.DefaultModel + } + return mapped +} diff --git a/apps/backend/internal/agent/settings/store/sqlite.go b/apps/backend/internal/agent/settings/store/sqlite.go index 64bc7f4a4b..1221dc7fbc 100644 --- a/apps/backend/internal/agent/settings/store/sqlite.go +++ b/apps/backend/internal/agent/settings/store/sqlite.go @@ -53,7 +53,7 @@ func (r *sqliteRepository) initSchema() error { agent_id TEXT NOT NULL, name TEXT NOT NULL, agent_display_name TEXT NOT NULL, - model TEXT NOT NULL, + model TEXT NOT NULL CHECK(model != ''), auto_approve INTEGER NOT NULL DEFAULT 0, dangerously_skip_permissions INTEGER NOT NULL DEFAULT 0, plan TEXT DEFAULT '', diff --git a/apps/backend/internal/integration/orchestrator_test.go b/apps/backend/internal/integration/orchestrator_test.go index f82e1dfb7c..6372ba0895 100644 --- a/apps/backend/internal/integration/orchestrator_test.go +++ b/apps/backend/internal/integration/orchestrator_test.go @@ -458,6 +458,36 @@ func (s *SimulatedAgentManagerClient) CancelAgent(ctx context.Context, sessionID return nil } +// GetAgentType returns the agent type configuration +func (s *SimulatedAgentManagerClient) GetAgentType(ctx context.Context, agentID string) (*v1.AgentType, error) { + agentTypes, _ := s.ListAgentTypes(ctx) + for _, at := range agentTypes { + if at.ID == agentID { + return at, nil + } + } + // Return a default agent type for testing + return &v1.AgentType{ + ID: agentID, + Name: "Simulated Agent", + Description: "Simulated agent for testing", + DockerImage: "test/agent", + DockerTag: "test", + Enabled: true, + }, nil +} + +// ResolveAgentProfile resolves an agent profile ID to profile information +func (s *SimulatedAgentManagerClient) ResolveAgentProfile(ctx context.Context, profileID string) (*executor.AgentProfileInfo, error) { + return &executor.AgentProfileInfo{ + ProfileID: profileID, + ProfileName: "Simulated Profile", + AgentID: "augment-agent", + AgentName: "Augment Agent", + Model: "claude-sonnet-4-20250514", + }, nil +} + // ============================================ // ORCHESTRATOR TEST SERVER // ============================================ diff --git a/apps/backend/internal/orchestrator/controller/controller.go b/apps/backend/internal/orchestrator/controller/controller.go index 9b6c474568..8d204ad3f7 100644 --- a/apps/backend/internal/orchestrator/controller/controller.go +++ b/apps/backend/internal/orchestrator/controller/controller.go @@ -139,7 +139,7 @@ func (c *Controller) StopTask(ctx context.Context, req dto.StopTaskRequest) (dto // PromptTask sends a prompt to a running task func (c *Controller) PromptTask(ctx context.Context, req dto.PromptTaskRequest) (dto.PromptTaskResponse, error) { - result, err := c.service.PromptTask(ctx, req.TaskID, req.TaskSessionID, req.Prompt) + result, err := c.service.PromptTask(ctx, req.TaskID, req.TaskSessionID, req.Prompt, req.Model) if err != nil { return dto.PromptTaskResponse{}, err } diff --git a/apps/backend/internal/orchestrator/dto/dto.go b/apps/backend/internal/orchestrator/dto/dto.go index 6d6d638953..2886d5ba92 100644 --- a/apps/backend/internal/orchestrator/dto/dto.go +++ b/apps/backend/internal/orchestrator/dto/dto.go @@ -123,6 +123,7 @@ type PromptTaskRequest struct { TaskID string `json:"task_id"` TaskSessionID string `json:"session_id"` Prompt string `json:"prompt"` + Model string `json:"model,omitempty"` // Optional: switch to this model before processing prompt } // PromptTaskResponse is the response for orchestrator.prompt diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 7e82c38b7c..cee2b16a3b 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -4,6 +4,7 @@ package executor import ( "context" "errors" + "fmt" "strings" "time" @@ -67,6 +68,24 @@ type AgentManagerClient interface { // CleanupStaleExecutionBySessionID removes a stale agent execution from tracking without trying to stop it. // This is used when we detect the agent process has stopped but the execution is still tracked. CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error + + // GetAgentType returns the agent type configuration + GetAgentType(ctx context.Context, agentID string) (*v1.AgentType, error) + + // ResolveAgentProfile resolves an agent profile ID to profile information + ResolveAgentProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) +} + +// AgentProfileInfo contains resolved profile information +type AgentProfileInfo struct { + ProfileID string + ProfileName string + AgentID string + AgentName string + Model string + AutoApprove bool + DangerouslySkipPermissions bool + Plan string } // LaunchAgentRequest contains parameters for launching an agent @@ -82,6 +101,7 @@ type LaunchAgentRequest struct { Metadata map[string]interface{} Env map[string]string ACPSessionID string // ACP session ID to resume, if available + ModelOverride string // If set, use this model instead of the profile's model // Worktree configuration for concurrent agent execution UseWorktree bool // Whether to use a Git worktree for isolation @@ -286,19 +306,42 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP req.WorktreeBranchPrefix = worktreeBranchPrefix } + // Resolve agent profile to get model and other settings for snapshot + var agentProfileSnapshot map[string]interface{} + if profileInfo, err := e.agentManager.ResolveAgentProfile(ctx, agentProfileID); err == nil && profileInfo != nil { + agentProfileSnapshot = map[string]interface{}{ + "id": profileInfo.ProfileID, + "name": profileInfo.ProfileName, + "agent_id": profileInfo.AgentID, + "agent_name": profileInfo.AgentName, + "model": profileInfo.Model, + "auto_approve": profileInfo.AutoApprove, + "dangerously_skip_permissions": profileInfo.DangerouslySkipPermissions, + "plan": profileInfo.Plan, + } + e.logger.Debug("resolved agent profile for snapshot", + zap.String("profile_id", profileInfo.ProfileID), + zap.String("model", profileInfo.Model)) + } else if err != nil { + e.logger.Warn("failed to resolve agent profile for snapshot, session will have empty snapshot", + zap.String("agent_profile_id", agentProfileID), + zap.Error(err)) + } + // Create agent session in database before launch so worktree associations can persist. sessionID := uuid.New().String() now := time.Now().UTC() session := &models.TaskSession{ - ID: sessionID, - TaskID: task.ID, - AgentProfileID: agentProfileID, - RepositoryID: repositoryID, - BaseBranch: baseBranch, - State: models.TaskSessionStateCreated, - Progress: 0, - StartedAt: now, - UpdatedAt: now, + ID: sessionID, + TaskID: task.ID, + AgentProfileID: agentProfileID, + RepositoryID: repositoryID, + BaseBranch: baseBranch, + State: models.TaskSessionStateCreated, + Progress: 0, + StartedAt: now, + UpdatedAt: now, + AgentProfileSnapshot: agentProfileSnapshot, } if executorID := e.defaultExecutorID(ctx, task.WorkspaceID); executorID != "" { session.ExecutorID = executorID @@ -780,6 +823,151 @@ func (e *Executor) Prompt(ctx context.Context, taskID, sessionID string, prompt return e.agentManager.PromptAgent(ctx, session.AgentExecutionID, prompt) } +// SwitchModel stops the current agent, restarts it with a new model, and sends the prompt. +// For agents that support session resume (can_recover: true), it attempts to resume context. +// For agents that don't support resume (can_recover: false), a fresh session is started. +func (e *Executor) SwitchModel(ctx context.Context, taskID, sessionID, newModel, prompt string) (*PromptResult, error) { + e.logger.Info("switching model for session", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("new_model", newModel)) + + // Get the session + session, err := e.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("failed to get session: %w", err) + } + if session.TaskID != taskID { + return nil, fmt.Errorf("session %s does not belong to task %s", sessionID, taskID) + } + + // Get current execution ID + oldAgentExecutionID := session.AgentExecutionID + if oldAgentExecutionID == "" { + return nil, ErrExecutionNotFound + } + + // Get the task for re-launching + task, err := e.repo.GetTask(ctx, taskID) + if err != nil { + return nil, fmt.Errorf("failed to get task: %w", err) + } + + // Get executor running info to check for resume token (ACP session ID) + var acpSessionID string + if running, err := e.repo.GetExecutorRunningBySessionID(ctx, sessionID); err == nil && running != nil { + acpSessionID = running.ResumeToken + } + + // Stop the current agent + e.logger.Info("stopping current agent for model switch", + zap.String("agent_execution_id", oldAgentExecutionID)) + if err := e.agentManager.StopAgent(ctx, oldAgentExecutionID, false); err != nil { + e.logger.Warn("failed to stop agent for model switch, continuing anyway", + zap.Error(err), + zap.String("agent_execution_id", oldAgentExecutionID)) + } + + // Build a new launch request with the model override + req := &LaunchAgentRequest{ + TaskID: task.ID, + SessionID: sessionID, // Reuse the existing session ID + TaskTitle: task.Title, + AgentProfileID: session.AgentProfileID, + TaskDescription: prompt, + ModelOverride: newModel, // This is the key - use the new model + ACPSessionID: acpSessionID, + } + + // Get repository info if available + if session.RepositoryID != "" { + repository, err := e.repo.GetRepository(ctx, session.RepositoryID) + if err == nil && repository != nil { + req.RepositoryURL = repository.LocalPath + req.Branch = session.BaseBranch + } + } + + // Get worktree info from running state + if running, err := e.repo.GetExecutorRunningBySessionID(ctx, sessionID); err == nil && running != nil { + if running.WorktreePath != "" { + req.RepositoryURL = running.WorktreePath + } + } + + req.Env = e.applyPreferredShellEnv(ctx, req.Env) + + e.logger.Info("launching new agent with model override", + zap.String("task_id", task.ID), + zap.String("session_id", sessionID), + zap.String("model", newModel), + zap.String("acp_session_id", acpSessionID)) + + // Launch the new agent + resp, err := e.agentManager.LaunchAgent(ctx, req) + if err != nil { + e.logger.Error("failed to launch agent with new model", + zap.String("task_id", task.ID), + zap.String("session_id", sessionID), + zap.Error(err)) + return nil, fmt.Errorf("failed to launch agent with new model: %w", err) + } + + // Update the session with the new execution ID + session.AgentExecutionID = resp.AgentExecutionID + session.ContainerID = resp.ContainerID + session.State = models.TaskSessionStateStarting + session.UpdatedAt = time.Now().UTC() + + // Update the agent profile snapshot with the new model + if session.AgentProfileSnapshot == nil { + session.AgentProfileSnapshot = make(map[string]interface{}) + } + session.AgentProfileSnapshot["model"] = newModel + + if err := e.repo.UpdateTaskSession(ctx, session); err != nil { + e.logger.Error("failed to update session after model switch", + zap.String("task_id", task.ID), + zap.String("session_id", sessionID), + zap.Error(err)) + } + + // Update executor running state + if running, err := e.repo.GetExecutorRunningBySessionID(ctx, sessionID); err == nil && running != nil { + running.AgentExecutionID = resp.AgentExecutionID + running.ContainerID = resp.ContainerID + running.Status = "starting" + if err := e.repo.UpsertExecutorRunning(ctx, running); err != nil { + e.logger.Warn("failed to update executor running after model switch", + zap.String("task_id", task.ID), + zap.String("session_id", sessionID), + zap.Error(err)) + } + } + + // Start the agent process (this also handles initialization and prompting) + if err := e.agentManager.StartAgentProcess(ctx, resp.AgentExecutionID); err != nil { + e.logger.Error("failed to start agent process after model switch", + zap.String("task_id", task.ID), + zap.String("agent_execution_id", resp.AgentExecutionID), + zap.Error(err)) + return nil, fmt.Errorf("failed to start agent after model switch: %w", err) + } + + e.logger.Info("model switch complete, agent started", + zap.String("task_id", task.ID), + zap.String("session_id", sessionID), + zap.String("new_model", newModel), + zap.String("agent_execution_id", resp.AgentExecutionID)) + + // The agent initialization and prompt are handled as part of StartAgentProcess + // Return success - the actual prompt response will come via ACP events + return &PromptResult{ + StopReason: "model_switched", + AgentMessage: "", + }, nil +} + // RespondToPermission sends a response to a permission request for a session func (e *Executor) RespondToPermission(ctx context.Context, sessionID, pendingID, optionID string, cancelled bool) error { e.logger.Info("responding to permission request", @@ -1086,3 +1274,31 @@ func (m *MockAgentManagerClient) IsAgentRunningForSession(ctx context.Context, s func (m *MockAgentManagerClient) CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error { return nil } + +// GetAgentType mocks getting an agent type configuration +func (m *MockAgentManagerClient) GetAgentType(ctx context.Context, agentID string) (*v1.AgentType, error) { + m.logger.Info("mock: getting agent type", + zap.String("agent_id", agentID)) + + return &v1.AgentType{ + ID: agentID, + Name: "Mock Agent", + Description: "Mock agent type for testing", + Enabled: true, + }, nil +} + +// ResolveAgentProfile mocks resolving an agent profile +func (m *MockAgentManagerClient) ResolveAgentProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) { + m.logger.Info("mock: resolving agent profile", + zap.String("profile_id", profileID)) + + return &AgentProfileInfo{ + ProfileID: profileID, + ProfileName: "Mock Profile", + AgentID: "mock-agent", + AgentName: "mock", + Model: "mock-model", + AutoApprove: false, + }, nil +} diff --git a/apps/backend/internal/orchestrator/handlers/handlers.go b/apps/backend/internal/orchestrator/handlers/handlers.go index 498e46fc5f..7374647427 100644 --- a/apps/backend/internal/orchestrator/handlers/handlers.go +++ b/apps/backend/internal/orchestrator/handlers/handlers.go @@ -176,6 +176,7 @@ type wsPromptTaskRequest struct { TaskID string `json:"task_id"` TaskSessionID string `json:"session_id"` Prompt string `json:"prompt"` + Model string `json:"model,omitempty"` } func (h *Handlers) wsPromptTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -197,6 +198,7 @@ func (h *Handlers) wsPromptTask(ctx context.Context, msg *ws.Message) (*ws.Messa TaskID: req.TaskID, TaskSessionID: req.TaskSessionID, Prompt: req.Prompt, + Model: req.Model, }) if err != nil { h.logger.Error("failed to send prompt", zap.String("task_id", req.TaskID), zap.Error(err)) diff --git a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go index 4471ea70c5..84be87b547 100644 --- a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go +++ b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go @@ -86,6 +86,24 @@ func (m *mockAgentManager) CancelAgent(ctx context.Context, sessionID string) er return nil } +func (m *mockAgentManager) GetAgentType(ctx context.Context, agentID string) (*v1.AgentType, error) { + return &v1.AgentType{ + ID: agentID, + Name: "Mock Agent", + Enabled: true, + }, nil +} + +func (m *mockAgentManager) ResolveAgentProfile(ctx context.Context, profileID string) (*executor.AgentProfileInfo, error) { + return &executor.AgentProfileInfo{ + ProfileID: profileID, + ProfileName: "Mock Profile", + AgentID: "mock-agent", + AgentName: "Mock Agent", + Model: "mock-model", + }, nil +} + // testTaskRepository is an in-memory task repository for testing type testTaskRepository struct { tasks map[string]*v1.Task diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 26c26e9d88..b59b2d3010 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -621,7 +621,7 @@ type PromptResult struct { } // PromptTask sends a follow-up prompt to a running agent for a task session. -func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prompt string) (*PromptResult, error) { +func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prompt string, model string) (*PromptResult, error) { s.logger.Info("sending prompt to task agent", zap.String("task_id", taskID), zap.String("session_id", sessionID), @@ -629,6 +629,38 @@ func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prom if sessionID == "" { return nil, fmt.Errorf("session_id is required") } + + // Check if model switching is requested + if model != "" { + // Get the current session to check current model + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("failed to get session for model switch: %w", err) + } + + // Get current model from agent profile snapshot + if session.AgentProfileSnapshot != nil { + if currentModel, ok := session.AgentProfileSnapshot["model"].(string); ok && currentModel != model { + // Model switch needed - delegate to executor + s.logger.Info("model switch requested", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("current_model", currentModel), + zap.String("new_model", model)) + + // SwitchModel will stop agent, rebuild with new model, restart, and send prompt + switchResult, err := s.executor.SwitchModel(ctx, taskID, sessionID, model, prompt) + if err != nil { + return nil, err + } + return &PromptResult{ + StopReason: switchResult.StopReason, + AgentMessage: switchResult.AgentMessage, + }, nil + } + } + } + s.setSessionRunning(ctx, taskID, sessionID, nil) result, err := s.executor.Prompt(ctx, taskID, sessionID, prompt) if err != nil { @@ -1140,10 +1172,11 @@ func (s *Service) updateTaskSessionState(ctx context.Context, taskID, sessionID zap.String("new_state", string(nextState))) if s.eventBus != nil { _ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent(events.TaskSessionStateChanged, "task-session", map[string]interface{}{ - "task_id": taskID, - "session_id": sessionID, - "old_state": string(oldState), - "new_state": string(nextState), + "task_id": taskID, + "session_id": sessionID, + "old_state": string(oldState), + "new_state": string(nextState), + "agent_profile_id": session.AgentProfileID, })) } } @@ -1293,9 +1326,10 @@ func (s *Service) handleContextWindowUpdated(ctx context.Context, data watcher.C events.TaskSessionStateChanged, "orchestrator", map[string]interface{}{ - "task_id": data.TaskID, - "session_id": session.ID, - "new_state": string(session.State), + "task_id": data.TaskID, + "session_id": session.ID, + "new_state": string(session.State), + "agent_profile_id": session.AgentProfileID, "metadata": map[string]interface{}{ "context_window": contextWindowData, }, diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go index ff09f8d077..cbd63f766b 100644 --- a/apps/backend/internal/task/handlers/message_handlers.go +++ b/apps/backend/internal/task/handlers/message_handlers.go @@ -27,7 +27,7 @@ type PromptResult struct { // OrchestratorService defines the interface for orchestrator operations type OrchestratorService interface { - PromptTask(ctx context.Context, taskID, sessionID, prompt string) (*PromptResult, error) + PromptTask(ctx context.Context, taskID, sessionID, prompt, model string) (*PromptResult, error) ResumeTaskSession(ctx context.Context, taskID, taskSessionID string) error } @@ -123,6 +123,7 @@ type wsAddMessageRequest struct { TaskSessionID string `json:"session_id"` Content string `json:"content"` AuthorID string `json:"author_id,omitempty"` + Model string `json:"model,omitempty"` } func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -184,9 +185,10 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w taskID := req.TaskID sessionID := req.TaskSessionID content := req.Content + model := req.Model go func() { promptCtx := context.WithoutCancel(ctx) - _, err := h.orchestrator.PromptTask(promptCtx, taskID, sessionID, content) + _, err := h.orchestrator.PromptTask(promptCtx, taskID, sessionID, content, model) if err != nil { if errors.Is(err, executor.ErrExecutionNotFound) { if resumeErr := h.orchestrator.ResumeTaskSession(promptCtx, taskID, sessionID); resumeErr != nil { @@ -197,7 +199,7 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w } else { for attempt := 0; attempt < 3; attempt++ { time.Sleep(500 * time.Millisecond) - _, err = h.orchestrator.PromptTask(promptCtx, taskID, sessionID, content) + _, err = h.orchestrator.PromptTask(promptCtx, taskID, sessionID, content, model) if err == nil { break } diff --git a/apps/web/app/s/[sessionId]/page.tsx b/apps/web/app/s/[sessionId]/page.tsx index f08f85e020..4f269ef3bb 100644 --- a/apps/web/app/s/[sessionId]/page.tsx +++ b/apps/web/app/s/[sessionId]/page.tsx @@ -4,6 +4,7 @@ import { fetchTaskSession, fetchTask, listAgents, + listAvailableAgents, listRepositories, listTaskSessionMessages, listTaskSessions, @@ -32,13 +33,15 @@ export default async function SessionPage({ } task = await fetchTask(session.task_id, { cache: 'no-store' }); - const [snapshot, agents, repositories, allSessionsResponse] = await Promise.all([ + const [snapshot, agents, repositories, allSessionsResponse, availableAgentsResponse] = await Promise.all([ fetchBoardSnapshot(task.board_id, { cache: 'no-store' }), listAgents({ cache: 'no-store' }), listRepositories(task.workspace_id, { cache: 'no-store' }), listTaskSessions(session.task_id, { cache: 'no-store' }), + listAvailableAgents({ cache: 'no-store' }).catch(() => ({ agents: [] })), ]); const allSessions = allSessionsResponse.sessions ?? [session]; + const availableAgents = availableAgentsResponse.agents ?? []; let messagesResponse: ListMessagesResponse | null = null; try { messagesResponse = await listTaskSessionMessages( @@ -129,6 +132,11 @@ export default async function SessionPage({ executorsLoaded: false, environmentsLoaded: false, }, + availableAgents: { + items: availableAgents, + loaded: true, + loading: false, + }, }; } catch (error) { console.warn( diff --git a/apps/web/app/settings/agents/[agentId]/page.tsx b/apps/web/app/settings/agents/[agentId]/page.tsx index 0300a074dc..138d960add 100644 --- a/apps/web/app/settings/agents/[agentId]/page.tsx +++ b/apps/web/app/settings/agents/[agentId]/page.tsx @@ -59,12 +59,12 @@ type AgentSetupFormProps = { onToastError: (error: unknown) => void; }; -const createDraftProfile = (agentId: string, agentDisplayName: string): DraftProfile => ({ +const createDraftProfile = (agentId: string, agentDisplayName: string, defaultModel: string): DraftProfile => ({ id: `draft-${generateUUID()}`, agent_id: agentId, name: '', agent_display_name: agentDisplayName, - model: '', + model: defaultModel, auto_approve: false, dangerously_skip_permissions: false, plan: '', @@ -81,13 +81,13 @@ const cloneAgent = (agent: Agent): DraftAgent => ({ profiles: agent.profiles.map((profile) => ({ ...profile })) as DraftProfile[], }); -const ensureProfiles = (agent: DraftAgent, agentDisplayName: string): DraftAgent => { +const ensureProfiles = (agent: DraftAgent, agentDisplayName: string, defaultModel: string): DraftAgent => { if (agent.profiles.length > 0) { return agent; } return { ...agent, - profiles: [createDraftProfile(agent.id, agentDisplayName)], + profiles: [createDraftProfile(agent.id, agentDisplayName, defaultModel)], }; }; @@ -169,7 +169,7 @@ function AgentSetupForm({ ...current, profiles: [ ...current.profiles, - { ...createDraftProfile(current.id, resolveDisplayName(current.name)), id: draftId }, + { ...createDraftProfile(current.id, resolveDisplayName(current.name), currentAgentModelConfig.default_model), id: draftId }, ], })); setNewProfileId(draftId); @@ -183,7 +183,7 @@ function AgentSetupForm({ profiles: remaining.length > 0 ? remaining - : [createDraftProfile(current.id, resolveDisplayName(current.name))], + : [createDraftProfile(current.id, resolveDisplayName(current.name), currentAgentModelConfig.default_model)], }; }); if (newProfileId === profileId) { @@ -244,6 +244,10 @@ function AgentSetupForm({ onToastError(new Error('Profile name is required.')); return; } + if (draftAgent.profiles.some((profile) => !profile.model.trim())) { + onToastError(new Error('Model is required for all profiles.')); + return; + } if (hasInvalidMcpConfig) { onToastError(new Error('Fix invalid MCP JSON before saving.')); return; @@ -312,7 +316,7 @@ function AgentSetupForm({ }); } upsertAgent(created); - setDraftAgent(ensureProfiles(cloneAgent(created), resolveDisplayName(created.name))); + setDraftAgent(ensureProfiles(cloneAgent(created), resolveDisplayName(created.name), currentAgentModelConfig.default_model)); router.replace(`/settings/agents/${encodeURIComponent(created.name)}`); } else { const agentPatch: { workspace_id?: string | null; mcp_config_path?: string | null } = {}; @@ -379,7 +383,7 @@ function AgentSetupForm({ profiles: nextProfiles, }; upsertAgent(nextAgent); - setDraftAgent(ensureProfiles(cloneAgent(nextAgent), resolveDisplayName(nextAgent.name))); + setDraftAgent(ensureProfiles(cloneAgent(nextAgent), resolveDisplayName(nextAgent.name), currentAgentModelConfig.default_model)); } setSaveStatus('success'); } catch (error) { @@ -568,8 +572,10 @@ export default function AgentSetupPage() { if (!decodedKey) return null; const resolveDisplayName = (name: string) => availableAgents.find((item) => item.name === name)?.display_name ?? ''; + const resolveDefaultModel = (name: string) => + availableAgents.find((item) => item.name === name)?.model_config?.default_model ?? ''; if (savedAgent) { - return ensureProfiles(cloneAgent(savedAgent), resolveDisplayName(savedAgent.name)); + return ensureProfiles(cloneAgent(savedAgent), resolveDisplayName(savedAgent.name), resolveDefaultModel(savedAgent.name)); } if (discoveryAgent) { const draft: DraftAgent = { @@ -583,7 +589,7 @@ export default function AgentSetupPage() { updated_at: new Date().toISOString(), isNew: true, }; - return ensureProfiles(draft, resolveDisplayName(draft.name)); + return ensureProfiles(draft, resolveDisplayName(draft.name), resolveDefaultModel(draft.name)); } return null; }, [decodedKey, discoveryAgent, savedAgent, availableAgents]); diff --git a/apps/web/app/task/[id]/page-client.tsx b/apps/web/app/task/[id]/page-client.tsx index 89d22d29d6..3cd8b0c64a 100644 --- a/apps/web/app/task/[id]/page-client.tsx +++ b/apps/web/app/task/[id]/page-client.tsx @@ -172,6 +172,7 @@ export default function TaskPage({ diff --git a/apps/web/components/settings/agent-profile-page.tsx b/apps/web/components/settings/agent-profile-page.tsx index cf7f9be058..6993294de8 100644 --- a/apps/web/components/settings/agent-profile-page.tsx +++ b/apps/web/components/settings/agent-profile-page.tsx @@ -69,6 +69,14 @@ function ProfileEditor({ agent, profile, modelConfig, initialMcpConfig }: Profil }); return; } + if (!draft.model.trim()) { + toast({ + title: 'Model is required', + description: 'Please select a model before saving.', + variant: 'error', + }); + return; + } setSaveStatus('loading'); try { const updated = await updateAgentProfileAction(draft.id, { diff --git a/apps/web/components/task/model-selector.tsx b/apps/web/components/task/model-selector.tsx new file mode 100644 index 0000000000..803b56c34e --- /dev/null +++ b/apps/web/components/task/model-selector.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { IconCpu } from '@tabler/icons-react'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select'; +import { useAppStore } from '@/components/state-provider'; +import { useAvailableAgents } from '@/hooks/use-available-agents'; +import { useSettingsData } from '@/hooks/use-settings-data'; +import { cn } from '@/lib/utils'; + +type ModelSelectorProps = { + sessionId: string | null; +}; + +export function ModelSelector({ sessionId }: ModelSelectorProps) { + // Ensure settings data (agents with profiles) is loaded + useSettingsData(true); + + const settingsAgents = useAppStore((state) => state.settingsAgents.items); + const taskSessions = useAppStore((state) => state.taskSessions.items); + const pendingModels = useAppStore((state) => state.pendingModel.bySessionId); + const activeModels = useAppStore((state) => state.activeModel.bySessionId); + const setPendingModel = useAppStore((state) => state.setPendingModel); + + // Ensure available agents are loaded (contains model_config) + // Note: We don't block on this loading - it only affects the dropdown options + const { items: availableAgents } = useAvailableAgents(); + + // Find the session from taskSessions + const session = sessionId ? (taskSessions[sessionId] ?? null) : null; + + // Get model from agent_profile_snapshot (guaranteed to be non-empty since model is required) + const snapshotModel = + session?.agent_profile_snapshot && typeof session.agent_profile_snapshot === 'object' + ? (session.agent_profile_snapshot as Record).model + : null; + const snapshotModelStr = typeof snapshotModel === 'string' && snapshotModel ? snapshotModel : null; + + // Resolve the agent profile from settings (for getting available models list) + let sessionProfile: { profile: (typeof settingsAgents)[0]['profiles'][0]; agent: (typeof settingsAgents)[0] } | null = null; + if (session?.agent_profile_id) { + for (const agent of settingsAgents) { + const profile = agent.profiles.find((p) => p.id === session.agent_profile_id); + if (profile) { + sessionProfile = { profile, agent }; + break; + } + } + } + + // Get available models from the agent discovery data + let availableModels: { id: string; name: string; provider: string; context_window: number; is_default: boolean }[] = []; + if (sessionProfile?.agent) { + const agentName = sessionProfile.agent.name; + const availableAgent = availableAgents.find((a) => a.name === agentName); + if (availableAgent?.model_config?.available_models) { + availableModels = availableAgent.model_config.available_models; + } + } + + // Priority: activeModel (after switch) > snapshot model + // Profile model fallback removed - all profiles now have models + const activeModel = sessionId ? (activeModels[sessionId] || null) : null; + const currentModel = activeModel || snapshotModelStr; + const pendingModel = sessionId ? (pendingModels[sessionId] || null) : null; + const displayedModel = pendingModel || currentModel || ''; + const hasPendingChange = pendingModel && pendingModel !== currentModel; + + // Ensure current model is included in the list (even if not in available models) + const modelOptions = [...availableModels]; + if (currentModel && !modelOptions.some((m) => m.id === currentModel)) { + modelOptions.unshift({ + id: currentModel, + name: currentModel, + provider: 'unknown', + context_window: 0, + is_default: false, + }); + } + + const handleModelChange = (modelId: string) => { + if (!sessionId) return; + setPendingModel(sessionId, modelId); + }; + + // If no session or no model available, show disabled placeholder + if (!sessionId || !currentModel) { + return ( + + ); + } + + return ( + + ); +} + diff --git a/apps/web/components/task/task-center-panel.tsx b/apps/web/components/task/task-center-panel.tsx index 37c3a5e2ee..bdba7818e7 100644 --- a/apps/web/components/task/task-center-panel.tsx +++ b/apps/web/components/task/task-center-panel.tsx @@ -21,6 +21,7 @@ type TaskCenterPanelProps = { openFileRequest: OpenFileTab | null; onDiffPathHandled: () => void; onFileOpenHandled: () => void; + sessionId?: string | null; }; export const TaskCenterPanel = memo(function TaskCenterPanel({ @@ -28,6 +29,7 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({ openFileRequest, onDiffPathHandled, onFileOpenHandled, + sessionId = null, }: TaskCenterPanelProps) { const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const [leftTab, setLeftTab] = useState('chat'); @@ -152,7 +154,7 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({ {activeTaskId ? ( - + ) : (
No task selected diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index 0fe686e9bd..80a977113d 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -3,7 +3,7 @@ import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { IconBrain, IconCpu, IconListCheck, IconLoader2, IconPaperclip } from '@tabler/icons-react'; +import { IconBrain, IconListCheck, IconLoader2, IconPaperclip } from '@tabler/icons-react'; import { Button } from '@kandev/ui/button'; import { DropdownMenu, @@ -11,7 +11,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@kandev/ui/dropdown-menu'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select'; + import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/components/state-provider'; @@ -31,25 +31,20 @@ import { TokenUsageDisplay } from '@/components/task/chat/token-usage-display'; import { TodoSummary } from '@/components/task/chat/todo-summary'; import { SessionsDropdown } from '@/components/task/sessions-dropdown'; import { useSessionContextWindow } from '@/hooks/use-session-context-window'; - -type AgentOption = { - id: string; - label: string; -}; +import { ModelSelector } from '@/components/task/model-selector'; type TaskChatPanelProps = { - agents: AgentOption[]; + /** @deprecated No longer used - model selection is now handled by ModelSelector */ + agents?: { id: string; label: string }[]; onSend?: (message: string) => void; sessionId?: string | null; }; export function TaskChatPanel({ - agents, onSend, sessionId = null, }: TaskChatPanelProps) { const [messageInput, setMessageInput] = useState(''); - const [selectedAgent, setSelectedAgent] = useState(agents[0]?.id ?? ''); const [planModeEnabled, setPlanModeEnabled] = useState(false); const [isSending, setIsSending] = useState(false); const messagesContainerRef = useRef(null); @@ -84,6 +79,12 @@ export function TaskChatPanel({ const sessionModel = sessionProfile?.model ?? null; + // Get pending model state for this session + const pendingModels = useAppStore((state) => state.pendingModel.bySessionId); + const clearPendingModel = useAppStore((state) => state.clearPendingModel); + const setActiveModel = useAppStore((state) => state.setActiveModel); + const pendingModel = resolvedSessionId ? pendingModels[resolvedSessionId] : null; + // Fetch context window usage for this session const contextWindow = useSessionContextWindow(resolvedSessionId); @@ -248,12 +249,27 @@ export function TaskChatPanel({ } const client = getWebSocketClient(); if (!client) return; + + // Include pending model in the request if one is selected + const modelToSend = pendingModel && pendingModel !== sessionModel ? pendingModel : undefined; + await client.request( 'message.add', - { task_id: taskId, session_id: resolvedSessionId, content: message }, + { + task_id: taskId, + session_id: resolvedSessionId, + content: message, + ...(modelToSend && { model: modelToSend }), + }, 10000 ); - }, [resolvedSessionId, taskId]); + + // Move pending model to active model after sending (it's now the model in use) + if (modelToSend && resolvedSessionId) { + setActiveModel(resolvedSessionId, modelToSend); + clearPendingModel(resolvedSessionId); + } + }, [resolvedSessionId, taskId, pendingModel, sessionModel, setActiveModel, clearPendingModel]); // Cancels the current agent turn without terminating the agent process, // allowing the user to interrupt and send a new prompt. @@ -341,17 +357,10 @@ export function TaskChatPanel({ )}
- {/* Session info - shows agent state and model */} + {/* Session info - shows agent state */} {session?.state && (
- {/* Model indicator */} - {sessionModel && ( -
- - {sessionModel} -
- )}
)} @@ -372,18 +381,7 @@ export function TaskChatPanel({ />
- + diff --git a/apps/web/components/task/task-layout.tsx b/apps/web/components/task/task-layout.tsx index bf3208b437..c8cbbcc36d 100644 --- a/apps/web/components/task/task-layout.tsx +++ b/apps/web/components/task/task-layout.tsx @@ -14,11 +14,13 @@ const DEFAULT_HORIZONTAL_LAYOUT: [number, number, number] = [18, 57, 25]; type TaskLayoutProps = { workspaceId: string | null; boardId: string | null; + sessionId?: string | null; }; export const TaskLayout = memo(function TaskLayout({ workspaceId, boardId, + sessionId = null, }: TaskLayoutProps) { const [horizontalLayout, setHorizontalLayout] = useState<[number, number, number]>( getLocalStorage('task-layout-horizontal-v2', DEFAULT_HORIZONTAL_LAYOUT) @@ -64,6 +66,7 @@ export const TaskLayout = memo(function TaskLayout({ openFileRequest={openFileRequest} onDiffPathHandled={() => setSelectedDiffPath(null)} onFileOpenHandled={() => setOpenFileRequest(null)} + sessionId={sessionId} /> diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 9aae81a379..bbdc3a2f1b 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -259,6 +259,14 @@ export type SessionAgentctlState = { itemsBySessionId: Record; }; +export type PendingModelState = { + bySessionId: Record; +}; + +export type ActiveModelState = { + bySessionId: Record; +}; + export type AppState = { kanban: KanbanState; workspaces: WorkspaceState; @@ -290,6 +298,8 @@ export type AppState = { sessionAgentctl: SessionAgentctlState; worktrees: WorktreesState; sessionWorktreesBySessionId: SessionWorktreesState; + pendingModel: PendingModelState; + activeModel: ActiveModelState; hydrate: (state: Partial) => void; setActiveWorkspace: (workspaceId: string | null) => void; setWorkspaces: (workspaces: WorkspaceState['items']) => void; @@ -352,6 +362,9 @@ export type AppState = { clearGitStatus: (sessionId: string) => void; setContextWindow: (sessionId: string, contextWindow: ContextWindowEntry) => void; bumpAgentProfilesVersion: () => void; + setPendingModel: (sessionId: string, modelId: string) => void; + clearPendingModel: (sessionId: string) => void; + setActiveModel: (sessionId: string, modelId: string) => void; }; export type AppStore = ReturnType; @@ -402,6 +415,8 @@ const defaultState: AppState = { sessionAgentctl: { itemsBySessionId: {} }, worktrees: { items: {} }, sessionWorktreesBySessionId: { itemsBySessionId: {} }, + pendingModel: { bySessionId: {} }, + activeModel: { bySessionId: {} }, hydrate: () => undefined, setActiveWorkspace: () => undefined, setWorkspaces: () => undefined, @@ -450,6 +465,9 @@ const defaultState: AppState = { clearGitStatus: () => undefined, setContextWindow: () => undefined, bumpAgentProfilesVersion: () => undefined, + setPendingModel: () => undefined, + clearPendingModel: () => undefined, + setActiveModel: () => undefined, }; function mergeInitialState( @@ -504,6 +522,9 @@ function mergeInitialState( | 'clearGitStatus' | 'setContextWindow' | 'bumpAgentProfilesVersion' + | 'setPendingModel' + | 'clearPendingModel' + | 'setActiveModel' > { if (!initialState) return defaultState; return { @@ -543,6 +564,8 @@ function mergeInitialState( ...defaultState.sessionWorktreesBySessionId, ...initialState.sessionWorktreesBySessionId, }, + pendingModel: { ...defaultState.pendingModel, ...initialState.pendingModel }, + activeModel: { ...defaultState.activeModel, ...initialState.activeModel }, }; } @@ -842,7 +865,11 @@ export function createAppStore(initialState?: Partial) { }), setTaskSession: (session) => set((draft) => { - draft.taskSessions.items[session.id] = session; + // Merge with existing session data to preserve fields like agent_profile_id + const existingSession = draft.taskSessions.items[session.id]; + draft.taskSessions.items[session.id] = existingSession + ? { ...existingSession, ...session } + : session; if (session.worktree_id) { draft.worktrees.items[session.worktree_id] = { id: session.worktree_id, @@ -909,6 +936,18 @@ export function createAppStore(initialState?: Partial) { set((draft) => { draft.agentProfiles.version += 1; }), + setPendingModel: (sessionId, modelId) => + set((draft) => { + draft.pendingModel.bySessionId[sessionId] = modelId; + }), + clearPendingModel: (sessionId) => + set((draft) => { + delete draft.pendingModel.bySessionId[sessionId]; + }), + setActiveModel: (sessionId, modelId) => + set((draft) => { + draft.activeModel.bySessionId[sessionId] = modelId; + }), })) ); } diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index 92c88d0ae7..4a07c8e6f4 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -155,6 +155,7 @@ export type TaskSessionStateChangedPayload = { session_id: string; old_state?: string; new_state: string; + agent_profile_id?: string; metadata?: Record; }; diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts index a4d23cd0f5..2e9426c284 100644 --- a/apps/web/lib/ws/handlers/agent-session.ts +++ b/apps/web/lib/ws/handlers/agent-session.ts @@ -18,13 +18,15 @@ export function registerTaskSessionHandlers(store: StoreApi): WsHandle if (sessionId) { const existingSession = store.getState().taskSessions.items[sessionId]; if (existingSession) { - // Update existing session + // Update existing session - preserve all existing fields store.getState().setTaskSession({ ...existingSession, state: newState, }); } else { // Create partial session object + // Include agent_profile_id from the payload if provided + const agentProfileId = payload.agent_profile_id; store.getState().setTaskSession({ id: sessionId, task_id: taskId, @@ -32,6 +34,7 @@ export function registerTaskSessionHandlers(store: StoreApi): WsHandle progress: 0, started_at: '', updated_at: '', + ...(agentProfileId ? { agent_profile_id: agentProfileId } : {}), }); } @@ -39,6 +42,7 @@ export function registerTaskSessionHandlers(store: StoreApi): WsHandle if (sessionsByTask) { const hasSession = sessionsByTask.some((session) => session.id === sessionId); if (!hasSession) { + const agentProfileId = payload.agent_profile_id; store.getState().setTaskSessionsForTask(taskId, [...sessionsByTask, { id: sessionId, task_id: taskId, @@ -46,6 +50,7 @@ export function registerTaskSessionHandlers(store: StoreApi): WsHandle progress: 0, started_at: '', updated_at: '', + ...(agentProfileId ? { agent_profile_id: agentProfileId } : {}), }]); } else { const nextSessions = sessionsByTask.map((session) => diff --git a/docs/design/dynamic-model-switching.md b/docs/design/dynamic-model-switching.md new file mode 100644 index 0000000000..6ac1f1edde --- /dev/null +++ b/docs/design/dynamic-model-switching.md @@ -0,0 +1,478 @@ +# Dynamic Model Switching Feature Analysis + +> **Status**: Design Document +> **Created**: 2026-01-21 +> **Scope**: Mid-session model switching within the Kandev application + +## Executive Summary + +This document analyzes the implementation of dynamic model switching, allowing users to change the AI model mid-session without starting a new session. The feature requires UI changes to expose model selection, backend logic to detect model changes and restart agents, and careful handling of session continuity. + +## Current Architecture Overview + +### Session Lifecycle (Current Flow) + +``` +User clicks "Start Session" + → Frontend sends orchestrator.start with agentProfileId + → Orchestrator.StartTask → Executor.ExecuteWithProfile + → Lifecycle Manager resolves profile → gets model from AgentProfileInfo + → CommandBuilder.BuildCommand adds --model flag + → Agent process started with fixed model + → ACP session initialized + → User sends prompts via session/prompt +``` + +**Key Insight**: Model is determined at session start via `AgentProfileInfo.Model` and baked into the agent command. There's no current mechanism to change the model mid-session. + +### Relevant Components + +| Component | Path | Role | +|-----------|------|------| +| TaskChatPanel | `apps/web/components/task/task-chat-panel.tsx` | Chat UI, displays current model | +| SessionsDropdown | `apps/web/components/task/sessions-dropdown.tsx` | Session switcher | +| Orchestrator Service | `apps/backend/internal/orchestrator/service.go` | PromptTask, StartTask, ResumeTaskSession | +| Executor | `apps/backend/internal/orchestrator/executor/executor.go` | Agent launch, prompt forwarding | +| Lifecycle Manager | `apps/backend/internal/agent/lifecycle/manager.go` | Agent process management | +| CommandBuilder | `apps/backend/internal/agent/lifecycle/command.go` | Builds CLI args including --model | +| ProfileResolver | `apps/backend/internal/agent/lifecycle/profile_resolver.go` | Resolves profile → model | +| SessionManager | `apps/backend/internal/agent/lifecycle/session.go` | ACP session init/prompt | +| TaskSession Model | `apps/backend/internal/task/models/models.go` | Session persistence | +| Message Service | `apps/backend/internal/task/service/service.go` | CreateMessage | + +--- + +## Requirements Breakdown + +### UI Requirements + +1. **Model Selector in Session View**: Replace existing agent selector with model dropdown +2. **Deferred Application**: Selection does NOT take effect immediately +3. **Apply on Prompt**: Selected model applied when user sends next prompt +4. **Persistent State**: All subsequent prompts use new model until changed again + +### Backend Requirements + +1. **Model Change Detection**: Compare current model vs requested model on prompt +2. **System Message Insertion**: "Model switching to [model name]" message +3. **Agent Restart**: Stop current agent, restart with new `--model` flag +4. **Session Resumption**: Attempt to resume ACP session after restart (if supported) + +--- + +## Technical Approach + +### Phase 1: Frontend Model Selector + +#### 1.1 Store State for Pending Model + +Add to the Zustand store a per-session "pending model" that tracks what model the user wants to switch to. + +**Files to modify:** +- `apps/web/lib/state/store.ts` - Add `pendingModelBySessionId: Record` +- `apps/web/lib/types/http.ts` - Add model-related types if needed + +#### 1.2 Model Selector Component + +Create a model dropdown in the chat panel that: +- Shows available models for the current agent type +- Shows current model (from session's agent profile) +- Allows selection of a different model +- Stores selection in `pendingModelBySessionId[sessionId]` + +**Files to modify:** +- `apps/web/components/task/task-chat-panel.tsx` - Add ModelSelector component +- May create new component: `apps/web/components/task/model-selector.tsx` + +**Data source for available models:** +- From `AvailableAgent.model_config.available_models` in the agent discovery store +- Need to resolve which agent the session is using via `session.agent_profile_id` + +#### 1.3 Prompt Submission with Model Override + +When user submits a prompt, check if `pendingModelBySessionId[sessionId]` differs from current model: +- If different: include `model` field in WebSocket prompt payload +- Clear pending model after submission (backend will handle the switch) + +**WebSocket payload change:** + +```typescript +// Current +{ action: "orchestrator.prompt", payload: { taskId, sessionId, content } } + +// Proposed +{ action: "orchestrator.prompt", payload: { taskId, sessionId, content, model?: string } } +``` + +**Files to modify:** +- `apps/web/lib/ws/actions.ts` - Add `model` to prompt action payload +- `apps/web/components/task/task-chat-panel.tsx` - Include model in prompt submission + +--- + +### Phase 2: Backend Model Change Detection + +#### 2.1 Extend Prompt Handling + +Modify the orchestrator's `PromptTask` to accept an optional model parameter. + +**Files to modify:** +- `apps/backend/internal/orchestrator/service.go` - `PromptTask` method +- `apps/backend/internal/ws/handlers/orchestrator.go` - Parse model from WS payload + +**Logic flow:** +``` +PromptTask(taskId, sessionId, content, model) + → If model != currentExecution.Model: + → Insert system message "Switching to {model}..." + → Stop current agent (graceful shutdown) + → Update session's effective model + → Restart agent with new --model flag + → Resume or reinitialize ACP session + → Forward prompt to agent +``` + +#### 2.2 Executor Model Switch + +The executor needs a new method to handle model switching: + +**Files to modify:** +- `apps/backend/internal/orchestrator/executor/executor.go` + - Add `SwitchModel(sessionId, newModel)` method + - Coordinates stop → rebuild → restart → resume + +**Implementation sketch:** +```go +func (e *Executor) SwitchModel(ctx context.Context, sessionId, newModel string) error { + exec, ok := e.running[sessionId] + if !ok { + return ErrSessionNotFound + } + + // 1. Stop current agent + e.lifecycleManager.StopAgent(ctx, exec.AgentID) + + // 2. Update profile with new model + newProfileInfo := exec.ProfileInfo + newProfileInfo.Model = newModel + + // 3. Restart agent with new command + newAgentID, err := e.lifecycleManager.Launch(ctx, LaunchOpts{ + Model: newModel, + // ... other opts from exec + }) + + // 4. Attempt session resume + e.lifecycleManager.InitializeSession(ctx, newAgentID, InitOpts{ + IsResume: true, + ResumeToken: exec.ResumeToken, + }) + + return nil +} +``` + +#### 2.3 System Message for Model Switch + +Insert a visible message in the chat indicating the model change. + +**Files to modify:** +- `apps/backend/internal/task/service/service.go` - Use `CreateMessage` with type `status` + +**Message format:** +```json +{ + "type": "status", + "content": "Switching model to Claude Opus 4.5...", + "metadata": { + "status_type": "model_switch", + "from_model": "sonnet4.5", + "to_model": "opus4.5" + } +} +``` + +--- + +### Phase 3: Session Resumption After Restart + +#### 3.1 Agent-Specific Resumption + +Different agents handle resumption differently (from `agents.json`): + +| Agent | `resume_via_acp` | `resume_flag` | `can_recover` | +|-------|------------------|---------------|---------------| +| auggie-agent | false | `--resume` | false | +| codex-agent | true | (none) | true | + +**For Codex-style agents (resume_via_acp: true):** +- Use ACP `session/load` with the stored `ResumeToken` +- Session history is preserved by the agent + +**For Auggie-style agents (resume_via_acp: false, can_recover: false):** +- Cannot truly resume; must reinitialize with fresh ACP session +- **Decision**: Accept context loss, display system message: "Model switched. A new session was started." + +#### 3.2 Handling Resume Failures + +**Files to modify:** +- `apps/backend/internal/agent/lifecycle/session.go` - `InitializeSession` error handling + +**Fallback strategy:** +1. Attempt resume with stored token +2. If resume fails → start new session +3. Emit warning message to user: "Session could not be fully resumed after model switch" + + +--- + +## Integration Points + +### WebSocket Integration + +| Layer | Change Required | +|-------|-----------------| +| Client (`apps/web/lib/ws/client.ts`) | No changes | +| Actions (`apps/web/lib/ws/actions.ts`) | Add `model` field to prompt action | +| Handler (`apps/backend/internal/ws/handlers/orchestrator.go`) | Parse `model` from payload | + +**Wire format example:** +```json +{ + "id": "uuid", + "type": "request", + "action": "orchestrator.prompt", + "payload": { + "task_id": "task-uuid", + "session_id": "session-uuid", + "content": "User message", + "model": "opus4.5" + } +} +``` + +### Store Integration + +**New store slice additions:** + +```typescript +// In store.ts +pendingModel: { + bySessionId: Record, // sessionId → pending model id + setPendingModel: (sessionId: string, modelId: string) => void, + clearPendingModel: (sessionId: string) => void, + getPendingModel: (sessionId: string) => string | null, +} +``` + +**Store → Component data flow:** +``` +ModelSelector reads: settingsAgents (for available models), session.agent_profile_id +ModelSelector writes: pendingModel.bySessionId[sessionId] +TaskChatPanel reads: pendingModel.bySessionId[sessionId] on prompt submit +``` + +### Database Integration + +**Decision: No schema change (runtime-only)** +- Model switch is runtime-only; session's `agent_profile_id` remains unchanged +- On backend restart, session resumes with original profile model +- No migration required + +--- + +## Challenges and Edge Cases + +### 1. Agent Busy During Model Switch Request + +**Scenario**: User selects new model while agent is processing a previous prompt. + +**Solution options:** +- **Queue the switch**: Store pending model, apply after current response completes +- **Block selection**: Disable model selector while agent is busy +- **Immediate abort**: Cancel current response, switch model (destructive) + +**Recommendation**: Queue the switch; apply on next user-initiated prompt. + +### 2. Rapid Model Changes + +**Scenario**: User changes model multiple times before sending a prompt. + +**Solution**: Only the final selection matters; each change overwrites `pendingModelBySessionId[sessionId]`. + +### 3. Resume Failure After Switch + +**Scenario**: Agent restarts with new model but ACP session/load fails. + +**Handling:** +1. Log error for debugging +2. Fall back to new session initialization +3. Show user message: "Model switched successfully but session context could not be fully restored" + +### 4. Different Agent Types + +**Scenario**: User tries to switch to a model not supported by current agent type. + +**Prevention**: Model selector only shows models from `agent.model_config.available_models` for the session's agent type. + +### 5. Session State Persistence + +**Scenario**: Backend restarts mid-session; what model should resume use? + +**Behavior**: Uses `agent_profile_snapshot.model` from session creation (original profile model). Runtime model switches are not persisted. + +### 6. Model Availability Changes + +**Scenario**: A model is removed from agent configuration while sessions are active. + +**Handling**: Validate model against current `available_models` before sending to backend. If invalid, fall back to session's original model. + +--- + +## Alternative Approaches + +### Alternative A: Runtime Model Switching via ACP + +**Concept**: Send a hypothetical `session/switch_model` ACP call without restarting agent. + +**Pros:** +- No process restart; faster switch +- Seamless context preservation + +**Cons:** +- Not supported by current agent implementations +- Would require agent CLI changes upstream +- Different agents would need different implementations + +**Verdict**: Not viable without significant upstream work. + +### Alternative B: Model as Separate Session Concept + +**Concept**: Treat each model switch as a "sub-session" within the same UI session. + +**Pros:** +- Clean separation of concerns +- Easy to track model history + +**Cons:** +- Complex UI implications +- Breaks current session model significantly +- Overhead for simple model changes + +**Verdict**: Over-engineering for the requirement. + +### Alternative C: Pre-select Model Before Session Start Only + +**Concept**: Allow model selection only at session creation, not mid-session. + +**Pros:** +- Simplest implementation +- No restart complexity + +**Cons:** +- Doesn't meet the stated requirement +- Poor UX for iterative work + +**Verdict**: Does not satisfy user requirements. + +--- + +## Implementation Phases + +### Phase 1: Frontend Model Selector (Est. 2-3 days) + +**Goal**: Model dropdown appears in UI; selection stored locally. + +**Files to modify:** +1. `apps/web/lib/state/store.ts` - Add pendingModel slice +2. `apps/web/components/task/task-chat-panel.tsx` - Add ModelSelector +3. Create `apps/web/components/task/model-selector.tsx` - New component + +**Deliverable**: User can select a model, but it has no effect yet. + +### Phase 2: Backend Prompt Extension (Est. 2-3 days) + +**Goal**: Prompts can include model; backend detects change. + +**Files to modify:** +1. `apps/web/lib/ws/actions.ts` - Add model to prompt payload +2. `apps/backend/internal/ws/handlers/orchestrator.go` - Parse model +3. `apps/backend/internal/orchestrator/service.go` - PromptTask accepts model + +**Deliverable**: Model parameter flows from UI to orchestrator. + +### Phase 3: Agent Restart Logic (Est. 3-4 days) + +**Goal**: Backend restarts agent with new model when detected. + +**Files to modify:** +1. `apps/backend/internal/orchestrator/executor/executor.go` - SwitchModel method +2. `apps/backend/internal/agent/lifecycle/manager.go` - Ensure clean restart +3. `apps/backend/internal/task/service/service.go` - Insert status message + +**Deliverable**: Model switch triggers agent restart; status message appears. + +### Phase 4: Session Resumption (Est. 2-3 days) + +**Goal**: Resume ACP session after agent restart (where supported). + +**Files to modify:** +1. `apps/backend/internal/agent/lifecycle/session.go` - Resume logic +2. `apps/backend/internal/orchestrator/executor/executor.go` - Handle resume failures + +**Deliverable**: Codex-style agents resume context; Auggie-style gracefully degrade. + +### Phase 5: Testing & Edge Cases (Est. 2-3 days) + +**Goal**: Verify all scenarios work correctly. + +**Test scenarios:** +- Model switch while agent idle +- Model switch while agent busy (queued) +- Rapid model changes +- Resume failure fallback +- Invalid model selection +- Backend restart mid-session + +--- + +## Summary of Files to Modify + +| File | Changes | +|------|---------| +| `apps/web/lib/state/store.ts` | Add pendingModel slice | +| `apps/web/components/task/task-chat-panel.tsx` | Integrate ModelSelector, pass model on prompt | +| `apps/web/components/task/model-selector.tsx` | New component | +| `apps/web/lib/ws/actions.ts` | Add model field to prompt action | +| `apps/backend/internal/ws/handlers/orchestrator.go` | Parse model from WS payload | +| `apps/backend/internal/orchestrator/service.go` | Extend PromptTask signature | +| `apps/backend/internal/orchestrator/executor/executor.go` | Add SwitchModel method | +| `apps/backend/internal/agent/lifecycle/manager.go` | Ensure clean restart support | +| `apps/backend/internal/agent/lifecycle/session.go` | Handle resume after restart | +| `apps/backend/internal/task/service/service.go` | Create model switch status message | + +--- + +## Estimated Total Effort + +| Phase | Estimate | +|-------|----------| +| Phase 1: Frontend Model Selector | 2-3 days | +| Phase 2: Backend Prompt Extension | 2-3 days | +| Phase 3: Agent Restart Logic | 3-4 days | +| Phase 4: Session Resumption | 2-3 days | +| Phase 5: Testing & Edge Cases | 2-3 days | +| **Total** | **11-16 days** | + +--- + +## Resolved Decisions + +| Decision | Resolution | +|----------|------------| +| Database schema | Runtime-only, no schema changes | +| Non-recoverable agents (Auggie) | Accept context loss, show "new session started" message | +| Agent busy during switch | Not an issue - model applied on next user prompt | +| UI placement | Replace existing agent selector with model selector | +| Confirmation dialog | None required - seamless switch | +