feat: dynamic model switching and session status fix (#68)

This commit is contained in:
João Salavisa
2026-01-21 17:19:50 +00:00
committed by GitHub
parent c57a82f81d
commit fba868d3fe
28 changed files with 1134 additions and 185 deletions
-108
View File
@@ -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
+30 -2
View File
@@ -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
}
@@ -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:
@@ -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() {
@@ -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
@@ -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
@@ -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
}
@@ -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 '',
@@ -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
// ============================================
@@ -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
}
@@ -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
@@ -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
}
@@ -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))
@@ -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
+42 -8
View File
@@ -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,
},
@@ -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
}
+9 -1
View File
@@ -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(
+16 -10
View File
@@ -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]);
+1
View File
@@ -172,6 +172,7 @@ export default function TaskPage({
<TaskLayout
workspaceId={task?.workspace_id ?? null}
boardId={task?.board_id ?? null}
sessionId={effectiveSessionId}
/>
</div>
</TooltipProvider>
@@ -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, {
+122
View File
@@ -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<string, unknown>).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 (
<Select disabled>
<SelectTrigger className="w-[180px] cursor-not-allowed opacity-50">
<SelectValue placeholder="No model" />
</SelectTrigger>
</Select>
);
}
return (
<Select value={displayedModel} onValueChange={handleModelChange}>
<SelectTrigger
className={cn(
'w-[180px] cursor-pointer',
hasPendingChange && 'border-primary/60 bg-primary/5'
)}
>
<div className="flex items-center gap-1.5">
<IconCpu className="h-3.5 w-3.5 text-muted-foreground" />
<SelectValue placeholder="Select model" />
{hasPendingChange && (
<span className="ml-auto text-[10px] font-medium text-primary">pending</span>
)}
</div>
</SelectTrigger>
<SelectContent>
{modelOptions.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -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({
<TabsContent value="chat" className="mt-3 flex flex-col min-h-0 flex-1">
{activeTaskId ? (
<TaskChatPanel agents={AGENTS} />
<TaskChatPanel agents={AGENTS} sessionId={sessionId} />
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
No task selected
+30 -32
View File
@@ -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<HTMLDivElement>(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({
)}
</div>
{/* Session info - shows agent state and model */}
{/* Session info - shows agent state */}
{session?.state && (
<div className="mt-2 flex flex-wrap items-center gap-2">
<RunningIndicator state={session.state} />
{/* Model indicator */}
{sessionModel && (
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md bg-muted/50 text-xs text-muted-foreground border border-border/50">
<IconCpu className="h-3 w-3" />
<span className="font-medium">{sessionModel}</span>
</div>
)}
</div>
)}
@@ -372,18 +381,7 @@ export function TaskChatPanel({
/>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Select value={selectedAgent} onValueChange={setSelectedAgent}>
<SelectTrigger className="w-[160px] cursor-pointer">
<SelectValue placeholder="Select agent" />
</SelectTrigger>
<SelectContent>
{agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
{agent.label}
</SelectItem>
))}
</SelectContent>
</Select>
<ModelSelector sessionId={resolvedSessionId} />
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
+3
View File
@@ -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}
/>
</ResizablePanel>
<ResizableHandle className="w-px" />
+40 -1
View File
@@ -259,6 +259,14 @@ export type SessionAgentctlState = {
itemsBySessionId: Record<string, SessionAgentctlStatus>;
};
export type PendingModelState = {
bySessionId: Record<string, string>;
};
export type ActiveModelState = {
bySessionId: Record<string, string>;
};
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<AppState>) => 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<typeof createAppStore>;
@@ -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<AppState>) {
}),
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<AppState>) {
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;
}),
}))
);
}
+1
View File
@@ -155,6 +155,7 @@ export type TaskSessionStateChangedPayload = {
session_id: string;
old_state?: string;
new_state: string;
agent_profile_id?: string;
metadata?: Record<string, unknown>;
};
+6 -1
View File
@@ -18,13 +18,15 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): 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<AppState>): WsHandle
progress: 0,
started_at: '',
updated_at: '',
...(agentProfileId ? { agent_profile_id: agentProfileId } : {}),
});
}
@@ -39,6 +42,7 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): 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<AppState>): WsHandle
progress: 0,
started_at: '',
updated_at: '',
...(agentProfileId ? { agent_profile_id: agentProfileId } : {}),
}]);
} else {
const nextSessions = sessionsByTask.map((session) =>
+478
View File
@@ -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<string, string>`
- `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<string, string>, // 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 |