diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go index 5eede60171..7a4c298a1f 100644 --- a/apps/backend/internal/backendapp/adapters.go +++ b/apps/backend/internal/backendapp/adapters.go @@ -496,6 +496,8 @@ func (a *lifecycleAdapter) ResolveAgentProfile(ctx context.Context, profileID st AgentID: info.AgentID, AgentName: info.AgentName, Model: info.Model, + Mode: info.Mode, + ConfigOptions: info.ConfigOptions, AutoApprove: info.AutoApprove, DangerouslySkipPermissions: info.DangerouslySkipPermissions, CLIPassthrough: info.CLIPassthrough, diff --git a/apps/backend/internal/backendapp/boot_state.go b/apps/backend/internal/backendapp/boot_state.go index 82f86fec87..f945028660 100644 --- a/apps/backend/internal/backendapp/boot_state.go +++ b/apps/backend/internal/backendapp/boot_state.go @@ -826,7 +826,7 @@ func (b bootStateBuilder) taskDetailInitialState( b.addTaskDetailResourceState(ctx, state, task) b.addTaskDetailKanbanState(ctx, state, task) b.addTaskDetailActiveTaskState(ctx, state, taskDTO, activeSessionID) - b.addTaskDetailSessionsState(state, task.ID, sessions, activeSessionID) + b.addTaskDetailSessionsState(ctx, state, task.ID, sessions, activeSessionID) b.addTaskDetailAgentsState(ctx, state) return state } @@ -942,6 +942,7 @@ func lastSessionByTaskState(taskID, sessionID string) map[string]string { } func (b bootStateBuilder) addTaskDetailSessionsState( + ctx context.Context, state map[string]any, taskID string, sessions []*taskmodels.TaskSession, @@ -987,10 +988,7 @@ func (b bootStateBuilder) addTaskDetailSessionsState( "loadingByTaskId": map[string]any{taskID: false}, "loadedByTaskId": map[string]any{taskID: true}, } - state["turns"] = map[string]any{ - "bySession": map[string]any{}, - "activeBySession": activeTurnBySessionState(activeSessionID), - } + state["turns"] = b.taskDetailTurnsState(ctx, activeSessionID) state["environmentIdBySessionId"] = environmentBySession state["worktrees"] = map[string]any{"items": worktrees} state["sessionWorktreesBySessionId"] = map[string]any{"itemsBySessionId": worktreesBySession} @@ -999,6 +997,31 @@ func (b bootStateBuilder) addTaskDetailSessionsState( } } +func (b bootStateBuilder) taskDetailTurnsState(ctx context.Context, sessionID string) map[string]any { + bySession := map[string]any{} + activeBySession := activeTurnBySessionState(sessionID) + if sessionID == "" { + return map[string]any{"bySession": bySession, "activeBySession": activeBySession} + } + turns, err := b.p.taskSvc.ListTurnsBySession(ctx, sessionID) + if err != nil { + b.logBootError("list task detail turns", err) + return map[string]any{"bySession": bySession, "activeBySession": activeBySession} + } + items := make([]taskdto.TurnDTO, 0, len(turns)) + for _, turn := range turns { + if turn == nil { + continue + } + items = append(items, taskdto.FromTurn(turn)) + if turn.CompletedAt == nil { + activeBySession[sessionID] = turn.ID + } + } + bySession[sessionID] = items + return map[string]any{"bySession": bySession, "activeBySession": activeBySession} +} + func taskSessionModelsBootState( snapshot lifecycle.SessionModelsSnapshot, baseline map[string]string, diff --git a/apps/backend/internal/backendapp/helpers_test.go b/apps/backend/internal/backendapp/helpers_test.go index 6185fce2e4..999ad46178 100644 --- a/apps/backend/internal/backendapp/helpers_test.go +++ b/apps/backend/internal/backendapp/helpers_test.go @@ -824,6 +824,82 @@ func TestBootRouteDataTaskDetailIncludesPersistedSessionModels(t *testing.T) { } } +func TestBootRouteDataTaskDetailIncludesPersistedTurns(t *testing.T) { + harness := newBootStateTestHarness(t) + ctx := context.Background() + workspaces, err := harness.taskSvc.ListWorkspaces(ctx) + if err != nil { + t.Fatalf("ListWorkspaces: %v", err) + } + workflows, err := harness.taskSvc.ListWorkflows(ctx, workspaces[0].ID, true) + if err != nil { + t.Fatalf("ListWorkflows: %v", err) + } + steps, err := harness.workflowSvc.ListStepsByWorkflow(ctx, workflows[0].ID) + if err != nil { + t.Fatalf("ListStepsByWorkflow: %v", err) + } + task, err := harness.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{ + WorkspaceID: workspaces[0].ID, WorkflowID: workflows[0].ID, + WorkflowStepID: steps[0].ID, Title: "Hydrated turn snapshot", + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + now := time.Now().UTC() + session := &models.TaskSession{ + ID: "boot-turn-session", TaskID: task.ID, IsPrimary: true, + } + if err := harness.taskRepo.CreateTaskSession(ctx, session); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + turn := &models.Turn{ + ID: "boot-persisted-turn", TaskID: task.ID, TaskSessionID: session.ID, + StartedAt: now, CompletedAt: &now, CreatedAt: now, UpdatedAt: now, + Metadata: map[string]interface{}{ + models.TurnMetaKeyRuntimeConfigSnapshot: models.TurnRuntimeConfigSnapshot{ + Model: "mock-fast", + ConfigOptions: []models.TurnRuntimeConfigOption{{ + ID: "effort", Name: "Effort", Value: "high", ValueName: "High", + }}, + ConfigBaseline: map[string]string{"effort": "medium"}, + }, + }, + } + if err := harness.taskRepo.CreateTurn(ctx, turn); err != nil { + t.Fatalf("CreateTurn: %v", err) + } + + routeData := bootRouteData(ctx, nil, routeParams{ + taskSvc: harness.taskSvc, + services: &Services{Workflow: harness.workflowSvc}, + }, webapp.ClassifyRoute("/t/"+task.ID)) + raw, err := json.Marshal(routeData) + if err != nil { + t.Fatalf("Marshal route data: %v", err) + } + var decoded struct { + TaskDetail struct { + InitialState struct { + Turns struct { + BySession map[string][]taskdto.TurnDTO `json:"bySession"` + } `json:"turns"` + } `json:"initialState"` + } `json:"taskDetail"` + } + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("Unmarshal route data: %v", err) + } + got := decoded.TaskDetail.InitialState.Turns.BySession[session.ID] + if len(got) != 1 || got[0].ID != turn.ID { + t.Fatalf("boot turns = %#v, want turn %q", got, turn.ID) + } + snapshot, ok := models.LoadTurnRuntimeConfigSnapshot(got[0].Metadata) + if !ok || len(snapshot.ConfigOptions) != 1 || snapshot.ConfigOptions[0].Value != "high" { + t.Fatalf("boot turn snapshot = %#v, ok=%v", snapshot, ok) + } +} + func TestTaskSessionModelsBootStateOmitsUnavailableBaseline(t *testing.T) { state := taskSessionModelsBootState(lifecycle.SessionModelsSnapshot{ CurrentModelID: "gpt-5.6-sol", diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming_test.go b/apps/backend/internal/orchestrator/event_handlers_streaming_test.go index 9a9cb6f8c8..d0e8999580 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming_test.go @@ -502,6 +502,20 @@ func TestPersistTurnPromptMetadata(t *testing.T) { svc.turnService = &repoTurnService{repo: repo} turn, err := svc.turnService.StartTurn(ctx, "s1") require.NoError(t, err) + configSnapshot := map[string]interface{}{ + "model": "gpt-5.4", + "config_options": []interface{}{ + map[string]interface{}{ + "id": "reasoning_effort", "name": "Reasoning effort", + "value": "high", "value_name": "High", + }, + }, + "config_baseline": map[string]interface{}{"reasoning_effort": "medium"}, + } + turn.Metadata = map[string]interface{}{ + models.TurnMetaKeyRuntimeConfigSnapshot: configSnapshot, + } + require.NoError(t, svc.turnService.UpdateTurn(ctx, turn)) svc.persistTurnPromptMetadata(ctx, &lifecycle.AgentStreamEventPayload{ TaskID: "t1", @@ -529,6 +543,7 @@ func TestPersistTurnPromptMetadata(t *testing.T) { updated, err := repo.GetTurn(ctx, turn.ID) require.NoError(t, err) require.Equal(t, "gpt-5.5", updated.Metadata["model"]) + require.Equal(t, configSnapshot, updated.Metadata[models.TurnMetaKeyRuntimeConfigSnapshot]) require.Equal(t, "codex-acp", updated.Metadata["agent_type"]) require.Equal(t, "exec-1", updated.Metadata["agent_id"]) usage, ok := updated.Metadata["prompt_usage"].(map[string]interface{}) diff --git a/apps/backend/internal/orchestrator/event_handlers_test.go b/apps/backend/internal/orchestrator/event_handlers_test.go index 1359e95465..c2473a1907 100644 --- a/apps/backend/internal/orchestrator/event_handlers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_test.go @@ -205,6 +205,7 @@ type mockAgentManager struct { // liveness per row. Nil → the mock is not a prober and reconciliation treats // every row as Unknown. rowLivenessFn func(*models.ExecutorRunning) models.ProcessLiveness + resolveProfileInfo *executor.AgentProfileInfo resolveProfileErr error restartProcessCalls []string // tracks execution IDs passed to RestartAgentProcess restartProcessErr error @@ -406,6 +407,9 @@ func (m *mockAgentManager) ResolveAgentProfile(_ context.Context, _ string) (*ex if m.resolveProfileErr != nil { return nil, m.resolveProfileErr } + if m.resolveProfileInfo != nil { + return m.resolveProfileInfo, nil + } return &executor.AgentProfileInfo{ SupportsMCP: true, CLIPassthrough: m.isPassthrough, diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index c74df5c66f..1b2ed98243 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -269,6 +269,8 @@ type AgentProfileInfo struct { AgentID string AgentName string Model string + Mode string + ConfigOptions map[string]string AutoApprove bool DangerouslySkipPermissions bool CLIPassthrough bool diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index ee5c4a0e1a..56fbfd0fff 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "hash/fnv" + "maps" "os/exec" "sort" "strings" @@ -503,6 +504,8 @@ func (e *Executor) resolveAgentProfileSnapshot(ctx context.Context, agentProfile "agent_id": profileInfo.AgentID, "agent_name": profileInfo.AgentName, "model": profileInfo.Model, + "mode": profileInfo.Mode, + "config_options": maps.Clone(profileInfo.ConfigOptions), "auto_approve": profileInfo.AutoApprove, "dangerously_skip_permissions": profileInfo.DangerouslySkipPermissions, "cli_passthrough": profileInfo.CLIPassthrough, diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 367b1e5c95..0561e64da7 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -111,6 +111,37 @@ func TestPrepareSession_Success(t *testing.T) { } } +func TestPrepareSessionSnapshotsProfileRuntimeConfig(t *testing.T) { + repo := newMockRepository() + agentManager := &mockAgentManager{ + resolveAgentProfileFunc: func(context.Context, string) (*AgentProfileInfo, error) { + return &AgentProfileInfo{ + ProfileID: "profile-123", + Model: "gpt-5.6-sol", + Mode: "agent", + ConfigOptions: map[string]string{"reasoning_effort": "high"}, + }, nil + }, + } + executor := newTestExecutor(t, agentManager, repo) + task := &v1.Task{ID: "task-123", WorkspaceID: "workspace-123", Title: "Test Task"} + + if _, err := executor.PrepareSession( + context.Background(), task, "profile-123", "executor-123", "", "step-123", + ); err != nil { + t.Fatalf("PrepareSession failed: %v", err) + } + + snapshot := repo.createTaskSessionCalls[0].AgentProfileSnapshot + if snapshot["mode"] != "agent" { + t.Fatalf("profile snapshot mode = %#v", snapshot["mode"]) + } + options, ok := snapshot["config_options"].(map[string]string) + if !ok || options["reasoning_effort"] != "high" { + t.Fatalf("profile snapshot config options = %#v", snapshot["config_options"]) + } +} + func TestPersistRuntimeModelMetadataStoresRuntimeConfigAndClearsContextWindow(t *testing.T) { repo := newMockRepository() exec := newTestExecutor(t, &mockAgentManager{}, repo) diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index c1e0818905..81db900826 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "maps" "os" "strings" "sync" @@ -327,11 +328,13 @@ func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, ag zap.Error(err)) } else if profileInfo != nil { session.AgentProfileSnapshot = map[string]interface{}{ - "id": profileInfo.ProfileID, - "name": profileInfo.ProfileName, - "agent_id": profileInfo.AgentID, - "agent_name": profileInfo.AgentName, - "model": profileInfo.Model, + "id": profileInfo.ProfileID, + "name": profileInfo.ProfileName, + "agent_id": profileInfo.AgentID, + "agent_name": profileInfo.AgentName, + "model": profileInfo.Model, + "mode": profileInfo.Mode, + "config_options": maps.Clone(profileInfo.ConfigOptions), } } s.promoteSessionIfTaskHasNoPrimary(ctx, taskID, session) diff --git a/apps/backend/internal/orchestrator/task_operations_test.go b/apps/backend/internal/orchestrator/task_operations_test.go index 91f7dbe92e..a4247ccd55 100644 --- a/apps/backend/internal/orchestrator/task_operations_test.go +++ b/apps/backend/internal/orchestrator/task_operations_test.go @@ -2088,7 +2088,13 @@ func TestStartCreatedSession_WorkflowOverridePromotesPreparedWhenTaskHasNoPrimar } var launchedProfile string + profileOptions := map[string]string{"reasoning_effort": "high"} agentMgr := &mockAgentManager{ + resolveProfileInfo: &executor.AgentProfileInfo{ + ProfileID: "profile-b", + Mode: "agent", + ConfigOptions: profileOptions, + }, launchAgentFunc: func(_ context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { launchedProfile = req.AgentProfileID return &executor.LaunchAgentResponse{AgentExecutionID: "exec-1"}, nil @@ -2132,6 +2138,14 @@ func TestStartCreatedSession_WorkflowOverridePromotesPreparedWhenTaskHasNoPrimar if launchedProfile != "profile-b" { t.Fatalf("launched profile = %q, want profile-b", launchedProfile) } + if updated.AgentProfileSnapshot["mode"] != "agent" { + t.Fatalf("profile snapshot mode = %#v", updated.AgentProfileSnapshot["mode"]) + } + profileOptions["reasoning_effort"] = "low" + configOptions, ok := updated.AgentProfileSnapshot["config_options"].(map[string]interface{}) + if !ok || configOptions["reasoning_effort"] != "high" { + t.Fatalf("profile snapshot config options = %#v", updated.AgentProfileSnapshot["config_options"]) + } } // TestStartCreatedSession_EmptyProfileFallsBackToWorkflowDefault pins the bug diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 6865b34bd8..ca7f5c2c68 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -108,6 +108,10 @@ const SessionMetaKeyACPConfigBaseline = "acp_config_baseline" // reconnection. It is display metadata and is not replayed to the provider. const SessionMetaKeyACPModelState = "acp_model_state" +// TurnMetaKeyRuntimeConfigSnapshot stores the immutable effective runtime +// configuration attributed to one prompt/response turn. +const TurnMetaKeyRuntimeConfigSnapshot = "runtime_config_snapshot" + // SessionRuntimeConfig is persisted as provider state or explicit overrides. // On resume, explicit values take precedence over the latest provider snapshot // so delayed provider events cannot replace user intent. @@ -117,6 +121,43 @@ type SessionRuntimeConfig struct { ConfigOptions map[string]string `json:"config_options,omitempty"` } +// TurnRuntimeConfigSnapshot is the minimal display state captured when a turn +// starts. It intentionally excludes the provider's complete option catalog. +type TurnRuntimeConfigSnapshot struct { + Model string `json:"model,omitempty"` + Mode string `json:"mode,omitempty"` + ConfigOptions []TurnRuntimeConfigOption `json:"config_options,omitempty"` + ConfigBaseline map[string]string `json:"config_baseline,omitempty"` +} + +// TurnRuntimeConfigOption preserves one selected value in provider order. +type TurnRuntimeConfigOption struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Value string `json:"value"` + ValueName string `json:"value_name,omitempty"` +} + +// LoadTurnRuntimeConfigSnapshot decodes typed and JSON-rehydrated turn metadata. +func LoadTurnRuntimeConfigSnapshot(metadata map[string]interface{}) (TurnRuntimeConfigSnapshot, bool) { + if metadata == nil { + return TurnRuntimeConfigSnapshot{}, false + } + raw := metadata[TurnMetaKeyRuntimeConfigSnapshot] + if snapshot, ok := raw.(TurnRuntimeConfigSnapshot); ok { + return snapshot, snapshot.Model != "" || snapshot.Mode != "" || len(snapshot.ConfigOptions) > 0 + } + data, err := json.Marshal(raw) + if err != nil { + return TurnRuntimeConfigSnapshot{}, false + } + var snapshot TurnRuntimeConfigSnapshot + if err := json.Unmarshal(data, &snapshot); err != nil { + return TurnRuntimeConfigSnapshot{}, false + } + return snapshot, snapshot.Model != "" || snapshot.Mode != "" || len(snapshot.ConfigOptions) > 0 +} + // SessionMetaKeyPendingStepCompletion stores the agent's (or manual fallback's) // step-complete signal under TaskSession.Metadata. ADR 0015: the orchestrator // reads this on turn-end for steps with AutoAdvanceRequiresSignal=true and diff --git a/apps/backend/internal/task/service/service_turns.go b/apps/backend/internal/task/service/service_turns.go index 56236e0a69..a030473da8 100644 --- a/apps/backend/internal/task/service/service_turns.go +++ b/apps/backend/internal/task/service/service_turns.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "maps" "path/filepath" "sort" "strings" @@ -14,6 +15,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/task/models" @@ -21,6 +23,8 @@ import ( // Turn operations +const runtimeModelConfigID = "model" + // StartTurn creates a new turn for a session and publishes the turn.started event. // Returns the created turn. func (s *Service) StartTurn(ctx context.Context, sessionID string) (*models.Turn, error) { @@ -34,6 +38,7 @@ func (s *Service) StartTurn(ctx context.Context, sessionID string) (*models.Turn TaskSessionID: sessionID, TaskID: session.TaskID, StartedAt: time.Now().UTC(), + Metadata: runtimeConfigSnapshotMetadata(session), CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } @@ -54,11 +59,152 @@ func (s *Service) StartTurn(ctx context.Context, sessionID string) (*models.Turn return turn, nil } +func runtimeConfigSnapshotMetadata(session *models.TaskSession) map[string]interface{} { + if session == nil { + return nil + } + snapshot := buildTurnRuntimeConfigSnapshot(session) + if snapshot.Model == "" && snapshot.Mode == "" && len(snapshot.ConfigOptions) == 0 { + return nil + } + return map[string]interface{}{models.TurnMetaKeyRuntimeConfigSnapshot: snapshot} +} + +func buildTurnRuntimeConfigSnapshot(session *models.TaskSession) models.TurnRuntimeConfigSnapshot { + effective := runtimeConfigFromProfileSnapshot(session.AgentProfileSnapshot) + if runtime, ok := models.LoadSessionRuntimeConfig(session.Metadata); ok { + mergeRuntimeConfig(&effective, runtime) + } + if overrides, ok := models.LoadSessionRuntimeConfigOverrides(session.Metadata); ok { + mergeRuntimeConfig(&effective, overrides) + } + baseline, _ := models.LoadSessionACPConfigBaseline(session.Metadata) + result := models.TurnRuntimeConfigSnapshot{ + Model: effective.Model, + Mode: effective.Mode, + ConfigBaseline: maps.Clone(baseline), + } + selector, ok := lifecycle.LoadSessionModelsSnapshot(session.Metadata[models.SessionMetaKeyACPModelState]) + if result.Model == "" && ok { + result.Model = selector.CurrentModelID + } + seen := make(map[string]struct{}, len(selector.ConfigOptions)) + for _, option := range selector.ConfigOptions { + selected, ok := selectedTurnConfigOption(option, effective.ConfigOptions) + if !ok { + continue + } + result.ConfigOptions = append(result.ConfigOptions, selected) + seen[option.ID] = struct{}{} + } + unknownIDs := make([]string, 0) + for id := range effective.ConfigOptions { + if _, exists := seen[id]; !exists && id != runtimeModelConfigID { + unknownIDs = append(unknownIDs, id) + } + } + sort.Strings(unknownIDs) + for _, id := range unknownIDs { + value := effective.ConfigOptions[id] + if value != "" { + result.ConfigOptions = append(result.ConfigOptions, models.TurnRuntimeConfigOption{ + ID: id, Value: value, ValueName: value, + }) + } + } + return result +} + +func selectedTurnConfigOption( + option streams.ConfigOption, + overrides map[string]string, +) (models.TurnRuntimeConfigOption, bool) { + if option.ID == "" || option.ID == runtimeModelConfigID || option.Category == runtimeModelConfigID { + return models.TurnRuntimeConfigOption{}, false + } + value := option.CurrentValue + if override, exists := overrides[option.ID]; exists { + value = override + } + if value == "" { + return models.TurnRuntimeConfigOption{}, false + } + return models.TurnRuntimeConfigOption{ + ID: option.ID, + Name: option.Name, + Value: value, + ValueName: selectedConfigValueName(option.Options, value), + }, true +} + +func runtimeConfigFromProfileSnapshot(snapshot map[string]interface{}) models.SessionRuntimeConfig { + config := models.SessionRuntimeConfig{} + if snapshot == nil { + return config + } + config.Model = models.StringFromAny(snapshot[runtimeModelConfigID]) + config.Mode = models.StringFromAny(snapshot["mode"]) + config.ConfigOptions = stringConfigOptions(snapshot["config_options"]) + if config.ConfigOptions == nil { + config.ConfigOptions = stringConfigOptions(snapshot["configOptions"]) + } + return config +} + +func stringConfigOptions(raw interface{}) map[string]string { + switch values := raw.(type) { + case map[string]string: + return maps.Clone(values) + case map[string]interface{}: + result := make(map[string]string, len(values)) + for key, value := range values { + if text := models.StringFromAny(value); text != "" { + result[key] = text + } + } + return result + default: + return nil + } +} + +func mergeRuntimeConfig(target *models.SessionRuntimeConfig, source models.SessionRuntimeConfig) { + if source.Model != "" { + target.Model = source.Model + } + if source.Mode != "" { + target.Mode = source.Mode + } + if source.ConfigOptions == nil { + return + } + if target.ConfigOptions == nil { + target.ConfigOptions = make(map[string]string) + } + for key, value := range source.ConfigOptions { + target.ConfigOptions[key] = value + } +} + +func selectedConfigValueName(options []streams.ConfigOptionValue, value string) string { + for _, option := range options { + if option.Value == value { + return option.Name + } + } + return value +} + // GetTurn returns a turn by ID. func (s *Service) GetTurn(ctx context.Context, turnID string) (*models.Turn, error) { return s.turns.GetTurn(ctx, turnID) } +// ListTurnsBySession returns all persisted turns for a session. +func (s *Service) ListTurnsBySession(ctx context.Context, sessionID string) ([]*models.Turn, error) { + return s.turns.ListTurnsBySession(ctx, sessionID) +} + // CompleteTurn marks a turn as completed and publishes the turn.completed event. func (s *Service) CompleteTurn(ctx context.Context, turnID string) error { if turnID == "" { @@ -550,13 +696,13 @@ func (s *Service) PersistSessionRuntimeConfigOption(ctx context.Context, session cfg.ConfigOptions = make(map[string]string) } cfg.ConfigOptions[configID] = value - if configID == "model" { + if configID == runtimeModelConfigID { cfg.Model = value } }); err != nil { return err } - if configID == "model" { + if configID == runtimeModelConfigID { return s.sessions.SetSessionMetadataKey(ctx, sessionID, "context_window", nil) } return nil diff --git a/apps/backend/internal/task/service/service_turns_test.go b/apps/backend/internal/task/service/service_turns_test.go index 43a76ba794..6c705bea51 100644 --- a/apps/backend/internal/task/service/service_turns_test.go +++ b/apps/backend/internal/task/service/service_turns_test.go @@ -8,15 +8,137 @@ import ( "time" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" + "github.com/stretchr/testify/require" ) type nilTaskSessionRepo struct { repository.SessionRepository } +func TestStartTurnPersistsImmutableEffectiveRuntimeConfigSnapshot(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + now := time.Now().UTC() + + options := []streams.ConfigOption{ + { + Type: "select", ID: "collaboration_mode", Name: "Collaboration mode", CurrentValue: "default", + Options: []streams.ConfigOptionValue{{Value: "default", Name: "Default"}}, + }, + { + Type: "select", ID: "reasoning_effort", Name: "Reasoning effort", CurrentValue: "medium", + Options: []streams.ConfigOptionValue{ + {Value: "medium", Name: "Medium"}, + {Value: "high", Name: "High"}, + {Value: "low", Name: "Low"}, + }, + }, + } + session := &models.TaskSession{ + ID: "session-turn-config", + TaskID: "task-123", + State: models.TaskSessionStateRunning, + StartedAt: now, + UpdatedAt: now, + AgentProfileSnapshot: map[string]interface{}{ + "model": "profile-model", + "mode": "default", + }, + Metadata: map[string]interface{}{ + models.SessionMetaKeyRuntimeConfig: models.SessionRuntimeConfig{ + Model: "gpt-5.6-sol", Mode: "agent", + ConfigOptions: map[string]string{ + "collaboration_mode": "default", + "reasoning_effort": "medium", + }, + }, + models.SessionMetaKeyRuntimeConfigOverrides: models.SessionRuntimeConfig{ + ConfigOptions: map[string]string{"reasoning_effort": "high"}, + }, + models.SessionMetaKeyACPConfigBaseline: map[string]string{ + "collaboration_mode": "default", + "reasoning_effort": "medium", + }, + models.SessionMetaKeyACPModelState: lifecycle.SessionModelsSnapshot{ + CurrentModelID: "gpt-5.6-sol", + ConfigOptions: options, + }, + }, + } + if err := repo.CreateTaskSession(ctx, session); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + + first, err := svc.StartTurn(ctx, session.ID) + if err != nil { + t.Fatalf("StartTurn first: %v", err) + } + if err := svc.CompleteTurn(ctx, first.ID); err != nil { + t.Fatalf("CompleteTurn first: %v", err) + } + if err := svc.PersistSessionRuntimeConfigOption(ctx, session.ID, "reasoning_effort", "low"); err != nil { + t.Fatalf("PersistSessionRuntimeConfigOption: %v", err) + } + second, err := svc.StartTurn(ctx, session.ID) + if err != nil { + t.Fatalf("StartTurn second: %v", err) + } + + storedFirst, err := repo.GetTurn(ctx, first.ID) + if err != nil { + t.Fatalf("GetTurn first: %v", err) + } + storedSecond, err := repo.GetTurn(ctx, second.ID) + if err != nil { + t.Fatalf("GetTurn second: %v", err) + } + firstSnapshot, ok := models.LoadTurnRuntimeConfigSnapshot(storedFirst.Metadata) + if !ok { + t.Fatal("first turn metadata does not contain a runtime config snapshot") + } + secondSnapshot, ok := models.LoadTurnRuntimeConfigSnapshot(storedSecond.Metadata) + if !ok { + t.Fatal("second turn metadata does not contain a runtime config snapshot") + } + + if firstSnapshot.Model != "gpt-5.6-sol" || firstSnapshot.Mode != "agent" { + t.Fatalf("first model/mode = %q/%q", firstSnapshot.Model, firstSnapshot.Mode) + } + wantFirstOptions := []models.TurnRuntimeConfigOption{ + {ID: "collaboration_mode", Name: "Collaboration mode", Value: "default", ValueName: "Default"}, + {ID: "reasoning_effort", Name: "Reasoning effort", Value: "high", ValueName: "High"}, + } + require.Equal(t, wantFirstOptions, firstSnapshot.ConfigOptions, "first turn config options") + if firstSnapshot.ConfigBaseline["reasoning_effort"] != "medium" { + t.Fatalf("first baseline = %#v", firstSnapshot.ConfigBaseline) + } + if secondSnapshot.ConfigOptions[1].Value != "low" || secondSnapshot.ConfigOptions[1].ValueName != "Low" { + t.Fatalf("second reasoning option = %#v", secondSnapshot.ConfigOptions[1]) + } + if firstSnapshot.ConfigOptions[1].Value != "high" { + t.Fatalf("first turn was relabeled after later override: %#v", firstSnapshot.ConfigOptions[1]) + } +} + +func TestBuildTurnRuntimeConfigSnapshotFallsBackToSelectorModel(t *testing.T) { + snapshot := buildTurnRuntimeConfigSnapshot(&models.TaskSession{ + Metadata: map[string]interface{}{ + models.SessionMetaKeyACPModelState: lifecycle.SessionModelsSnapshot{ + CurrentModelID: "selector-model", + }, + }, + }) + + if snapshot.Model != "selector-model" { + t.Fatalf("snapshot model = %q, want selector-model", snapshot.Model) + } +} + func (nilTaskSessionRepo) GetTaskSession(context.Context, string) (*models.TaskSession, error) { return nil, nil } diff --git a/apps/web/components/task/chat/messages/chat-message.test.tsx b/apps/web/components/task/chat/messages/chat-message.test.tsx index b106198e7a..c8e3465860 100644 --- a/apps/web/components/task/chat/messages/chat-message.test.tsx +++ b/apps/web/components/task/chat/messages/chat-message.test.tsx @@ -9,12 +9,14 @@ import { type CustomPrompt, type Message, type TaskSession, + type Turn, } from "@/lib/types/http"; const SENDER_TASK_ID = "task-sender"; const SENDER_TITLE = "Fix login bug"; const SENDER_BADGE_SELECTOR = "[data-testid='sender-task-badge']"; const MESSAGE_TIMESTAMP = "2026-05-04T00:00:00Z"; +const TURN_MODEL = "gpt-5.6-sol"; const PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; const OPEN_ATTACHMENT_1_LABEL = "Open Attachment 1"; @@ -168,7 +170,11 @@ function renderWithSender( ); } -function renderAgentMessageWithSession(session: Partial, metadata = {}) { +function renderAgentMessageWithSession( + session: Partial, + metadata = {}, + turnMetadata?: Record, +) { const taskSession: TaskSession = { id: toSessionId("sess-1"), task_id: toTaskId("task-target"), @@ -177,10 +183,26 @@ function renderAgentMessageWithSession(session: Partial, metadata = updated_at: MESSAGE_TIMESTAMP, ...session, }; + const turn: Turn | null = + turnMetadata === undefined + ? null + : { + id: "turn-1", + session_id: toSessionId("sess-1"), + task_id: toTaskId("task-target"), + started_at: MESSAGE_TIMESTAMP, + metadata: turnMetadata, + created_at: MESSAGE_TIMESTAMP, + updated_at: MESSAGE_TIMESTAMP, + }; const Wrapper = ({ children }: { children: ReactNode }) => ( {children} @@ -190,7 +212,7 @@ function renderAgentMessageWithSession(session: Partial, metadata = return render( @@ -390,148 +412,72 @@ describe("ChatMessage agent session config metadata", () => { expect(onOpenFile).toHaveBeenCalledWith("docs/specs/native/spec.md"); }); - - it("shows session config options next to the model", () => { - renderAgentMessageWithSession({ - agent_profile_snapshot: { - model: "gpt-5.5", - config_options: { - reasoning_effort: "high", - verbosity: "low", - }, - }, - }); - - expect(screen.getByText("gpt-5.5 · Reasoning effort: high · Verbosity: low")).not.toBeNull(); - }); - - it("prefers live runtime config over the profile snapshot", () => { - renderAgentMessageWithSession({ - metadata: { - runtime_config: { - model: "gpt-5.6", - mode: "accept-edits", - config_options: { - reasoning_effort: "medium", - }, - }, - }, - agent_profile_snapshot: { - model: "gpt-5.5", - mode: "default", - config_options: { - reasoning_effort: "high", - }, - }, - }); - - expect( - screen.getByText("gpt-5.6 · Mode: accept-edits · Reasoning effort: medium"), - ).not.toBeNull(); - }); - - it("merges runtime config options over snapshot options per option", () => { - renderAgentMessageWithSession({ - metadata: { - runtime_config: { - config_options: { - reasoning_effort: "medium", - }, - }, - }, - agent_profile_snapshot: { - model: "gpt-5.5", - config_options: { - reasoning_effort: "high", - verbosity: "low", - }, - }, - }); - - expect( - screen.getAllByText("gpt-5.5 · Reasoning effort: medium · Verbosity: low").length, - ).toBeGreaterThan(0); - }); - - it("keeps merged runtime-only config options sorted", () => { - renderAgentMessageWithSession({ - metadata: { - runtime_config: { - config_options: { - reasoning_effort: "medium", - }, - }, - }, - agent_profile_snapshot: { - model: "gpt-5.5", - config_options: { - verbosity: "low", - }, - }, - }); - - expect( - screen.getAllByText("gpt-5.5 · Reasoning effort: medium · Verbosity: low").length, - ).toBeGreaterThan(0); - }); }); describe("ChatMessage agent session config metadata overrides", () => { - it("does not fall back to snapshot options when runtime options are explicitly empty", () => { - const { container } = renderAgentMessageWithSession({ - metadata: { - runtime_config: { - config_options: {}, + it("renders changed options from the immutable turn snapshot", () => { + const { container } = renderAgentMessageWithSession( + { + metadata: { + runtime_config: { + model: TURN_MODEL, + mode: "agent", + config_options: { + collaboration_mode: "default", + reasoning_effort: "low", + }, + }, }, }, - agent_profile_snapshot: { - model: "gpt-5.5", - config_options: { - reasoning_effort: "high", + { model: TURN_MODEL }, + { + runtime_config_snapshot: { + model: TURN_MODEL, + mode: "agent", + config_options: [ + { + id: "collaboration_mode", + name: "Collaboration mode", + value: "default", + value_name: "Default", + }, + { + id: "reasoning_effort", + name: "Reasoning effort", + value: "high", + value_name: "High", + }, + ], + config_baseline: { + collaboration_mode: "default", + reasoning_effort: "medium", + }, }, }, - }); + ); - expect(screen.getByText("gpt-5.5")).not.toBeNull(); - expect(container.textContent).not.toContain("Reasoning effort"); + expect(screen.getByText(`${TURN_MODEL} · Reasoning effort: High`)).not.toBeNull(); + expect(container.textContent).not.toContain("Reasoning effort: low"); + expect(container.textContent).not.toContain("Collaboration mode"); + expect(container.textContent).not.toContain("Mode: agent"); }); - it("keeps message-level model attribution while showing session options", () => { - renderAgentMessageWithSession( + it("does not borrow mutable session options for a legacy turn", () => { + const { container } = renderAgentMessageWithSession( { - agent_profile_snapshot: { - model: "gpt-5.5", - config_options: { - reasoning_effort: "high", + metadata: { + runtime_config: { + model: TURN_MODEL, + config_options: { reasoning_effort: "high" }, }, }, }, { model: "gpt-5.5-mini" }, + {}, ); - expect(screen.getByText("gpt-5.5-mini · Reasoning effort: high")).not.toBeNull(); - }); - - it("uses message-level config options when message metadata provides them", () => { - const { container } = renderAgentMessageWithSession( - { - agent_profile_snapshot: { - model: "gpt-5.5", - config_options: { - reasoning_effort: "high", - }, - }, - }, - { - model: "gpt-5.5-mini", - config_options: { - reasoning_effort: "low", - }, - }, - ); - - expect(screen.getByText("gpt-5.5-mini · Reasoning effort: low")).not.toBeNull(); - expect(container.textContent).not.toContain("Reasoning effort: high"); + expect(screen.getByText("gpt-5.5-mini")).not.toBeNull(); + expect(container.textContent).not.toContain("Reasoning effort"); }); }); diff --git a/apps/web/components/task/chat/messages/message-actions.tsx b/apps/web/components/task/chat/messages/message-actions.tsx index 9d0eb443ad..0ce7db64df 100644 --- a/apps/web/components/task/chat/messages/message-actions.tsx +++ b/apps/web/components/task/chat/messages/message-actions.tsx @@ -20,6 +20,7 @@ import { buildMessageDebugEntries, hasMessageDebugMetadata, } from "@/components/task/chat/messages/message-debug-metadata"; +import { formatMessageSessionConfig } from "@/components/task/chat/messages/message-session-config"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@kandev/ui/dialog"; const ACTION_BUTTON_SIZE = "h-5 w-5 p-1"; @@ -42,147 +43,6 @@ type MessageActionsProps = { hasNext?: boolean; }; -type SessionConfigSource = { - model?: unknown; - mode?: unknown; - config_options?: unknown; - configOptions?: unknown; -}; - -type MessageSessionConfig = { - model: string | null; - mode: string | null; - configOptions: Array<{ label: string; value: string }>; - configOptionsSet: boolean; -}; - -function stringValue(value: unknown): string | null { - return typeof value === "string" && value.trim() !== "" ? value : null; -} - -function isStringConfigEntry(entry: [string, unknown]): entry is [string, string] { - const [key, optionValue] = entry; - return key !== "model" && key !== "mode" && stringValue(optionValue) !== null; -} - -function configOptionEntries(value: unknown): Array<{ label: string; value: string }> { - if (!value || typeof value !== "object" || Array.isArray(value)) return []; - return Object.entries(value) - .filter(isStringConfigEntry) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, optionValue]) => ({ - label: humanizeConfigKey(key), - value: optionValue, - })); -} - -function humanizeConfigKey(key: string): string { - return key - .replace(/[_-]+/g, " ") - .replace(/([a-z0-9])([A-Z])/g, "$1 $2") - .trim() - .replace(/^./, (char) => char.toUpperCase()); -} - -function configFromSource(source: SessionConfigSource | null | undefined): MessageSessionConfig { - if (!source) return emptySessionConfig(); - const configOptionsValue = source.config_options ?? source.configOptions; - return { - model: stringValue(source.model), - mode: stringValue(source.mode), - configOptions: configOptionEntries(configOptionsValue), - configOptionsSet: isConfigOptionsObject(configOptionsValue), - }; -} - -function emptySessionConfig(): MessageSessionConfig { - return { model: null, mode: null, configOptions: [], configOptionsSet: false }; -} - -function isConfigOptionsObject(value: unknown): boolean { - return !!value && typeof value === "object" && !Array.isArray(value); -} - -function mergeSessionConfig( - primary: MessageSessionConfig, - fallback: MessageSessionConfig, -): MessageSessionConfig { - return { - model: primary.model ?? fallback.model, - mode: primary.mode ?? fallback.mode, - configOptions: mergedConfigOptions(primary, fallback), - configOptionsSet: primary.configOptionsSet || fallback.configOptionsSet, - }; -} - -function mergedConfigOptions( - primary: MessageSessionConfig, - fallback: MessageSessionConfig, -): MessageSessionConfig["configOptions"] { - if (!primary.configOptionsSet) return fallback.configOptions; - if (primary.configOptions.length === 0) return []; - const optionsByLabel = new Map(fallback.configOptions.map((option) => [option.label, option])); - for (const option of primary.configOptions) { - optionsByLabel.set(option.label, option); - } - return Array.from(optionsByLabel.values()).sort((a, b) => a.label.localeCompare(b.label)); -} - -function formatSessionConfig(config: MessageSessionConfig): string | null { - if (!config.model) return null; - const details = formatSessionConfigDetails(config); - return details ? `${config.model} · ${details}` : config.model; -} - -function formatSessionConfigDetails(config: MessageSessionConfig): string | null { - const details = [ - config.mode ? `Mode: ${config.mode}` : null, - ...config.configOptions.map((option) => `${option.label}: ${option.value}`), - ].filter(Boolean); - return details.length > 0 ? details.join(" · ") : null; -} - -function useMessageSessionConfigText(message: Message, showModel: boolean) { - const sessionId = message.session_id; - const messageConfig = showModel - ? configFromSource(message.metadata as SessionConfigSource | undefined) - : null; - const [sessionConfigText, sessionDetailsText] = splitSessionConfigText( - useSessionConfigText(sessionId, showModel), - ); - if (!messageConfig?.model) return sessionConfigText || null; - const messageDetails = formatSessionConfigDetails(messageConfig); - const details = messageDetails ?? sessionDetailsText; - return details ? `${messageConfig.model} · ${details}` : messageConfig.model; -} - -function useSessionConfigText(sessionId: string | undefined, showModel: boolean) { - return useAppStore((state) => { - if (!showModel || !sessionId) return null; - const session = state.taskSessions.items[sessionId]; - const snapshot = configFromSource( - session?.agent_profile_snapshot as SessionConfigSource | null | undefined, - ); - const metadata = session?.metadata as Record | null | undefined; - const runtime = configFromSource( - metadata?.runtime_config as SessionConfigSource | null | undefined, - ); - const config = mergeSessionConfig(runtime, snapshot); - return joinSessionConfigText(formatSessionConfig(config), formatSessionConfigDetails(config)); - }); -} - -function joinSessionConfigText(full: string | null, details: string | null): string { - return JSON.stringify([full, details]); -} - -function splitSessionConfigText(value: string | null): [string | null, string | null] { - if (!value) return [null, null]; - const parsed = JSON.parse(value) as [unknown, unknown]; - const [full, details] = parsed; - return [stringValue(full), stringValue(details)]; -} - function CopyButton({ copied, onCopy }: { copied: boolean; onCopy: () => void }) { return (