fix: attribute model configuration to turns (#1784)

This commit is contained in:
Carlos Florêncio
2026-07-19 12:46:40 +01:00
committed by GitHub
parent 4b1320ffb0
commit 4fdd679269
26 changed files with 1156 additions and 283 deletions
@@ -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,
+28 -5
View File
@@ -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,
@@ -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",
@@ -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{})
@@ -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,
@@ -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
@@ -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,
@@ -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)
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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
}
@@ -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<TaskSession>, metadata = {}) {
function renderAgentMessageWithSession(
session: Partial<TaskSession>,
metadata = {},
turnMetadata?: Record<string, unknown>,
) {
const taskSession: TaskSession = {
id: toSessionId("sess-1"),
task_id: toTaskId("task-target"),
@@ -177,10 +183,26 @@ function renderAgentMessageWithSession(session: Partial<TaskSession>, 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 }) => (
<StateProvider
initialState={{
taskSessions: { items: { "sess-1": taskSession } },
turns: {
bySession: { "sess-1": turn ? [turn] : [] },
activeBySession: { "sess-1": turn?.id ?? null },
},
}}
>
{children}
@@ -190,7 +212,7 @@ function renderAgentMessageWithSession(session: Partial<TaskSession>, metadata =
return render(
<Wrapper>
<ChatMessage
comment={userMessage({ author_type: "agent", metadata })}
comment={userMessage({ author_type: "agent", metadata, turn_id: turn?.id })}
label="Message"
className=""
/>
@@ -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");
});
});
@@ -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<string, unknown> | 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 (
<button
@@ -370,7 +230,6 @@ export function MessageActions({
hasNext = false,
}: MessageActionsProps) {
const { copied, copy } = useCopyToClipboard();
const sessionConfigText = useMessageSessionConfigText(message, showModel);
const { turn, usageMultiplier } = useAppStore(
useShallow((state) => {
const turnId = message.turn_id;
@@ -389,6 +248,7 @@ export function MessageActions({
return { turn, usageMultiplier };
}),
);
const sessionConfigText = formatMessageSessionConfig(message.metadata, turn?.metadata);
const handleCopy = async () => {
await copy(message.content);
};
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import { formatMessageSessionConfig } from "./message-session-config";
const LEGACY_MODEL = "gpt-legacy";
function turnMetadata(overrides: Record<string, unknown> = {}) {
return {
runtime_config_snapshot: {
model: "gpt-5.6-sol",
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",
},
...overrides,
},
};
}
describe("formatMessageSessionConfig", () => {
it("keeps changed options in captured provider order", () => {
const metadata = turnMetadata({
config_options: [
{ id: "fast_mode", name: "Fast mode", value: "on", value_name: "On" },
{
id: "reasoning_effort",
name: "Reasoning effort",
value: "high",
value_name: "High",
},
],
config_baseline: { fast_mode: "off", reasoning_effort: "medium" },
});
expect(formatMessageSessionConfig(undefined, metadata)).toBe(
"gpt-5.6-sol · Fast mode: On · Reasoning effort: High",
);
});
it("shows only the model when all options match the baseline", () => {
const metadata = turnMetadata({
config_options: [
{
id: "reasoning_effort",
name: "Reasoning effort",
value: "medium",
value_name: "Medium",
},
],
});
expect(formatMessageSessionConfig(undefined, metadata)).toBe("gpt-5.6-sol");
});
it("shows captured options when the turn has no baseline", () => {
const metadata = turnMetadata({ config_baseline: undefined });
expect(formatMessageSessionConfig(undefined, metadata)).toBe(
"gpt-5.6-sol · Collaboration mode: Default · Reasoning effort: High",
);
});
it("prefers provider-refined turn model over cached message and captured models", () => {
expect(
formatMessageSessionConfig(
{ model: "message-model" },
{ ...turnMetadata(), model: "turn-model" },
),
).toContain("turn-model ·");
});
it("prefers captured turn model over legacy message metadata", () => {
expect(formatMessageSessionConfig({ model: "message-model" }, turnMetadata())).toContain(
"gpt-5.6-sol ·",
);
});
it("uses raw identifiers and values when captured display names are absent", () => {
const metadata = turnMetadata({
config_options: [{ id: "reasoning_effort", value: "high" }],
});
expect(formatMessageSessionConfig(undefined, metadata)).toBe(
"gpt-5.6-sol · Reasoning effort: high",
);
});
it("does not infer session options for legacy or malformed metadata", () => {
expect(formatMessageSessionConfig({ model: LEGACY_MODEL }, {})).toBe(LEGACY_MODEL);
expect(
formatMessageSessionConfig(
{ model: LEGACY_MODEL },
{ runtime_config_snapshot: { config_options: "invalid" } },
),
).toBe(LEGACY_MODEL);
});
});
@@ -0,0 +1,100 @@
type TurnConfigOption = {
id: string;
name?: string;
value: string;
value_name?: string;
};
type TurnConfigSnapshot = {
model?: string;
config_options?: TurnConfigOption[];
config_baseline?: Record<string, string>;
};
const TURN_CONFIG_SNAPSHOT_KEY = "runtime_config_snapshot";
const RUNTIME_MODEL_CONFIG_ID = "model";
function stringValue(value: unknown): string | null {
return typeof value === "string" && value.trim() !== "" ? value : null;
}
function recordValue(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function parseConfigOption(value: unknown): TurnConfigOption | null {
const option = recordValue(value);
if (!option) return null;
const id = stringValue(option.id);
const selectedValue = stringValue(option.value);
if (!id || !selectedValue) return null;
return {
id,
name: stringValue(option.name) ?? undefined,
value: selectedValue,
value_name: stringValue(option.value_name) ?? undefined,
};
}
function parseConfigBaseline(value: unknown): Record<string, string> | undefined {
const baseline = recordValue(value);
if (!baseline) return undefined;
return Object.fromEntries(
Object.entries(baseline).filter((entry): entry is [string, string] => {
return typeof entry[1] === "string";
}),
);
}
function parseTurnConfigSnapshot(metadata: Record<string, unknown> | null | undefined) {
const raw = recordValue(metadata?.[TURN_CONFIG_SNAPSHOT_KEY]);
if (!raw) return null;
return {
model: stringValue(raw.model) ?? undefined,
config_options: Array.isArray(raw.config_options)
? raw.config_options.map(parseConfigOption).filter((option) => option !== null)
: [],
config_baseline: parseConfigBaseline(raw.config_baseline),
} satisfies TurnConfigSnapshot;
}
function humanizeConfigKey(key: string): string {
return key
.replace(/[_-]+/g, " ")
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.trim()
.replace(/^./, (char) => char.toUpperCase());
}
function changedConfigOptions(snapshot: TurnConfigSnapshot): TurnConfigOption[] {
const baseline = snapshot.config_baseline;
return (snapshot.config_options ?? []).filter((option) => {
if (option.id === RUNTIME_MODEL_CONFIG_ID) return false;
return (
baseline === undefined ||
!Object.hasOwn(baseline, option.id) ||
baseline[option.id] !== option.value
);
});
}
export function formatMessageSessionConfig(
messageMetadata: Record<string, unknown> | null | undefined,
turnMetadata: Record<string, unknown> | null | undefined,
): string | null {
const snapshot = parseTurnConfigSnapshot(turnMetadata);
const model =
stringValue(turnMetadata?.model) ??
snapshot?.model ??
stringValue(messageMetadata?.model) ??
null;
if (!model) return null;
if (!snapshot) return model;
const details = changedConfigOptions(snapshot).map((option) => {
const name = option.name ?? humanizeConfigKey(option.id);
return `${name}: ${option.value_name ?? option.value}`;
});
return details.length > 0 ? `${model} · ${details.join(" · ")}` : model;
}
@@ -168,6 +168,77 @@ test.describe("Chat model selector — RPC failure", () => {
* session snapshot and SSR re-served the pre-change model on reload.
*/
test.describe("Chat model selector — persistence", () => {
test("completed turn metadata keeps changed options after a page reload", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Turn Config Reload Test",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("expected an auto-started session");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 30_000 });
const trigger = testPage.getByRole("button", { name: "Session model settings" });
await trigger.click();
await testPage.getByTestId("config-option-trigger-effort").click();
await testPage.getByRole("button", { name: "High", exact: true }).click();
await expect(trigger).toHaveText("Mock Fast / High", { timeout: 5_000 });
await testPage.keyboard.press("Escape");
const prompt = "second turn uses high effort";
await session.sendMessage(prompt);
await session.expectChatResponseVisible(prompt);
await session.waitForChatIdle({ timeout: 30_000 });
const capturedConfig = testPage.getByText("mock-fast · Effort: High", { exact: true });
await expect(capturedConfig.first()).toHaveText("mock-fast · Effort: High");
await testPage.reload();
await session.waitForLoad();
const messagesResponse = await testPage.request.get(
`/api/v1/task-sessions/${task.session_id}/messages?limit=50&sort=desc`,
);
const messagesPayload = (await messagesResponse.json()) as {
messages: { content: string; turn_id?: string }[];
};
const responseMessage = messagesPayload.messages.find((message) =>
message.content.includes(prompt),
);
expect(responseMessage?.turn_id).toBeTruthy();
const turnsResponse = await testPage.request.get(
`/api/v1/task-sessions/${task.session_id}/turns`,
);
const turnsPayload = (await turnsResponse.json()) as {
turns: { id: string; metadata?: Record<string, unknown> }[];
};
const responseTurn = turnsPayload.turns.find((turn) => turn.id === responseMessage?.turn_id);
expect(responseTurn?.metadata).toMatchObject({
runtime_config_snapshot: {
config_options: expect.arrayContaining([
expect.objectContaining({ id: "effort", value: "high" }),
]),
config_baseline: { effort: "medium" },
},
});
await expect(capturedConfig.first()).toHaveText("mock-fast · Effort: High");
});
test("profile config overrides provider defaults on the first rendered label", async ({
testPage,
apiClient,
+53 -2
View File
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mergeInitialState } from "@/lib/state/default-state";
import type { AppState } from "@/lib/state/store";
import type { Task } from "@/lib/types/http";
@@ -10,6 +11,7 @@ const mocks = vi.hoisted(() => ({
fetchWorkflowSnapshot: vi.fn(),
listAgents: vi.fn(),
listRepositories: vi.fn(),
listSessionTurns: vi.fn(),
listTaskSessionMessages: vi.fn(),
listTaskSessions: vi.fn(),
listWorkflows: vi.fn(),
@@ -30,7 +32,7 @@ vi.mock("@/lib/api", () => ({
}));
vi.mock("@/lib/api/domains/session-api", () => ({
listSessionTurns: vi.fn(),
listSessionTurns: mocks.listSessionTurns,
}));
vi.mock("@/lib/api/domains/user-shell-api", () => ({
@@ -41,7 +43,7 @@ import { fetchSessionDataForTask } from "./session-page-state";
const NOW = "2026-07-16T12:00:00Z";
describe("fetchSessionDataForTask workspace hydration", () => {
describe("fetchSessionDataForTask hydration", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.fetchTask.mockResolvedValue({
@@ -82,4 +84,53 @@ describe("fetchSessionDataForTask workspace hydration", () => {
expect(initialState.workspaces?.items[0]?.office_workflow_id).toBe("workflow-office");
});
it("hydrates persisted per-turn runtime configuration after a page reload", async () => {
const session = {
id: "session-1",
task_id: "task-1",
state: "completed",
started_at: NOW,
updated_at: NOW,
};
const runtimeConfigSnapshot = {
model: "gpt-5.6-sol",
mode: "agent",
config_options: [
{
id: "reasoning_effort",
name: "Reasoning effort",
value: "high",
value_name: "High",
},
],
config_baseline: { reasoning_effort: "medium" },
};
mocks.listTaskSessions.mockResolvedValue({ sessions: [session], total: 1 });
mocks.fetchTaskSession.mockResolvedValue({ session });
mocks.listSessionTurns.mockResolvedValue({
turns: [
{
id: "turn-1",
session_id: session.id,
task_id: "task-1",
started_at: NOW,
completed_at: NOW,
metadata: { runtime_config_snapshot: runtimeConfigSnapshot },
created_at: NOW,
updated_at: NOW,
},
],
total: 1,
});
mocks.listTaskSessionMessages.mockResolvedValue(null);
const result = await fetchSessionDataForTask("task-1");
const hydratedState = mergeInitialState(result.initialState);
expect(hydratedState.turns.bySession[session.id]?.[0]?.metadata).toEqual({
runtime_config_snapshot: runtimeConfigSnapshot,
});
});
});
@@ -0,0 +1,25 @@
# ADR-2026-07-18-turn-configuration-snapshots: Attribute Runtime Configuration to Turns
**Status:** accepted
**Date:** 2026-07-18
**Area:** backend, frontend
## Context
Task-session runtime configuration is mutable across a conversation, while agent messages are historical output. Rendering every message with the session's latest model-adjacent options can relabel old output after a user changes model, reasoning, collaboration, or other provider settings. Persisting the same full snapshot on every streamed message would duplicate data and still leave message bundles within one prompt cycle to coordinate.
## Decision
The task turn owns the immutable runtime-configuration attribution for one prompt/response cycle. When `internal/task/service` creates a turn, it captures the effective model, mode, ordered selected ACP option values and display names, and the task-session provider-default baseline in turn metadata. Message rendering reads this snapshot through the existing turn payload; it does not fall back to mutable task-session options for historical turns.
Provider-reported prompt metadata may add or refine actual execution attribution such as model and usage on the same turn. It must preserve the captured option values and baseline. No schema migration is required because `task_session_turns.metadata` is already durable JSON.
## Consequences
Historical message metadata remains stable when later turns change configuration, and all messages in one turn share one compact snapshot. Turn rows become slightly larger, but the snapshot stores only selected display values rather than the provider's complete option catalog. Legacy turns remain truthful but less detailed: they show available model attribution and do not infer options from current session state.
## Alternatives Considered
- **Read current session configuration at render time:** rejected because it rewrites historical attribution after later changes.
- **Snapshot configuration on every message:** rejected because streamed and tool messages duplicate identical turn-level data and complicate consistency.
- **Store only raw option maps on turns:** rejected because later UI state would still be needed for provider order and human-readable names.
+1
View File
@@ -53,3 +53,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-07-15-office-agent-execution-profile-routing | [Separate Office identity from routed execution profiles](2026-07-15-office-agent-execution-profile-routing.md) | proposed | backend, frontend | 2026-07-15 |
| 0044 | [ACP agent compatibility dialects](0044-acp-agent-compatibility-dialects.md) | accepted | backend, protocol | 2026-07-16 |
| 0045 | [Install-wide storage maintenance uses typed ownership providers and quarantine](0045-install-wide-storage-maintenance.md) | accepted | backend, frontend, infra | 2026-07-14 |
| 2026-07-18-turn-configuration-snapshots | [Attribute runtime configuration to turns](2026-07-18-turn-configuration-snapshots.md) | accepted | backend, frontend | 2026-07-18 |
@@ -0,0 +1,83 @@
---
spec: docs/specs/ui/acp-model-configuration-summary.md
decision: docs/decisions/2026-07-18-turn-configuration-snapshots.md
created: 2026-07-18
status: implemented
---
# Implementation Plan: Turn Configuration Attribution
## Overview
Capture the effective session configuration once when a turn is created, persist it in existing turn metadata, and render message attribution from that immutable snapshot. Reuse the task-session ACP baseline to show only changed options while retaining provider names and order.
## Waves
Wave 1:
- [x] [task-01-backend-turn-snapshot](task-01-backend-turn-snapshot.md) — done
Wave 2:
- [x] [task-02-frontend-turn-attribution](task-02-frontend-turn-attribution.md) — done
Wave 3:
- [x] [task-03-integration-and-verification](task-03-integration-and-verification.md) — done
Tasks are sequential because the frontend contract depends on the backend metadata shape, and integration verification depends on both. Runtime policy keeps all work in the current workspace without delegated agents.
## Backend Design
Likely files:
- `apps/backend/internal/task/models/models.go`
- `apps/backend/internal/task/service/service_turns.go`
- `apps/backend/internal/task/service/service_turns_test.go`
- `apps/backend/internal/orchestrator/event_handlers_streaming.go`
- `apps/backend/internal/orchestrator/event_handlers_streaming_test.go`
Add a typed, JSON-tolerant turn configuration snapshot. At `StartTurn`, resolve the effective model/mode/options from the profile snapshot, persisted provider runtime state, explicit overrides, and the persisted ACP selector state. Persist selected option IDs, raw values, display names, provider order, and the captured baseline under one turn metadata key. Existing prompt-usage updates must merge metadata without replacing the snapshot.
No database migration or new API is planned: turn metadata already persists in SQLite/Postgres and is included in boot/API/WebSocket turn payloads.
## Frontend Design
Likely files:
- `apps/web/components/task/chat/messages/message-actions.tsx`
- `apps/web/components/task/chat/messages/message-session-config.ts`
- `apps/web/components/task/chat/messages/message-session-config.test.ts`
- `apps/web/components/task/chat/messages/chat-message.test.tsx`
Move parsing and formatting into a pure helper. Resolve model attribution from message metadata, provider-refined turn metadata, then the captured snapshot. Render only snapshot options whose raw value differs from the captured baseline, preserving provider order and `Name: Value` labels. Do not render mode in the compact row. Legacy turns without a snapshot may show a model but must not fall back to current session options.
This changes data selection and text content inside the existing truncated metadata row; it does not introduce a new control, layout, or viewport-specific interaction. Focused unit/component coverage satisfies mobile parity, with existing truncation retained.
## Verification
Targeted red/green cycles:
```bash
rtk go test -run 'TestStartTurn.*Config|TestPersistPromptMetadata.*Config' ./internal/task/service ./internal/orchestrator
rtk pnpm --filter @kandev/web test -- --run components/task/chat/messages/message-session-config.test.ts components/task/chat/messages/chat-message.test.tsx
```
Run Go commands from `apps/backend` and pnpm commands from `apps/`.
Final verification:
```bash
rtk make fmt
rtk node apps/web/scripts/generate-release-notes.mjs
rtk node apps/web/scripts/generate-changelog.mjs
rtk make typecheck
rtk make test
rtk make lint
```
## Risks
- A turn can be created before a provider has emitted a settled ACP selector snapshot. The turn must preserve the best configuration known at creation and must not be mutated later.
- Prompt-usage updates currently add model and usage to turn metadata. Their read-modify-write path must retain the snapshot.
- Legacy messages currently borrow mutable session options. Removing that fallback intentionally reduces detail to avoid false historical attribution.
@@ -0,0 +1,44 @@
---
id: "01-backend-turn-snapshot"
title: "Persist the effective configuration on each turn"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/acp-model-configuration-summary.md"
---
# Task 01: Persist the Effective Configuration on Each Turn
## Acceptance
- Creating two turns around a session configuration change persists different immutable snapshots on the two turn rows.
- Each snapshot contains model, mode, ordered selected option IDs/raw values/display names, and the captured provider-default baseline.
- Later prompt-usage metadata updates preserve the captured snapshot while refining model/usage attribution.
## Verification
```bash
rtk go test -run 'TestStartTurn.*Config' ./internal/task/service
rtk go test -run 'TestPersistPromptMetadata.*Config' ./internal/orchestrator
```
Run from `apps/backend`.
## Files
- `apps/backend/internal/task/models/models.go`
- `apps/backend/internal/task/service/service_turns.go`
- `apps/backend/internal/task/service/service_turns_test.go`
- `apps/backend/internal/orchestrator/event_handlers_streaming.go`
- `apps/backend/internal/orchestrator/event_handlers_streaming_test.go`
## Inputs
- Spec data model, persistence guarantees, and cross-turn scenario.
- ADR-2026-07-18-turn-configuration-snapshots.
- Existing `SessionRuntimeConfig`, `SessionModelsSnapshot`, `StartTurn`, and `persistPromptMetadataOnTurn` patterns.
## Output Contract
Report the persisted JSON shape, effective-value precedence, failing/passing tests, files changed, and any turn-creation timing gaps.
@@ -0,0 +1,43 @@
---
id: "02-frontend-turn-attribution"
title: "Render changed configuration from turn metadata"
status: done
wave: 2
depends_on: ["01-backend-turn-snapshot"]
plan: "plan.md"
spec: "../../specs/ui/acp-model-configuration-summary.md"
---
# Task 02: Render Changed Configuration from Turn Metadata
## Acceptance
- Each agent message renders its model plus only options changed from that turn's captured baseline, preserving captured provider order and names.
- Changing session configuration for a later turn does not relabel earlier messages.
- Legacy turns without snapshots show available model attribution only and never current session options.
## Verification
```bash
rtk pnpm --filter @kandev/web test -- --run components/task/chat/messages/message-session-config.test.ts components/task/chat/messages/chat-message.test.tsx
rtk pnpm --filter @kandev/web typecheck
```
Run from `apps/`.
## Files
- `apps/web/components/task/chat/messages/message-actions.tsx`
- `apps/web/components/task/chat/messages/message-session-config.ts`
- `apps/web/components/task/chat/messages/message-session-config.test.ts`
- `apps/web/components/task/chat/messages/chat-message.test.tsx`
## Inputs
- Spec message-attribution and legacy-turn scenarios.
- Task 01 metadata shape.
- Existing `MessageActions`, turn store, and selector baseline comparison patterns.
## Output Contract
Report formatting behavior, legacy fallback behavior, desktop/mobile parity assessment, tests run, files changed, and remaining display risks.
@@ -0,0 +1,45 @@
---
id: "03-integration-and-verification"
title: "Verify durable cross-turn attribution"
status: done
wave: 3
depends_on: ["01-backend-turn-snapshot", "02-frontend-turn-attribution"]
plan: "plan.md"
spec: "../../specs/ui/acp-model-configuration-summary.md"
---
# Task 03: Verify Durable Cross-turn Attribution
## Acceptance
- Focused backend and frontend suites pass together after formatting.
- Refresh/API hydration continues to carry turn snapshots through the existing turn metadata contract.
- Full repository format, typecheck, test, and lint checks pass or any unrelated blocker is documented precisely.
## Verification
```bash
rtk make fmt
rtk node apps/web/scripts/generate-release-notes.mjs
rtk node apps/web/scripts/generate-changelog.mjs
rtk make typecheck
rtk make test
rtk make lint
```
Run from the repository root.
## Files
- Backend and frontend files from tasks 01 and 02
- `docs/specs/ui/acp-model-configuration-summary.md`
- `docs/decisions/2026-07-18-turn-configuration-snapshots.md`
## Inputs
- Completed task 01 and task 02 changes.
- Existing turn API/boot/WebSocket serialization tests.
## Output Contract
Report all commands and results, integration gaps, formatter changes, unresolved failures, and final task/plan status.
@@ -20,6 +20,10 @@ ACP agents can advertise an arbitrary ordered set of model-adjacent session conf
- Hovering or focusing the closed task selector shows a compact tooltip containing every currently rendered selector option as provider-supplied `Name: Value` rows, including baseline-matching values. The tooltip contains no descriptions or inferred provider knowledge. Opening the selector shows compact option names and selected values; entering an option submenu shows that option's provider description and the provider descriptions of its selectable values when supplied.
- Kandev preserves optional ACP descriptions for both top-level config options and selectable values throughout the adapter, backend event, WebSocket, store, and selector pipeline. Missing descriptions produce no invented or hard-coded explanatory text.
- Task-detail boot data includes the last persisted model list, live config options, and provider-default baseline so the compact label is complete on the first render instead of repainting after WebSocket reconnection.
- Each turn stores an immutable configuration snapshot when the turn is created. The snapshot contains the effective model, mode, ordered selected config values with their provider-supplied display names, and the task-session provider-default baseline used for comparison. Agent-message metadata renders from this turn snapshot instead of the session's latest mutable runtime configuration.
- Agent-message metadata always shows the attributed model and shows only non-model config values that differ from the turn's captured baseline. It keeps option names in the compact row because message attribution is read outside the selector context. Baseline-matching values and the session mode are omitted from the compact row; the mode remains available in turn metadata.
- Prompt-usage metadata may refine the turn's attributed model when the provider reports the actual model used, but it does not replace the turn's captured config values or baseline.
- Legacy turns without a configuration snapshot show their available message- or turn-level model attribution only. They never borrow current session options, because doing so would relabel historical output.
- The compact baseline-aware summary applies only to task chat input and task context model selectors. Shared selector uses such as agent-profile settings and utility configuration continue to list every selected value in the closed trigger.
- Dynamic `config_option_update` payloads replace the live option set while retaining the original persisted baseline. Provider-added, removed, reordered, or dependent options are compared by stable option ID and raw value.
- Legacy task sessions that have no stored baseline establish one from their first fully settled provider configuration after this feature is deployed. They do not attempt to reconstruct historical defaults.
@@ -35,6 +39,9 @@ ACP agents can advertise an arbitrary ordered set of model-adjacent session conf
- **GIVEN** an ACP option or value supplies a description, **WHEN** the user enters that option's submenu, **THEN** Kandev shows the provider text. The closed trigger and top-level option list remain compact, and missing descriptions leave the description region absent.
- **GIVEN** a task selector has current model and config values, **WHEN** the user hovers or focuses its closed trigger, **THEN** a compact tooltip lists every selector option as `Name: Value` without provider descriptions, including values omitted from the changed-only trigger label.
- **GIVEN** a task session has persisted dynamic model configuration, **WHEN** the task-detail page is refreshed, **THEN** the first rendered selector label includes all changed values without waiting for a WebSocket event.
- **GIVEN** turn A runs with reasoning `High`, **WHEN** reasoning changes to `Low` before turn B, **THEN** turn A continues to show `Reasoning effort: High` and turn B shows `Reasoning effort: Low`.
- **GIVEN** a turn uses only provider-default options, **WHEN** its agent-message metadata renders, **THEN** the compact row shows only the attributed model.
- **GIVEN** a legacy turn has no configuration snapshot, **WHEN** the session's runtime options later change, **THEN** the legacy turn does not display those current options.
- **GIVEN** the same shared selector is rendered in agent-profile settings, **WHEN** it is closed, **THEN** it continues to list all selected values regardless of the task-session baseline.
- **GIVEN** a narrow touch viewport, **WHEN** the user taps the selector, **THEN** all current options and available descriptions remain reachable without hover or horizontal page scrolling.
@@ -43,6 +50,7 @@ ACP agents can advertise an arbitrary ordered set of model-adjacent session conf
- Task-session metadata contains a dedicated write-once ACP provider-default baseline keyed by config option ID with raw selected values.
- Task-session metadata also contains the latest complete ACP model selector state needed for task-detail boot hydration, including provider-supplied model and option metadata.
- The provider's latest mutable state remains in runtime configuration metadata. Explicit user selections are stored separately and applied as overrides after that provider state, preventing delayed provider events from replacing resume intent. Baseline, live state, and explicit overrides have distinct ownership and lifecycle semantics.
- Turn metadata contains a minimal immutable configuration snapshot. Selected option IDs and raw values support baseline comparison; captured option/value names and order preserve the provider's display semantics without depending on later session state.
- ACP config option and option-value transport types carry optional descriptions.
## Failure Modes
@@ -55,10 +63,12 @@ ACP agents can advertise an arbitrary ordered set of model-adjacent session conf
- Once stored, the baseline is not replaced by later ACP updates, user selections, agent-initiated selections, backend restarts, or session resume.
- Baseline comparison is scoped to the task session, not the agent profile or provider globally.
- Once a turn is created, its configuration snapshot is not changed by later session configuration events. Only provider-reported attribution fields such as the actual model and usage may be added to the turn.
## Out of Scope
- Defining or inferring provider defaults beyond the task session's initial provider-advertised configuration.
- Hard-coded descriptions, aliases, importance rankings, or default values for individual ACP providers.
- Reconstructing configuration snapshots for turns created before this behavior shipped.
- Changing the closed-label behavior in agent-profile settings or other non-task selector surfaces.
- Adding support for ACP input control types that Kandev does not otherwise render.