feat(mcp): inherit creator session runtime for new tasks (#2581)
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/service"
|
||||
usermodels "github.com/kandev/kandev/internal/user/models"
|
||||
workflowmodels "github.com/kandev/kandev/internal/workflow/models"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHandleCreateTask_UsesVerifiedCreatorSessionProfileAndRuntime(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, repo := newTestTaskService(t)
|
||||
workspaces, err := svc.ListWorkspaces(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, workspaces, 1)
|
||||
workflow, err := svc.CreateWorkflow(ctx, &service.CreateWorkflowRequest{
|
||||
WorkspaceID: workspaces[0].ID,
|
||||
Name: "Creator session workflow",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
sourceResult, err := svc.CreateTask(ctx, &service.CreateTaskRequest{
|
||||
WorkspaceID: workspaces[0].ID,
|
||||
WorkflowID: workflow.ID,
|
||||
Title: "Source task",
|
||||
Metadata: map[string]interface{}{
|
||||
models.MetaKeyAgentProfileID: "source-task-profile",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{
|
||||
ID: "creator-session",
|
||||
TaskID: sourceResult.Task.ID,
|
||||
AgentProfileID: "creator-session-profile",
|
||||
ExecutorProfileID: "source-executor-profile",
|
||||
State: models.TaskSessionStateWaitingForInput,
|
||||
IsPrimary: true,
|
||||
AgentProfileSnapshot: map[string]interface{}{
|
||||
"model": "profile-model",
|
||||
"mode": "profile-mode",
|
||||
"config_options": map[string]string{
|
||||
"reasoning_effort": "medium",
|
||||
},
|
||||
},
|
||||
Metadata: map[string]interface{}{
|
||||
models.SessionMetaKeyRuntimeConfig: models.SessionRuntimeConfig{
|
||||
Model: "runtime-model",
|
||||
ConfigOptions: map[string]string{
|
||||
"reasoning_effort": "high",
|
||||
},
|
||||
},
|
||||
models.SessionMetaKeySessionMode: "acceptEdits",
|
||||
models.SessionMetaKeyRuntimeConfigOverrides: models.SessionRuntimeConfig{
|
||||
ConfigOptions: map[string]string{
|
||||
"reasoning_effort": "low",
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()}
|
||||
resp, err := h.handleCreateTask(ctx, makeWSMessage(t, ws.ActionMCPCreateTask, map[string]interface{}{
|
||||
"source_task_id": sourceResult.Task.ID,
|
||||
"source_session_id": "creator-session",
|
||||
"workspace_id": workspaces[0].ID,
|
||||
"workflow_id": workflow.ID,
|
||||
"title": "Creator session child",
|
||||
"start_agent": false,
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ws.MessageTypeResponse, resp.Type, "payload: %s", string(resp.Payload))
|
||||
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(resp.Payload, &created))
|
||||
task, err := svc.GetTask(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "creator-session-profile", task.Metadata[models.MetaKeyAgentProfileID])
|
||||
require.Equal(t, "source-executor-profile", task.Metadata[models.MetaKeyExecutorProfileID])
|
||||
|
||||
seed, ok := models.LoadInitialSessionRuntimeConfig(task.Metadata)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "runtime-model", seed.Model)
|
||||
require.Equal(t, "acceptEdits", seed.Mode)
|
||||
require.Equal(t, map[string]string{"reasoning_effort": "low"}, seed.ConfigOptions)
|
||||
require.Equal(t, "creator-session-profile", task.Metadata[models.MetaKeyInitialSessionRuntimeConfigProfileID])
|
||||
}
|
||||
|
||||
func TestHandleCreateTask_RejectsCreatorSessionFromAnotherTaskBeforePersistence(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, repo := newTestTaskService(t)
|
||||
workspaces, err := svc.ListWorkspaces(ctx)
|
||||
require.NoError(t, err)
|
||||
workflow, err := svc.CreateWorkflow(ctx, &service.CreateWorkflowRequest{
|
||||
WorkspaceID: workspaces[0].ID,
|
||||
Name: "Creator attribution workflow",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
sourceResult, err := svc.CreateTask(ctx, &service.CreateTaskRequest{
|
||||
WorkspaceID: workspaces[0].ID,
|
||||
WorkflowID: workflow.ID,
|
||||
Title: "Source task",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
foreignResult, err := svc.CreateTask(ctx, &service.CreateTaskRequest{
|
||||
WorkspaceID: workspaces[0].ID,
|
||||
WorkflowID: workflow.ID,
|
||||
Title: "Foreign task",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{
|
||||
ID: "foreign-session",
|
||||
TaskID: foreignResult.Task.ID,
|
||||
AgentProfileID: "foreign-profile",
|
||||
State: models.TaskSessionStateWaitingForInput,
|
||||
}))
|
||||
|
||||
h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()}
|
||||
resp, err := h.handleCreateTask(ctx, makeWSMessage(t, ws.ActionMCPCreateTask, map[string]interface{}{
|
||||
"source_task_id": sourceResult.Task.ID,
|
||||
"source_session_id": "foreign-session",
|
||||
"workspace_id": workspaces[0].ID,
|
||||
"workflow_id": workflow.ID,
|
||||
"title": "Should not persist",
|
||||
"start_agent": false,
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
assertWSError(t, resp, ws.ErrorCodeInternalError)
|
||||
|
||||
tasks, err := svc.ListTasks(ctx, workflow.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tasks, 2)
|
||||
}
|
||||
|
||||
func TestHandleCreateTask_SubtaskKeepsParentExecutorWhenCreatorProfileWins(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, workspace, workflow, source := newCreatorSessionTask(t)
|
||||
h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()}
|
||||
|
||||
resp, err := h.handleCreateTask(ctx, makeWSMessage(t, ws.ActionMCPCreateTask, map[string]interface{}{
|
||||
"parent_id": source.ID,
|
||||
"source_task_id": source.ID,
|
||||
"source_session_id": "creator-session",
|
||||
"title": "Creator session subtask",
|
||||
"start_agent": false,
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ws.MessageTypeResponse, resp.Type, "payload: %s", string(resp.Payload))
|
||||
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(resp.Payload, &created))
|
||||
task, err := svc.GetTask(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, workspace.ID, task.WorkspaceID)
|
||||
require.Equal(t, workflow.ID, task.WorkflowID)
|
||||
require.Equal(t, "creator-session-profile", task.Metadata[models.MetaKeyAgentProfileID])
|
||||
require.Equal(t, "parent-executor-profile", task.Metadata[models.MetaKeyExecutorProfileID])
|
||||
seed, ok := models.LoadInitialSessionRuntimeConfig(task.Metadata)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "creator-runtime-model", seed.Model)
|
||||
require.Equal(t, "creator-session-profile", task.Metadata[models.MetaKeyInitialSessionRuntimeConfigProfileID])
|
||||
}
|
||||
|
||||
func TestResolveMCPAutoStartConfig_ExplicitAndWorkspaceProfilesSuppressCreatorRuntime(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, workspace, workflow, source := newCreatorSessionTask(t)
|
||||
|
||||
explicitProvider := &mcpUserSettingsProvider{err: context.Canceled}
|
||||
explicitHandler := &Handlers{
|
||||
taskSvc: svc,
|
||||
userSettingsProvider: explicitProvider,
|
||||
logger: testLogger(t).WithFields(),
|
||||
}
|
||||
explicit, err := explicitHandler.resolveMCPAutoStartConfigWithSource(
|
||||
ctx,
|
||||
&models.Task{WorkspaceID: workspace.ID, WorkflowID: workflow.ID},
|
||||
"explicit-profile", "", source.ID, "creator-session",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "explicit-profile", explicit.AgentProfileID)
|
||||
require.Nil(t, explicit.InitialRuntimeConfig)
|
||||
require.Zero(t, explicitProvider.calls)
|
||||
|
||||
workspaceProfile := "workspace-profile"
|
||||
_, err = svc.UpdateWorkspace(ctx, workspace.ID, &service.UpdateWorkspaceRequest{
|
||||
DefaultAgentProfileID: &workspaceProfile,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
workspaceHandler := &Handlers{
|
||||
taskSvc: svc,
|
||||
userSettingsProvider: &mcpUserSettingsProvider{settings: &usermodels.UserSettings{
|
||||
MCPTaskAgentProfileDefault: usermodels.MCPTaskAgentProfileDefaultWorkspaceDefault,
|
||||
}},
|
||||
logger: testLogger(t).WithFields(),
|
||||
}
|
||||
workspaceConfig, err := workspaceHandler.resolveMCPAutoStartConfigWithSource(
|
||||
ctx,
|
||||
&models.Task{WorkspaceID: workspace.ID, WorkflowID: workflow.ID},
|
||||
"", "", source.ID, "creator-session",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, workspaceProfile, workspaceConfig.AgentProfileID)
|
||||
require.Nil(t, workspaceConfig.InitialRuntimeConfig)
|
||||
}
|
||||
|
||||
func TestResolveMCPAutoStartConfig_WorkflowProfileSuppressesCreatorRuntime(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, repo, workflowCtrl, workflowRepo := newTestTaskServiceWithWorkflow(t)
|
||||
workspace, workflow := defaultWorkspaceAndWorkflow(t, ctx, svc)
|
||||
step := seedWorkflowStep(t, ctx, workflowRepo, &workflowmodels.WorkflowStep{
|
||||
WorkflowID: workflow.ID,
|
||||
Name: "Workflow profile",
|
||||
Position: 1,
|
||||
AgentProfileID: "workflow-profile",
|
||||
AllowManualMove: true,
|
||||
})
|
||||
sourceResult, err := svc.CreateTask(ctx, &service.CreateTaskRequest{
|
||||
WorkspaceID: workspace.ID,
|
||||
WorkflowID: workflow.ID,
|
||||
Title: "Workflow source",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{
|
||||
ID: "workflow-creator-session",
|
||||
TaskID: sourceResult.Task.ID,
|
||||
AgentProfileID: "creator-session-profile",
|
||||
State: models.TaskSessionStateWaitingForInput,
|
||||
AgentProfileSnapshot: map[string]interface{}{
|
||||
"model": "creator-runtime-model",
|
||||
},
|
||||
}))
|
||||
|
||||
h := &Handlers{taskSvc: svc, workflowCtrl: workflowCtrl, logger: testLogger(t).WithFields()}
|
||||
config, err := h.resolveMCPAutoStartConfigWithSource(
|
||||
ctx,
|
||||
&models.Task{WorkspaceID: workspace.ID, WorkflowID: workflow.ID, WorkflowStepID: step.ID},
|
||||
"", "", sourceResult.Task.ID, "workflow-creator-session",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "workflow-profile", config.AgentProfileID)
|
||||
require.Nil(t, config.InitialRuntimeConfig)
|
||||
}
|
||||
|
||||
func newCreatorSessionTask(t *testing.T) (*service.Service, *models.Workspace, *models.Workflow, *models.Task) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
svc, repo := newTestTaskService(t)
|
||||
workspaces, err := svc.ListWorkspaces(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, workspaces, 1)
|
||||
workflow, err := svc.CreateWorkflow(ctx, &service.CreateWorkflowRequest{
|
||||
WorkspaceID: workspaces[0].ID,
|
||||
Name: "Creator session workflow",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
sourceResult, err := svc.CreateTask(ctx, &service.CreateTaskRequest{
|
||||
WorkspaceID: workspaces[0].ID,
|
||||
WorkflowID: workflow.ID,
|
||||
Title: "Source task",
|
||||
Metadata: map[string]interface{}{
|
||||
models.MetaKeyAgentProfileID: "source-task-profile",
|
||||
models.MetaKeyExecutorProfileID: "parent-executor-profile",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{
|
||||
ID: "creator-session",
|
||||
TaskID: sourceResult.Task.ID,
|
||||
AgentProfileID: "creator-session-profile",
|
||||
ExecutorProfileID: "parent-executor-profile",
|
||||
State: models.TaskSessionStateWaitingForInput,
|
||||
IsPrimary: true,
|
||||
AgentProfileSnapshot: map[string]interface{}{
|
||||
"model": "creator-runtime-model",
|
||||
},
|
||||
}))
|
||||
return svc, workspaces[0], workflow, sourceResult.Task
|
||||
}
|
||||
@@ -576,6 +576,7 @@ func (h *Handlers) handleCreateTask(ctx context.Context, msg *ws.Message) (*ws.M
|
||||
var req struct {
|
||||
ParentID string `json:"parent_id"`
|
||||
SourceTaskID string `json:"source_task_id"`
|
||||
SourceSessionID string `json:"source_session_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
WorkflowID string `json:"workflow_id"`
|
||||
WorkflowStepID string `json:"workflow_step_id"`
|
||||
@@ -688,7 +689,9 @@ func (h *Handlers) handleCreateTask(ctx context.Context, msg *ws.Message) (*ws.M
|
||||
WorkflowID: req.WorkflowID,
|
||||
WorkflowStepID: req.WorkflowStepID,
|
||||
}
|
||||
launchConfig, metadata, err := h.resolveMCPLaunchMetadata(ctx, pendingTask, req.AgentProfileID, req.ExecutorProfileID, req.SourceTaskID)
|
||||
launchConfig, metadata, err := h.resolveMCPLaunchMetadataWithSource(
|
||||
ctx, pendingTask, req.AgentProfileID, req.ExecutorProfileID, req.SourceTaskID, req.SourceSessionID,
|
||||
)
|
||||
if err != nil {
|
||||
code := ws.ErrorCodeInternalError
|
||||
if errors.Is(err, errMCPAgentProfileRequired) {
|
||||
@@ -1115,9 +1118,10 @@ func inheritedRepoInputs(src []*models.TaskRepository) []service.TaskRepositoryI
|
||||
}
|
||||
|
||||
type mcpAutoStartConfig struct {
|
||||
AgentProfileID string
|
||||
ExecutorID string
|
||||
ExecutorProfileID string
|
||||
AgentProfileID string
|
||||
ExecutorID string
|
||||
ExecutorProfileID string
|
||||
InitialRuntimeConfig *models.SessionRuntimeConfig
|
||||
}
|
||||
|
||||
var errMCPAgentProfileRequired = errors.New("agent_profile_id is required because the selected task profile policy, workflow, and workspace defaults did not resolve a profile")
|
||||
@@ -1143,7 +1147,17 @@ func (h *Handlers) resolveMCPAutoStartConfig(ctx context.Context, task *models.T
|
||||
}
|
||||
|
||||
func (h *Handlers) resolveMCPLaunchMetadata(ctx context.Context, task *models.Task, agentProfileID, executorProfileID, sourceTaskID string) (mcpAutoStartConfig, map[string]interface{}, error) {
|
||||
launchConfig, err := h.resolveMCPAutoStartConfigWithError(ctx, task, agentProfileID, executorProfileID, sourceTaskID)
|
||||
return h.resolveMCPLaunchMetadataWithSource(ctx, task, agentProfileID, executorProfileID, sourceTaskID, "")
|
||||
}
|
||||
|
||||
func (h *Handlers) resolveMCPLaunchMetadataWithSource(
|
||||
ctx context.Context,
|
||||
task *models.Task,
|
||||
agentProfileID, executorProfileID, sourceTaskID, sourceSessionID string,
|
||||
) (mcpAutoStartConfig, map[string]interface{}, error) {
|
||||
launchConfig, err := h.resolveMCPAutoStartConfigWithSource(
|
||||
ctx, task, agentProfileID, executorProfileID, sourceTaskID, sourceSessionID,
|
||||
)
|
||||
if err != nil {
|
||||
return mcpAutoStartConfig{}, nil, fmt.Errorf("failed to resolve launch profile: %w", err)
|
||||
}
|
||||
@@ -1159,14 +1173,36 @@ func (h *Handlers) resolveMCPLaunchMetadata(ctx context.Context, task *models.Ta
|
||||
if launchConfig.ExecutorProfileID != "" {
|
||||
metadata[models.MetaKeyExecutorProfileID] = launchConfig.ExecutorProfileID
|
||||
}
|
||||
if launchConfig.InitialRuntimeConfig != nil {
|
||||
metadata[models.MetaKeyInitialSessionRuntimeConfig] = *launchConfig.InitialRuntimeConfig
|
||||
metadata[models.MetaKeyInitialSessionRuntimeConfigProfileID] = launchConfig.AgentProfileID
|
||||
}
|
||||
return launchConfig, metadata, nil
|
||||
}
|
||||
|
||||
func (h *Handlers) resolveMCPAutoStartConfigWithError(ctx context.Context, task *models.Task, agentProfileID, executorProfileID, sourceTaskID string) (mcpAutoStartConfig, error) {
|
||||
return h.resolveMCPAutoStartConfigWithSource(ctx, task, agentProfileID, executorProfileID, sourceTaskID, "")
|
||||
}
|
||||
|
||||
func (h *Handlers) resolveMCPAutoStartConfigWithSource(
|
||||
ctx context.Context,
|
||||
task *models.Task,
|
||||
agentProfileID, executorProfileID, sourceTaskID, sourceSessionID string,
|
||||
) (mcpAutoStartConfig, error) {
|
||||
creatorSession, err := h.resolveMCPCreatorSession(ctx, sourceTaskID, sourceSessionID)
|
||||
if err != nil {
|
||||
return mcpAutoStartConfig{}, err
|
||||
}
|
||||
profileDefault, err := h.mcpTaskAgentProfileDefault(ctx, agentProfileID)
|
||||
if err != nil {
|
||||
return mcpAutoStartConfig{}, fmt.Errorf("read MCP task agent profile default: %w", err)
|
||||
}
|
||||
useCreatorRuntime := creatorSession != nil &&
|
||||
profileDefault == usermodels.MCPTaskAgentProfileDefaultCurrentTask &&
|
||||
agentProfileID == "" && creatorSession.AgentProfileID != ""
|
||||
if useCreatorRuntime {
|
||||
agentProfileID = creatorSession.AgentProfileID
|
||||
}
|
||||
profileForInheritance := &agentProfileID
|
||||
// Workspace-default mode keeps executor inheritance but discards inherited agent profiles.
|
||||
ignoredInheritedProfile := ""
|
||||
@@ -1174,20 +1210,14 @@ func (h *Handlers) resolveMCPAutoStartConfigWithError(ctx context.Context, task
|
||||
profileForInheritance = &ignoredInheritedProfile
|
||||
}
|
||||
|
||||
executorID, err := h.inheritFromTask(ctx, task.ParentID, profileForInheritance, &executorProfileID)
|
||||
executorID, executorProfileID, err := h.resolveMCPInheritedExecutors(
|
||||
ctx, task, profileForInheritance, executorProfileID, sourceTaskID,
|
||||
)
|
||||
if err != nil {
|
||||
return mcpAutoStartConfig{}, fmt.Errorf("inherit from parent task %s: %w", task.ParentID, err)
|
||||
return mcpAutoStartConfig{}, err
|
||||
}
|
||||
|
||||
// For top-level tasks, inherit from the source task (the calling agent's task).
|
||||
if task.ParentID == "" && sourceTaskID != "" {
|
||||
sourceExecutorID, err := h.inheritFromTask(ctx, sourceTaskID, profileForInheritance, &executorProfileID)
|
||||
if err != nil {
|
||||
return mcpAutoStartConfig{}, fmt.Errorf("inherit from source task %s: %w", sourceTaskID, err)
|
||||
}
|
||||
if executorID == "" {
|
||||
executorID = sourceExecutorID
|
||||
}
|
||||
if profileDefault != usermodels.MCPTaskAgentProfileDefaultWorkspaceDefault {
|
||||
agentProfileID = *profileForInheritance
|
||||
}
|
||||
|
||||
// Mirror the orchestrator's launch-time precedence so the profile reported
|
||||
@@ -1200,31 +1230,97 @@ func (h *Handlers) resolveMCPAutoStartConfigWithError(ctx context.Context, task
|
||||
// CreateTask assigns when the step is omitted. When the task will be on a
|
||||
// step, the workflow-derived profile overrides the caller; otherwise it only
|
||||
// fills an omitted profile.
|
||||
workflowProfileID, onStepAtLaunch, err := h.resolveWorkflowLaunchProfile(ctx, task.WorkflowStepID, task.WorkflowID)
|
||||
agentProfileID, useCreatorRuntime, err = h.resolveMCPFinalAgentProfile(
|
||||
ctx, task, agentProfileID, useCreatorRuntime,
|
||||
)
|
||||
if err != nil {
|
||||
return mcpAutoStartConfig{}, fmt.Errorf("resolve workflow agent profile: %w", err)
|
||||
}
|
||||
if workflowProfileID != "" && (onStepAtLaunch || agentProfileID == "") {
|
||||
agentProfileID = workflowProfileID
|
||||
}
|
||||
if agentProfileID == "" && h.taskSvc != nil {
|
||||
workspace, err := h.taskSvc.GetWorkspace(ctx, task.WorkspaceID)
|
||||
if err != nil {
|
||||
return mcpAutoStartConfig{}, fmt.Errorf("get workspace %s: %w", task.WorkspaceID, err)
|
||||
}
|
||||
if workspace != nil && workspace.DefaultAgentProfileID != nil {
|
||||
agentProfileID = *workspace.DefaultAgentProfileID
|
||||
}
|
||||
return mcpAutoStartConfig{}, err
|
||||
}
|
||||
if executorID == "" && executorProfileID == "" {
|
||||
executorID = models.ExecutorIDWorktree
|
||||
}
|
||||
|
||||
return mcpAutoStartConfig{
|
||||
config := mcpAutoStartConfig{
|
||||
AgentProfileID: agentProfileID,
|
||||
ExecutorID: executorID,
|
||||
ExecutorProfileID: executorProfileID,
|
||||
}, nil
|
||||
}
|
||||
if useCreatorRuntime {
|
||||
if runtimeConfig, ok := models.LoadEffectiveSessionRuntimeConfig(creatorSession); ok {
|
||||
config.InitialRuntimeConfig = &runtimeConfig
|
||||
}
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (h *Handlers) resolveMCPInheritedExecutors(
|
||||
ctx context.Context,
|
||||
task *models.Task,
|
||||
agentProfileID *string,
|
||||
executorProfileID, sourceTaskID string,
|
||||
) (string, string, error) {
|
||||
executorID, err := h.inheritFromTask(ctx, task.ParentID, agentProfileID, &executorProfileID)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("inherit from parent task %s: %w", task.ParentID, err)
|
||||
}
|
||||
if task.ParentID == "" && sourceTaskID != "" {
|
||||
sourceExecutorID, sourceErr := h.inheritFromTask(ctx, sourceTaskID, agentProfileID, &executorProfileID)
|
||||
if sourceErr != nil {
|
||||
return "", "", fmt.Errorf("inherit from source task %s: %w", sourceTaskID, sourceErr)
|
||||
}
|
||||
if executorID == "" {
|
||||
executorID = sourceExecutorID
|
||||
}
|
||||
}
|
||||
return executorID, executorProfileID, nil
|
||||
}
|
||||
|
||||
func (h *Handlers) resolveMCPFinalAgentProfile(
|
||||
ctx context.Context,
|
||||
task *models.Task,
|
||||
agentProfileID string,
|
||||
useCreatorRuntime bool,
|
||||
) (string, bool, error) {
|
||||
workflowProfileID, onStepAtLaunch, err := h.resolveWorkflowLaunchProfile(ctx, task.WorkflowStepID, task.WorkflowID)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("resolve workflow agent profile: %w", err)
|
||||
}
|
||||
if workflowProfileID != "" && (onStepAtLaunch || agentProfileID == "") {
|
||||
// The workflow-selected profile owns this launch, so creator runtime
|
||||
// values must not be copied into it.
|
||||
return workflowProfileID, false, nil
|
||||
}
|
||||
if agentProfileID != "" || h.taskSvc == nil {
|
||||
return agentProfileID, useCreatorRuntime, nil
|
||||
}
|
||||
workspace, err := h.taskSvc.GetWorkspace(ctx, task.WorkspaceID)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("get workspace %s: %w", task.WorkspaceID, err)
|
||||
}
|
||||
if workspace != nil && workspace.DefaultAgentProfileID != nil {
|
||||
return *workspace.DefaultAgentProfileID, false, nil
|
||||
}
|
||||
return agentProfileID, false, nil
|
||||
}
|
||||
|
||||
func (h *Handlers) resolveMCPCreatorSession(ctx context.Context, sourceTaskID, sourceSessionID string) (*models.TaskSession, error) {
|
||||
if sourceSessionID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if sourceTaskID == "" {
|
||||
return nil, errors.New("source_session_id requires source_task_id")
|
||||
}
|
||||
if h.taskSvc == nil {
|
||||
return nil, errors.New("cannot verify source_session_id without task service")
|
||||
}
|
||||
session, err := h.taskSvc.GetTaskSession(ctx, sourceSessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("verify source session %s: %w", sourceSessionID, err)
|
||||
}
|
||||
if session == nil || session.TaskID != sourceTaskID {
|
||||
return nil, fmt.Errorf("source session %s does not belong to source task %s", sourceSessionID, sourceTaskID)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (h *Handlers) mcpTaskAgentProfileDefault(ctx context.Context, explicitAgentProfileID string) (string, error) {
|
||||
|
||||
@@ -180,6 +180,9 @@ func (s *Server) createTaskHandler() server.ToolHandlerFunc {
|
||||
"source_task_id": s.taskID,
|
||||
"start_agent": startAgent,
|
||||
}
|
||||
if s.sessionID != "" && s.taskID != "" {
|
||||
payload["source_session_id"] = s.sessionID
|
||||
}
|
||||
if externalID := req.GetString("external_id", ""); externalID != "" {
|
||||
payload["external_id"] = externalID
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@ func TestCreateTask_ToolSchema_HasParentID(t *testing.T) {
|
||||
assert.Contains(t, tool.Tool.Description, "current_task")
|
||||
assert.Contains(t, tool.Tool.Description, "workspace_default")
|
||||
assert.Contains(t, tool.Tool.Description, "workflow")
|
||||
assert.Contains(t, tool.Tool.Description, "verified creating session")
|
||||
assert.Contains(t, tool.Tool.Description, "effective model, mode, and dynamic options")
|
||||
assert.Contains(t, tool.Tool.Description, "An explicit agent_profile_id prevents creator-session runtime inheritance")
|
||||
assert.Contains(t, tool.Tool.Description, "workspace_mode='new_workspace'")
|
||||
assert.NotContains(t, props, "mcp_task_agent_profile_default", "saved policy must not change the tool input schema")
|
||||
|
||||
@@ -92,6 +95,8 @@ func TestCreateTask_ToolSchema_HasParentID(t *testing.T) {
|
||||
assert.Contains(t, agentProfileDesc, "outranks it")
|
||||
assert.Contains(t, agentProfileDesc, "current_task")
|
||||
assert.Contains(t, agentProfileDesc, "workspace_default")
|
||||
assert.Contains(t, agentProfileDesc, "verified creating session")
|
||||
assert.Contains(t, agentProfileDesc, "effective model, mode, and dynamic options")
|
||||
|
||||
workflowProp, ok := props["workflow_id"].(map[string]interface{})
|
||||
require.True(t, ok, "workflow_id schema should be an object")
|
||||
@@ -349,6 +354,45 @@ func TestCreateTask_SourceTaskID_AlwaysSet(t *testing.T) {
|
||||
assert.Equal(t, "my-task-123", payload["source_task_id"])
|
||||
}
|
||||
|
||||
func TestCreateTask_ForwardsBoundSourceSessionID(t *testing.T) {
|
||||
backend := &testBackend{
|
||||
response: map[string]interface{}{"id": "task-new"},
|
||||
}
|
||||
s := newTaskModeServer(t, backend, "my-task-123")
|
||||
|
||||
result := callTool(t, s, "create_task_kandev", map[string]interface{}{
|
||||
"title": "New task",
|
||||
"workspace_id": "ws-1",
|
||||
"workflow_id": "wf-1",
|
||||
})
|
||||
|
||||
require.False(t, result.IsError)
|
||||
payload, ok := backend.lastPayload.(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "my-task-123", payload["source_task_id"])
|
||||
assert.Equal(t, "test-session", payload["source_session_id"])
|
||||
assert.NotContains(t, toolInputProperties(t, s, "create_task_kandev"), "source_session_id")
|
||||
}
|
||||
|
||||
func TestCreateTask_ExternalModeDoesNotInventSourceSessionID(t *testing.T) {
|
||||
backend := &testBackend{
|
||||
response: map[string]interface{}{"id": "task-new"},
|
||||
}
|
||||
s := New(backend, "", "", 10005, newTestLogger(t), "", true, ModeExternal)
|
||||
|
||||
result := callTool(t, s, "create_task_kandev", map[string]interface{}{
|
||||
"title": "External task",
|
||||
"workspace_id": "ws-1",
|
||||
"workflow_id": "wf-1",
|
||||
})
|
||||
|
||||
require.False(t, result.IsError)
|
||||
payload, ok := backend.lastPayload.(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
assert.Empty(t, payload["source_task_id"])
|
||||
assert.NotContains(t, payload, "source_session_id")
|
||||
}
|
||||
|
||||
func TestCreateTask_SourceTaskID_EmptyWhenNoTaskContext(t *testing.T) {
|
||||
backend := &testBackend{
|
||||
response: map[string]interface{}{"id": "task-new"},
|
||||
@@ -364,6 +408,7 @@ func TestCreateTask_SourceTaskID_EmptyWhenNoTaskContext(t *testing.T) {
|
||||
payload, ok := backend.lastPayload.(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "", payload["source_task_id"])
|
||||
assert.NotContains(t, payload, "source_session_id")
|
||||
}
|
||||
|
||||
func TestCreateTask_StartAgentFalse_DoesNotAutoStart(t *testing.T) {
|
||||
|
||||
@@ -1097,8 +1097,8 @@ WHEN TO OMIT parent_id (top-level task):
|
||||
|
||||
IMPORTANT:
|
||||
- Subtasks inherit task workspace, workflow, agent profile, executor, and materialized workspace from the parent by default. Pass workspace_id/workflow_id only when deliberately targeting a different task workspace/workflow; any supplied workflow_id must belong to the effective workspace_id. Pass workspace_mode='new_workspace' when the subtask needs its own materialized workspace/worktree.
|
||||
- A workflow step's launch profile outranks an explicit agent_profile_id when the task is on a step: that is the step's pinned profile, or the workflow default when the step has none. That profile is what launches, and it is the one reported back in the created task's metadata. Off a step, or when the step and workflow resolve no profile, an explicit agent_profile_id wins. When both are absent, the saved user policy applies: current_task inherits from the current/source task or parent before workflow and target-workspace defaults; workspace_default skips current/source and parent profiles, honors workflow profiles first, then uses the target workspace default.
|
||||
- Executor and executor-profile inheritance from the current/source task or parent is unchanged by either saved agent-profile policy.
|
||||
- A workflow step's launch profile outranks an explicit agent_profile_id when the task is on a step: that is the step's pinned profile, or the workflow default when the step has none. That profile is what launches, and it is the one reported back in the created task's metadata. Off a step, or when the step and workflow resolve no profile, an explicit agent_profile_id wins. When no workflow profile wins and agent_profile_id is omitted, the saved user policy applies: current_task uses the verified creating session's profile and effective model, mode, and dynamic options for a session-bound call. Without verified session context, it falls back to the current/source task or parent profile, then workflow and target-workspace defaults. workspace_default skips the creating session, current/source task, and parent profiles, honors workflow profiles first, then uses the target workspace default. An explicit agent_profile_id prevents creator-session runtime inheritance.
|
||||
- Creator-session runtime values are copied only when current_task selects that verified session profile. Executor and executor-profile inheritance from the current/source task or parent is unchanged by either saved agent-profile policy.
|
||||
- Every created task must have a resolvable agent profile. start_agent=false still records the profile for a later manual start.
|
||||
- Subtasks inherit the parent's repository unless you supply repository_url, repository_id, or local_path — in which case the subtask targets that repo instead
|
||||
- base_branch behaviour:
|
||||
@@ -1115,7 +1115,7 @@ IDEMPOTENCY (external_id):
|
||||
- deduplicated:true in the result means a task already held that identity and nothing new was created — do not report having created a task in that case.
|
||||
- deduplicated:true together with creation_complete:false means another create claimed the identity and had not finished when observed; it may still be running. Proceed with the returned task_id or escalate to a human — never release the identity and create again, which can produce a duplicate task and a duplicate agent.`
|
||||
parentDesc := "Parent task ID for subtasks. Use 'self' to create a subtask of your current task (RECOMMENDED for plan phases, delegated work). Omit only for unrelated top-level tasks."
|
||||
agentProfileDesc := "Agent profile ID to use. On a workflow step, the step's launch profile (its pinned profile, or the workflow default when unpinned) outranks it; otherwise an explicit agent_profile_id wins. When both are absent, current_task inherits the current/source or parent profile before workflow/workspace defaults; workspace_default skips those task profiles, then uses workflow profiles before the target workspace default. start_agent=false still needs a resolvable profile for later manual start."
|
||||
agentProfileDesc := "Agent profile ID to use. On a workflow step, the step's launch profile (its pinned profile, or the workflow default when unpinned) outranks it; otherwise an explicit agent_profile_id wins. When both are absent, current_task uses the verified creating session's profile and effective model, mode, and dynamic options for a session-bound call, then falls back to the current/source or parent profile without verified session context. workspace_default skips those task profiles and the creating session, then uses workflow profiles before the target workspace default. Explicit profiles do not copy creator-session runtime values. start_agent=false still needs a resolvable profile for later manual start."
|
||||
|
||||
if s.mode == ModeExternal {
|
||||
toolDesc = `Create a new top-level task and auto-start an agent on it.
|
||||
@@ -1123,7 +1123,7 @@ IDEMPOTENCY (external_id):
|
||||
IMPORTANT:
|
||||
- Provide a repository via repository_url, repository_id, or local_path
|
||||
- workspace_id and workflow_id are auto-resolved if only one exists; provide explicitly if ambiguous
|
||||
- A workflow step's launch profile outranks an explicit agent_profile_id when the task is on a step: that is the step's pinned profile, or the workflow default when the step has none. That profile is what launches, and it is the one reported back in the created task's metadata. Off a step, or when the step and workflow resolve no profile, an explicit agent_profile_id wins. When both are absent, the saved user policy applies: current_task inherits a parent profile before workflow and target-workspace defaults; workspace_default skips the parent profile, honors workflow profiles first, then uses the target workspace default. External mode has no current/source task.
|
||||
- A workflow step's launch profile outranks an explicit agent_profile_id when the task is on a step: that is the step's pinned profile, or the workflow default when the step has none. That profile is what launches, and it is the one reported back in the created task's metadata. Off a step, or when the step and workflow resolve no profile, an explicit agent_profile_id wins. When both are absent, the saved user policy applies: current_task uses the parent task profile because external mode has no creating session or current/source task context, then checks workflow and target-workspace defaults. workspace_default skips the parent profile, honors workflow profiles first, then uses the target workspace default. External mode has no creating session, so it never copies creator-session runtime values.
|
||||
- Executor and executor-profile inheritance from a parent is unchanged by either saved agent-profile policy.
|
||||
- Every created task must have a resolvable agent profile. start_agent=false still records the profile for a later manual start.
|
||||
- 'prompt' is the agent's initial prompt — be specific and detailed
|
||||
@@ -1135,7 +1135,7 @@ IDEMPOTENCY (external_id):
|
||||
- deduplicated:true in the result means a task already held that identity and nothing new was created — do not report having created a task in that case.
|
||||
- deduplicated:true together with creation_complete:false means another create claimed the identity and had not finished when observed; it may still be running. Proceed with the returned task_id or escalate to a human — never release the identity and create again, which can produce a duplicate task and a duplicate agent.`
|
||||
parentDesc = "Optional parent task ID. Omit for top-level tasks; provide an existing task ID only to create a subtask of that task."
|
||||
agentProfileDesc = "Agent profile ID to use. On a workflow step, the step's launch profile (its pinned profile, or the workflow default when unpinned) outranks it; otherwise an explicit agent_profile_id wins. When both are absent, current_task inherits a parent profile before workflow/workspace defaults; workspace_default skips the parent profile, then uses workflow profiles before the target workspace default. External mode has no current/source task. start_agent=false still needs a resolvable profile for later manual start."
|
||||
agentProfileDesc = "Agent profile ID to use. On a workflow step, the step's launch profile (its pinned profile, or the workflow default when unpinned) outranks it; otherwise an explicit agent_profile_id wins. When both are absent, current_task uses the parent task profile because external mode has no creating session or current/source task context; workspace_default skips the parent profile, then uses workflow profiles before the target workspace default. External mode never copies creator-session runtime values. start_agent=false still needs a resolvable profile for later manual start."
|
||||
}
|
||||
|
||||
s.mcpServer.AddTool(
|
||||
|
||||
@@ -984,6 +984,8 @@ func TestServerModeExternal_RegistersCorrectTools(t *testing.T) {
|
||||
assert.Contains(t, createTask.Tool.Description, "current_task")
|
||||
assert.Contains(t, createTask.Tool.Description, "workspace_default")
|
||||
assert.Contains(t, createTask.Tool.Description, "workflow profiles first")
|
||||
assert.Contains(t, createTask.Tool.Description, "no creating session")
|
||||
assert.Contains(t, createTask.Tool.Description, "parent task profile")
|
||||
|
||||
// External mode does NOT include session-scoped tools
|
||||
assert.NotContains(t, tools, "ask_user_question_kandev")
|
||||
|
||||
@@ -95,6 +95,13 @@ type officeTaskSessionCreator interface {
|
||||
CreateOfficeTaskSession(context.Context, *models.TaskSession) error
|
||||
}
|
||||
|
||||
// initialRuntimeSeedTaskSessionCreator lets repositories claim the launch-only
|
||||
// runtime seed and create the session in one transaction. Test and legacy
|
||||
// stores can omit it; the executor keeps its existing best-effort fallback.
|
||||
type initialRuntimeSeedTaskSessionCreator interface {
|
||||
CreateTaskSessionWithInitialRuntimeSeed(context.Context, *models.TaskSession) error
|
||||
}
|
||||
|
||||
type primarySessionTaskStateStore interface {
|
||||
UpdateTaskStateIfPrimarySessionState(
|
||||
context.Context,
|
||||
|
||||
@@ -748,6 +748,11 @@ func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfi
|
||||
}
|
||||
|
||||
metadata := cloneMetadata(task.Metadata)
|
||||
initialRuntimeConfig, hasInitialRuntimeConfig := models.LoadInitialSessionRuntimeConfig(task.Metadata)
|
||||
delete(metadata, models.MetaKeyInitialSessionRuntimeConfig)
|
||||
delete(metadata, models.MetaKeyInitialSessionRuntimeConfigProfileID)
|
||||
initialRuntimeConfigProfileID := models.LoadInitialSessionRuntimeConfigProfileID(task.Metadata)
|
||||
_, hasAtomicInitialRuntimeSeed := e.repo.(initialRuntimeSeedTaskSessionCreator)
|
||||
var repositoryID string
|
||||
var baseBranch string
|
||||
|
||||
@@ -792,6 +797,9 @@ func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfi
|
||||
metadata = make(map[string]interface{})
|
||||
}
|
||||
metadata[models.SessionMetaKeyOrigin] = models.SessionOriginTaskInitial
|
||||
if hasInitialRuntimeConfig && initialRuntimeConfigProfileID == agentProfileID && !hasAtomicInitialRuntimeSeed {
|
||||
metadata[models.SessionMetaKeyRuntimeConfigOverrides] = initialRuntimeConfig
|
||||
}
|
||||
}
|
||||
|
||||
// Create agent session in database. WorkspacePath is propagated from task
|
||||
@@ -831,11 +839,17 @@ func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfi
|
||||
session.ExecutorID = execConfig.ExecutorID
|
||||
}
|
||||
|
||||
if err := e.repo.CreateTaskSession(ctx, session); err != nil {
|
||||
var createErr error
|
||||
if atomicCreator, ok := e.repo.(initialRuntimeSeedTaskSessionCreator); ok {
|
||||
createErr = atomicCreator.CreateTaskSessionWithInitialRuntimeSeed(ctx, session)
|
||||
} else {
|
||||
createErr = e.repo.CreateTaskSession(ctx, session)
|
||||
}
|
||||
if createErr != nil {
|
||||
e.logger.Error("failed to persist agent session",
|
||||
zap.String("task_id", task.ID),
|
||||
zap.Error(err))
|
||||
return "", err
|
||||
zap.Error(createErr))
|
||||
return "", createErr
|
||||
}
|
||||
|
||||
// Set primary flag only for the first session (no existing primary).
|
||||
|
||||
@@ -211,6 +211,122 @@ func TestPrepareSessionSnapshotsProfileRuntimeConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareSessionConsumesInitialRuntimeSeedOnlyForFirstSession(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
executor := newTestExecutor(t, &mockAgentManager{}, repo)
|
||||
seed := models.SessionRuntimeConfig{
|
||||
Model: "gpt-5.6-sol",
|
||||
Mode: "acceptEdits",
|
||||
ConfigOptions: map[string]string{"reasoning_effort": "high"},
|
||||
}
|
||||
task := &v1.Task{
|
||||
ID: "task-runtime-seed",
|
||||
WorkspaceID: "workspace-123",
|
||||
Title: "Runtime seed task",
|
||||
Metadata: map[string]interface{}{
|
||||
models.MetaKeyInitialSessionRuntimeConfig: seed,
|
||||
models.MetaKeyInitialSessionRuntimeConfigProfileID: "profile-123",
|
||||
},
|
||||
}
|
||||
|
||||
firstID, err := executor.PrepareSession(
|
||||
context.Background(), task, "profile-123", "executor-123", "", "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareSession first: %v", err)
|
||||
}
|
||||
first, err := repo.GetTaskSession(context.Background(), firstID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTaskSession first: %v", err)
|
||||
}
|
||||
firstOverrides, ok := models.LoadSessionRuntimeConfigOverrides(first.Metadata)
|
||||
if !ok || firstOverrides.Model != seed.Model || firstOverrides.Mode != seed.Mode {
|
||||
t.Fatalf("first runtime overrides = %#v, want %#v", firstOverrides, seed)
|
||||
}
|
||||
if firstOverrides.ConfigOptions["reasoning_effort"] != "high" {
|
||||
t.Fatalf("first runtime options = %#v", firstOverrides.ConfigOptions)
|
||||
}
|
||||
if _, exists := first.Metadata[models.MetaKeyInitialSessionRuntimeConfig]; exists {
|
||||
t.Fatalf("launch-only seed leaked into first session metadata: %#v", first.Metadata)
|
||||
}
|
||||
if _, exists := first.Metadata[models.MetaKeyInitialSessionRuntimeConfigProfileID]; exists {
|
||||
t.Fatalf("launch-only seed owner leaked into first session metadata: %#v", first.Metadata)
|
||||
}
|
||||
|
||||
secondID, err := executor.PrepareSession(
|
||||
context.Background(), task, "profile-123", "executor-123", "", "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareSession second: %v", err)
|
||||
}
|
||||
second, err := repo.GetTaskSession(context.Background(), secondID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTaskSession second: %v", err)
|
||||
}
|
||||
if _, ok := models.LoadSessionRuntimeConfigOverrides(second.Metadata); ok {
|
||||
t.Fatalf("second session unexpectedly received initial runtime overrides: %#v", second.Metadata)
|
||||
}
|
||||
if _, exists := second.Metadata[models.MetaKeyInitialSessionRuntimeConfig]; exists {
|
||||
t.Fatalf("launch-only seed leaked into second session metadata: %#v", second.Metadata)
|
||||
}
|
||||
if _, exists := second.Metadata[models.MetaKeyInitialSessionRuntimeConfigProfileID]; exists {
|
||||
t.Fatalf("launch-only seed owner leaked into second session metadata: %#v", second.Metadata)
|
||||
}
|
||||
|
||||
firstOverrides.ConfigOptions["reasoning_effort"] = "low"
|
||||
if seed.ConfigOptions["reasoning_effort"] != "high" {
|
||||
t.Fatalf("seed options aliased prepared session: %#v", seed.ConfigOptions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareSessionDoesNotApplyInitialRuntimeSeedAfterProfileSelectionChanges(t *testing.T) {
|
||||
seed := models.SessionRuntimeConfig{
|
||||
Model: "gpt-5.6-sol",
|
||||
Mode: "acceptEdits",
|
||||
ConfigOptions: map[string]string{"reasoning_effort": "high"},
|
||||
}
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
selectedProfile string
|
||||
}{
|
||||
{name: "explicit profile", selectedProfile: "explicit-profile"},
|
||||
{name: "workflow profile", selectedProfile: "workflow-profile"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
executor := newTestExecutor(t, &mockAgentManager{}, repo)
|
||||
task := &v1.Task{
|
||||
ID: "task-runtime-seed-" + testCase.selectedProfile,
|
||||
WorkspaceID: "workspace-123",
|
||||
Title: "Changed initial runtime seed profile",
|
||||
Metadata: map[string]interface{}{
|
||||
models.MetaKeyInitialSessionRuntimeConfig: seed,
|
||||
models.MetaKeyInitialSessionRuntimeConfigProfileID: "creator-profile",
|
||||
},
|
||||
}
|
||||
|
||||
sessionID, err := executor.PrepareSession(
|
||||
context.Background(), task, testCase.selectedProfile, "executor-123", "", "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareSession: %v", err)
|
||||
}
|
||||
created, err := repo.GetTaskSession(context.Background(), sessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTaskSession: %v", err)
|
||||
}
|
||||
_, hasOverrides := models.LoadSessionRuntimeConfigOverrides(created.Metadata)
|
||||
if hasOverrides {
|
||||
t.Fatalf("changed profile must not receive the creator runtime seed: %#v", created.Metadata)
|
||||
}
|
||||
_, hasOwner := created.Metadata[models.MetaKeyInitialSessionRuntimeConfigProfileID]
|
||||
if hasOwner {
|
||||
t.Fatalf("launch-only seed owner must not leak into the session: %#v", created.Metadata)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistRuntimeModelMetadataStoresRuntimeConfigAndClearsContextWindow(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
exec := newTestExecutor(t, &mockAgentManager{}, repo)
|
||||
|
||||
@@ -139,6 +139,14 @@ const (
|
||||
MetaKeyParentQuestionChildID = "child_task_id"
|
||||
MetaKeyParentQuestionStatus = "parent_question_status"
|
||||
MetaKeyParentQuestionResponse = "parent_question_response"
|
||||
// MetaKeyInitialSessionRuntimeConfig is a launch-only seed for the first
|
||||
// session created for a task. PrepareSession consumes it into session
|
||||
// runtime overrides and never leaves it in session metadata.
|
||||
MetaKeyInitialSessionRuntimeConfig = "initial_session_runtime_config"
|
||||
// MetaKeyInitialSessionRuntimeConfigProfileID identifies the agent profile
|
||||
// that produced the launch-only runtime seed. A seed is valid only for this
|
||||
// profile, even if task profile selection changes before the first launch.
|
||||
MetaKeyInitialSessionRuntimeConfigProfileID = "initial_session_runtime_config_profile_id"
|
||||
)
|
||||
|
||||
// IsAgentTitlePending reports whether task metadata contains the durable
|
||||
@@ -241,6 +249,98 @@ type SessionRuntimeConfig struct {
|
||||
ConfigOptions map[string]string `json:"config_options,omitempty"`
|
||||
}
|
||||
|
||||
// LoadInitialSessionRuntimeConfig decodes the task launch seed from typed or
|
||||
// JSON-rehydrated task metadata.
|
||||
func LoadInitialSessionRuntimeConfig(metadata map[string]interface{}) (SessionRuntimeConfig, bool) {
|
||||
return loadSessionRuntimeConfig(metadata, MetaKeyInitialSessionRuntimeConfig)
|
||||
}
|
||||
|
||||
// LoadInitialSessionRuntimeConfigProfileID returns the profile that owns the
|
||||
// launch-only runtime seed. Older tasks did not store a separate owner, so
|
||||
// their resolved task profile remains a compatibility fallback.
|
||||
func LoadInitialSessionRuntimeConfigProfileID(metadata map[string]interface{}) string {
|
||||
if metadata == nil {
|
||||
return ""
|
||||
}
|
||||
if profileID := StringFromAny(metadata[MetaKeyInitialSessionRuntimeConfigProfileID]); profileID != "" {
|
||||
return profileID
|
||||
}
|
||||
return StringFromAny(metadata[MetaKeyAgentProfileID])
|
||||
}
|
||||
|
||||
// LoadEffectiveSessionRuntimeConfig resolves the effective model, mode, and
|
||||
// dynamic options for a task session. The profile snapshot is the base, the
|
||||
// provider runtime state replaces it, the persisted session mode takes
|
||||
// precedence over provider mode, and explicit runtime overrides win last.
|
||||
func LoadEffectiveSessionRuntimeConfig(session *TaskSession) (SessionRuntimeConfig, bool) {
|
||||
if session == nil {
|
||||
return SessionRuntimeConfig{}, false
|
||||
}
|
||||
effective := runtimeConfigFromAgentProfileSnapshot(session.AgentProfileSnapshot)
|
||||
if runtime, ok := LoadSessionRuntimeConfig(session.Metadata); ok {
|
||||
mergeSessionRuntimeConfig(&effective, runtime)
|
||||
if runtime.ConfigOptions != nil {
|
||||
// Provider runtime options are a complete replacement for profile
|
||||
// options. Explicit overrides below are the only later merge.
|
||||
effective.ConfigOptions = maps.Clone(runtime.ConfigOptions)
|
||||
}
|
||||
}
|
||||
if mode := StringFromAny(session.Metadata[SessionMetaKeySessionMode]); mode != "" {
|
||||
effective.Mode = mode
|
||||
}
|
||||
if overrides, ok := LoadSessionRuntimeConfigOverrides(session.Metadata); ok {
|
||||
mergeSessionRuntimeConfig(&effective, overrides)
|
||||
}
|
||||
effective.ConfigOptions = cleanRuntimeConfigOptions(effective.ConfigOptions)
|
||||
return effective, !effective.IsZero()
|
||||
}
|
||||
|
||||
func runtimeConfigFromAgentProfileSnapshot(snapshot map[string]interface{}) SessionRuntimeConfig {
|
||||
if snapshot == nil {
|
||||
return SessionRuntimeConfig{}
|
||||
}
|
||||
config := SessionRuntimeConfig{
|
||||
Model: StringFromAny(snapshot["model"]),
|
||||
Mode: StringFromAny(snapshot["mode"]),
|
||||
}
|
||||
config.ConfigOptions = stringMapFromAny(snapshot["config_options"])
|
||||
if config.ConfigOptions == nil {
|
||||
config.ConfigOptions = stringMapFromAny(snapshot["configOptions"])
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func mergeSessionRuntimeConfig(target *SessionRuntimeConfig, source SessionRuntimeConfig) {
|
||||
if source.Model != "" {
|
||||
target.Model = source.Model
|
||||
}
|
||||
if source.Mode != "" {
|
||||
target.Mode = source.Mode
|
||||
}
|
||||
if len(source.ConfigOptions) == 0 {
|
||||
return
|
||||
}
|
||||
if target.ConfigOptions == nil {
|
||||
target.ConfigOptions = make(map[string]string, len(source.ConfigOptions))
|
||||
}
|
||||
for key, value := range source.ConfigOptions {
|
||||
target.ConfigOptions[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
func cleanRuntimeConfigOptions(options map[string]string) map[string]string {
|
||||
if len(options) == 0 {
|
||||
return nil
|
||||
}
|
||||
cleaned := maps.Clone(options)
|
||||
delete(cleaned, "model")
|
||||
delete(cleaned, "mode")
|
||||
if len(cleaned) == 0 {
|
||||
return nil
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// SessionOriginalEffectiveConfiguration is the immutable configuration a task
|
||||
// session had after provider defaults and its profile settings settled.
|
||||
type SessionOriginalEffectiveConfiguration struct {
|
||||
@@ -433,6 +533,7 @@ func loadSessionRuntimeConfig(metadata map[string]interface{}, key string) (Sess
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case SessionRuntimeConfig:
|
||||
v.ConfigOptions = maps.Clone(v.ConfigOptions)
|
||||
return v, !v.IsZero()
|
||||
case map[string]string:
|
||||
out := SessionRuntimeConfig{
|
||||
|
||||
@@ -54,6 +54,22 @@ func TestLoadSessionRuntimeConfigOverridesUsesDedicatedKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSessionRuntimeConfigTypedValueClonesOptions(t *testing.T) {
|
||||
metadata := map[string]interface{}{
|
||||
SessionMetaKeyRuntimeConfig: SessionRuntimeConfig{
|
||||
Model: "gpt-5.6-sol",
|
||||
ConfigOptions: map[string]string{"reasoning_effort": "high"},
|
||||
},
|
||||
}
|
||||
|
||||
config, ok := LoadSessionRuntimeConfig(metadata)
|
||||
require.True(t, ok)
|
||||
config.ConfigOptions["reasoning_effort"] = "low"
|
||||
|
||||
stored := metadata[SessionMetaKeyRuntimeConfig].(SessionRuntimeConfig)
|
||||
require.Equal(t, "high", stored.ConfigOptions["reasoning_effort"])
|
||||
}
|
||||
|
||||
func TestLoadOriginalSessionEffectiveConfiguration(t *testing.T) {
|
||||
metadata := map[string]interface{}{
|
||||
SessionMetaKeyOriginalEffectiveConfig: map[string]interface{}{
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoadEffectiveSessionRuntimeConfigMergesInPrecedenceOrder(t *testing.T) {
|
||||
session := &TaskSession{
|
||||
AgentProfileSnapshot: map[string]interface{}{
|
||||
"model": "profile-model",
|
||||
"mode": "profile-mode",
|
||||
"config_options": map[string]string{
|
||||
"profile_only": "profile",
|
||||
"provider_replaced": "profile-value",
|
||||
},
|
||||
},
|
||||
Metadata: map[string]interface{}{
|
||||
SessionMetaKeyRuntimeConfig: SessionRuntimeConfig{
|
||||
Model: "provider-model",
|
||||
Mode: "provider-mode",
|
||||
ConfigOptions: map[string]string{
|
||||
"model": "provider-model",
|
||||
"mode": "provider-mode",
|
||||
"provider_replaced": "provider-value",
|
||||
"provider_only": "provider",
|
||||
},
|
||||
},
|
||||
SessionMetaKeySessionMode: "session-mode",
|
||||
SessionMetaKeyRuntimeConfigOverrides: SessionRuntimeConfig{
|
||||
Model: "override-model",
|
||||
Mode: "override-mode",
|
||||
ConfigOptions: map[string]string{
|
||||
"model": "override-model",
|
||||
"mode": "override-mode",
|
||||
"provider_replaced": "override-value",
|
||||
"override_only": "override",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, ok := LoadEffectiveSessionRuntimeConfig(session)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "override-model", got.Model)
|
||||
require.Equal(t, "override-mode", got.Mode)
|
||||
require.Equal(t, map[string]string{
|
||||
"provider_replaced": "override-value",
|
||||
"provider_only": "provider",
|
||||
"override_only": "override",
|
||||
}, got.ConfigOptions)
|
||||
}
|
||||
|
||||
func TestLoadEffectiveSessionRuntimeConfigReturnsIndependentOptions(t *testing.T) {
|
||||
profileOptions := map[string]string{"profile": "value"}
|
||||
providerOptions := map[string]string{"provider": "value"}
|
||||
overrideOptions := map[string]string{"override": "value"}
|
||||
session := &TaskSession{
|
||||
AgentProfileSnapshot: map[string]interface{}{"config_options": profileOptions},
|
||||
Metadata: map[string]interface{}{
|
||||
SessionMetaKeyRuntimeConfig: SessionRuntimeConfig{ConfigOptions: providerOptions},
|
||||
SessionMetaKeyRuntimeConfigOverrides: SessionRuntimeConfig{ConfigOptions: overrideOptions},
|
||||
},
|
||||
}
|
||||
|
||||
got, ok := LoadEffectiveSessionRuntimeConfig(session)
|
||||
require.True(t, ok)
|
||||
got.ConfigOptions["profile"] = "changed"
|
||||
got.ConfigOptions["provider"] = "changed"
|
||||
got.ConfigOptions["override"] = "changed"
|
||||
|
||||
require.Equal(t, "value", profileOptions["profile"])
|
||||
require.Equal(t, "value", providerOptions["provider"])
|
||||
require.Equal(t, "value", overrideOptions["override"])
|
||||
}
|
||||
|
||||
func TestLoadEffectiveSessionRuntimeConfigAcceptsJSONRehydratedMetadata(t *testing.T) {
|
||||
got, ok := LoadEffectiveSessionRuntimeConfig(&TaskSession{
|
||||
AgentProfileSnapshot: map[string]interface{}{
|
||||
"model": "profile-model",
|
||||
"configOptions": map[string]interface{}{
|
||||
"effort": "medium",
|
||||
"profile_only": "stale",
|
||||
},
|
||||
},
|
||||
Metadata: map[string]interface{}{
|
||||
SessionMetaKeyRuntimeConfig: map[string]interface{}{
|
||||
"model": "provider-model",
|
||||
"config_options": map[string]interface{}{"effort": "high"},
|
||||
},
|
||||
SessionMetaKeySessionMode: "acceptEdits",
|
||||
SessionMetaKeyRuntimeConfigOverrides: map[string]interface{}{
|
||||
"config_options": map[string]interface{}{"effort": "low"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "provider-model", got.Model)
|
||||
require.Equal(t, "acceptEdits", got.Mode)
|
||||
require.Equal(t, map[string]string{"effort": "low"}, got.ConfigOptions)
|
||||
}
|
||||
@@ -231,6 +231,90 @@ func (r *Repository) CreateTaskSession(ctx context.Context, session *models.Task
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// CreateTaskSessionWithInitialRuntimeSeed claims the task's launch-only
|
||||
// runtime seed while creating the session. The task lock / SQLite writer
|
||||
// transaction makes the session count, seed read, session insert, and seed
|
||||
// removal one serialized operation, so a concurrent launch or replacement
|
||||
// session cannot inherit the seed a second time.
|
||||
func (r *Repository) CreateTaskSessionWithInitialRuntimeSeed(ctx context.Context, session *models.TaskSession) error {
|
||||
tx, err := r.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if err := r.taskCleanupBarrierLocked(ctx, tx, session.TaskID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
initialRuntimeConfig, hasInitialRuntimeConfig, initialRuntimeConfigProfileID, hasInitialRuntimeSeedKey, err := r.loadInitialSessionRuntimeSeedTx(ctx, tx, session.TaskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var sessionCount int
|
||||
if err := tx.QueryRowContext(ctx, r.db.Rebind(
|
||||
`SELECT COUNT(*) FROM task_sessions WHERE task_id = ?`,
|
||||
), session.TaskID).Scan(&sessionCount); err != nil {
|
||||
return fmt.Errorf("check task sessions before initial runtime session: %w", err)
|
||||
}
|
||||
if sessionCount == 0 {
|
||||
if session.Metadata == nil {
|
||||
session.Metadata = make(map[string]interface{})
|
||||
}
|
||||
session.Metadata[models.SessionMetaKeyOrigin] = models.SessionOriginTaskInitial
|
||||
if hasInitialRuntimeConfig && initialRuntimeConfigProfileID == session.AgentProfileID {
|
||||
session.Metadata[models.SessionMetaKeyRuntimeConfigOverrides] = initialRuntimeConfig
|
||||
}
|
||||
} else if models.IsOriginalTaskSession(session.Metadata) {
|
||||
// PrepareSession performs a read before this transaction. If another
|
||||
// launch won the race, do not persist the stale origin marker.
|
||||
delete(session.Metadata, models.SessionMetaKeyOrigin)
|
||||
}
|
||||
|
||||
if err := r.createTaskSession(ctx, tx, session); err != nil {
|
||||
return err
|
||||
}
|
||||
if hasInitialRuntimeSeedKey {
|
||||
if _, err := r.removeTaskMetadataKeyWithExecutor(ctx, tx, session.TaskID, models.MetaKeyInitialSessionRuntimeConfig); err != nil {
|
||||
return fmt.Errorf("consume initial runtime seed: %w", err)
|
||||
}
|
||||
if _, err := r.removeTaskMetadataKeyWithExecutor(ctx, tx, session.TaskID, models.MetaKeyInitialSessionRuntimeConfigProfileID); err != nil {
|
||||
return fmt.Errorf("consume initial runtime seed profile: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *Repository) loadInitialSessionRuntimeSeedTx(
|
||||
ctx context.Context,
|
||||
tx *sqlx.Tx,
|
||||
taskID string,
|
||||
) (models.SessionRuntimeConfig, bool, string, bool, error) {
|
||||
var metadataJSON sql.NullString
|
||||
err := tx.QueryRowContext(ctx, r.db.Rebind(
|
||||
`SELECT metadata FROM tasks WHERE id = ?`,
|
||||
), taskID).Scan(&metadataJSON)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return models.SessionRuntimeConfig{}, false, "", false, fmt.Errorf("task not found: %s", taskID)
|
||||
}
|
||||
if err != nil {
|
||||
return models.SessionRuntimeConfig{}, false, "", false, fmt.Errorf("load task metadata for initial runtime seed: %w", err)
|
||||
}
|
||||
|
||||
metadata := make(map[string]interface{})
|
||||
raw := strings.TrimSpace(metadataJSON.String)
|
||||
if metadataJSON.Valid && raw != "" && raw != "null" && raw != "{}" {
|
||||
if err := json.Unmarshal([]byte(raw), &metadata); err != nil {
|
||||
return models.SessionRuntimeConfig{}, false, "", false, fmt.Errorf("decode task metadata for initial runtime seed: %w", err)
|
||||
}
|
||||
}
|
||||
seed, ok := models.LoadInitialSessionRuntimeConfig(metadata)
|
||||
profileID := models.LoadInitialSessionRuntimeConfigProfileID(metadata)
|
||||
_, hasSeedKey := metadata[models.MetaKeyInitialSessionRuntimeConfig]
|
||||
return seed, ok, profileID, hasSeedKey, nil
|
||||
}
|
||||
|
||||
// CreateOfficeTaskSession creates an Office session and atomically marks it as
|
||||
// the task's initial session when no earlier session exists. The task row lock
|
||||
// serializes callers across PostgreSQL connections; SQLite's single writer
|
||||
|
||||
@@ -171,6 +171,122 @@ func TestCreateOfficeTaskSessionMarksOnlyTheFirstConcurrentSessionAsOrigin(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTaskSessionWithInitialRuntimeSeedConsumesOnceAcrossConcurrentAndReplacementSessions(t *testing.T) {
|
||||
repo := newRepoForSessionTests(t)
|
||||
ctx := context.Background()
|
||||
const taskID = "task-initial-runtime-seed-race"
|
||||
seed := models.SessionRuntimeConfig{
|
||||
Model: "mock-smart",
|
||||
Mode: "plan-mock",
|
||||
ConfigOptions: map[string]string{"effort": "max"},
|
||||
}
|
||||
require.NoError(t, repo.CreateTask(ctx, &models.Task{
|
||||
ID: taskID,
|
||||
Title: "Initial runtime seed race",
|
||||
Metadata: map[string]interface{}{
|
||||
models.MetaKeyInitialSessionRuntimeConfig: seed,
|
||||
models.MetaKeyInitialSessionRuntimeConfigProfileID: "profile-1",
|
||||
},
|
||||
}))
|
||||
|
||||
sessions := []*models.TaskSession{
|
||||
{ID: "initial-runtime-session-1", TaskID: taskID, AgentProfileID: "profile-1", State: models.TaskSessionStateCreated},
|
||||
{ID: "initial-runtime-session-2", TaskID: taskID, AgentProfileID: "profile-1", State: models.TaskSessionStateCreated},
|
||||
}
|
||||
errs := make([]error, len(sessions))
|
||||
var wg sync.WaitGroup
|
||||
for i, session := range sessions {
|
||||
wg.Add(1)
|
||||
go func(i int, session *models.TaskSession) {
|
||||
defer wg.Done()
|
||||
errs[i] = repo.CreateTaskSessionWithInitialRuntimeSeed(ctx, session)
|
||||
}(i, session)
|
||||
}
|
||||
wg.Wait()
|
||||
for i, err := range errs {
|
||||
require.NoError(t, err, "CreateTaskSessionWithInitialRuntimeSeed(%d)", i)
|
||||
}
|
||||
|
||||
created, err := repo.ListTaskSessions(ctx, taskID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, created, len(sessions))
|
||||
initialCount := 0
|
||||
initialSessionID := ""
|
||||
for _, session := range created {
|
||||
if overrides, ok := models.LoadSessionRuntimeConfigOverrides(session.Metadata); ok {
|
||||
initialCount++
|
||||
initialSessionID = session.ID
|
||||
require.Equal(t, seed.Model, overrides.Model)
|
||||
require.Equal(t, seed.Mode, overrides.Mode)
|
||||
require.Equal(t, "max", overrides.ConfigOptions["effort"])
|
||||
}
|
||||
}
|
||||
require.Equal(t, 1, initialCount)
|
||||
|
||||
task, err := repo.GetTask(ctx, taskID)
|
||||
require.NoError(t, err)
|
||||
if _, ok := task.Metadata[models.MetaKeyInitialSessionRuntimeConfig]; ok {
|
||||
t.Fatalf("initial runtime seed remained in task metadata: %#v", task.Metadata)
|
||||
}
|
||||
if _, ok := task.Metadata[models.MetaKeyInitialSessionRuntimeConfigProfileID]; ok {
|
||||
t.Fatalf("initial runtime seed profile remained in task metadata: %#v", task.Metadata)
|
||||
}
|
||||
|
||||
require.NoError(t, repo.DeleteTaskSession(ctx, initialSessionID))
|
||||
replacement := &models.TaskSession{
|
||||
ID: "initial-runtime-session-replacement",
|
||||
TaskID: taskID,
|
||||
AgentProfileID: "profile-replacement",
|
||||
State: models.TaskSessionStateCreated,
|
||||
}
|
||||
require.NoError(t, repo.CreateTaskSessionWithInitialRuntimeSeed(ctx, replacement))
|
||||
createdReplacement, err := repo.GetTaskSession(ctx, replacement.ID)
|
||||
require.NoError(t, err)
|
||||
if _, ok := models.LoadSessionRuntimeConfigOverrides(createdReplacement.Metadata); ok {
|
||||
t.Fatalf("replacement session unexpectedly received initial runtime overrides: %#v", createdReplacement.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTaskSessionWithInitialRuntimeSeedConsumesMismatchedProfile(t *testing.T) {
|
||||
repo := newRepoForSessionTests(t)
|
||||
ctx := context.Background()
|
||||
const taskID = "task-initial-runtime-seed-profile-mismatch"
|
||||
seed := models.SessionRuntimeConfig{
|
||||
Model: "mock-smart",
|
||||
Mode: "plan-mock",
|
||||
ConfigOptions: map[string]string{"effort": "max"},
|
||||
}
|
||||
require.NoError(t, repo.CreateTask(ctx, &models.Task{
|
||||
ID: taskID,
|
||||
Title: "Initial runtime seed profile mismatch",
|
||||
Metadata: map[string]interface{}{
|
||||
models.MetaKeyInitialSessionRuntimeConfig: seed,
|
||||
models.MetaKeyInitialSessionRuntimeConfigProfileID: "profile-owner",
|
||||
models.MetaKeyAgentProfileID: "profile-selected",
|
||||
},
|
||||
}))
|
||||
|
||||
mismatched := &models.TaskSession{
|
||||
ID: "initial-runtime-session-mismatched-profile",
|
||||
TaskID: taskID,
|
||||
AgentProfileID: "profile-selected",
|
||||
State: models.TaskSessionStateCreated,
|
||||
}
|
||||
require.NoError(t, repo.CreateTaskSessionWithInitialRuntimeSeed(ctx, mismatched))
|
||||
|
||||
created, err := repo.GetTaskSession(ctx, mismatched.ID)
|
||||
require.NoError(t, err)
|
||||
_, hasOverrides := models.LoadSessionRuntimeConfigOverrides(created.Metadata)
|
||||
require.False(t, hasOverrides, "mismatched profile must not receive the creator runtime seed")
|
||||
|
||||
task, err := repo.GetTask(ctx, taskID)
|
||||
require.NoError(t, err)
|
||||
_, hasSeed := task.Metadata[models.MetaKeyInitialSessionRuntimeConfig]
|
||||
require.False(t, hasSeed, "mismatched profile must consume the launch-only seed")
|
||||
_, hasOwner := task.Metadata[models.MetaKeyInitialSessionRuntimeConfigProfileID]
|
||||
require.False(t, hasOwner, "mismatched profile must consume the seed owner")
|
||||
}
|
||||
|
||||
func TestPostgresCreateOfficeTaskSessionMarksOnlyTheFirstConcurrentSessionAsOrigin(t *testing.T) {
|
||||
db := openIsolatedPostgresMultiConn(t, testutil.PostgresDSNFromEnv(t), 2)
|
||||
repo, err := NewWithDB(db, db, nil)
|
||||
|
||||
@@ -517,6 +517,14 @@ func (r *Repository) UpdateTask(ctx context.Context, task *models.Task) error {
|
||||
// RemoveTaskMetadataKey removes one metadata key without replacing concurrent
|
||||
// task fields. It returns whether the key was present and removed.
|
||||
func (r *Repository) RemoveTaskMetadataKey(ctx context.Context, taskID, key string) (bool, error) {
|
||||
return r.removeTaskMetadataKeyWithExecutor(ctx, r.db, taskID, key)
|
||||
}
|
||||
|
||||
func (r *Repository) removeTaskMetadataKeyWithExecutor(
|
||||
ctx context.Context,
|
||||
exec taskSessionExecutor,
|
||||
taskID, key string,
|
||||
) (bool, error) {
|
||||
var query string
|
||||
if dialect.IsPostgres(r.db.DriverName()) {
|
||||
query = `
|
||||
@@ -533,7 +541,7 @@ func (r *Repository) RemoveTaskMetadataKey(ctx context.Context, taskID, key stri
|
||||
if dialect.IsPostgres(r.db.DriverName()) {
|
||||
path = key
|
||||
}
|
||||
result, err := r.db.ExecContext(ctx, r.db.Rebind(query), path, time.Now().UTC(), taskID, path)
|
||||
result, err := exec.ExecContext(ctx, r.db.Rebind(query), path, time.Now().UTC(), taskID, path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -98,13 +98,7 @@ func runtimeConfigSnapshotMetadata(session *models.TaskSession) map[string]inter
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
effective, _ := models.LoadEffectiveSessionRuntimeConfig(session)
|
||||
baseline, _ := models.LoadSessionACPConfigBaseline(session.Metadata)
|
||||
result := models.TurnRuntimeConfigSnapshot{
|
||||
Model: effective.Model,
|
||||
@@ -164,20 +158,18 @@ func selectedTurnConfigOption(
|
||||
}, true
|
||||
}
|
||||
|
||||
func runtimeConfigFromProfileSnapshot(snapshot map[string]interface{}) models.SessionRuntimeConfig {
|
||||
config := models.SessionRuntimeConfig{}
|
||||
if snapshot == nil {
|
||||
return config
|
||||
func selectedConfigValueName(options []streams.ConfigOptionValue, value string) string {
|
||||
for _, option := range options {
|
||||
if option.Value == value {
|
||||
return option.Name
|
||||
}
|
||||
}
|
||||
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
|
||||
return value
|
||||
}
|
||||
|
||||
// stringConfigOptions decodes the profile option shapes used by persisted
|
||||
// task-service metadata. Keep this package-local decoder available for the
|
||||
// service coverage tests that exercise both in-memory and JSON-like values.
|
||||
func stringConfigOptions(raw interface{}) map[string]string {
|
||||
switch values := raw.(type) {
|
||||
case map[string]string:
|
||||
@@ -195,33 +187,6 @@ func stringConfigOptions(raw interface{}) map[string]string {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -142,6 +142,61 @@ func TestBuildTurnRuntimeConfigSnapshotFallsBackToSelectorModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTurnRuntimeConfigSnapshotUsesSessionModeOverRuntimeMode(t *testing.T) {
|
||||
snapshot := buildTurnRuntimeConfigSnapshot(&models.TaskSession{
|
||||
AgentProfileSnapshot: map[string]interface{}{
|
||||
"model": "profile-model",
|
||||
"mode": "profile-mode",
|
||||
"config_options": map[string]string{
|
||||
"reasoning_effort": "medium",
|
||||
},
|
||||
},
|
||||
Metadata: map[string]interface{}{
|
||||
models.SessionMetaKeyRuntimeConfig: models.SessionRuntimeConfig{
|
||||
Model: "runtime-model",
|
||||
Mode: "runtime-mode",
|
||||
ConfigOptions: map[string]string{
|
||||
"reasoning_effort": "high",
|
||||
"collaboration_mode": "default",
|
||||
},
|
||||
},
|
||||
models.SessionMetaKeySessionMode: "acceptEdits",
|
||||
models.SessionMetaKeyRuntimeConfigOverrides: models.SessionRuntimeConfig{
|
||||
ConfigOptions: map[string]string{"reasoning_effort": "low"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if snapshot.Model != "runtime-model" || snapshot.Mode != "acceptEdits" {
|
||||
t.Fatalf("snapshot model/mode = %q/%q", snapshot.Model, snapshot.Mode)
|
||||
}
|
||||
options := make(map[string]string, len(snapshot.ConfigOptions))
|
||||
for _, option := range snapshot.ConfigOptions {
|
||||
options[option.ID] = option.Value
|
||||
}
|
||||
require.Equal(t, "low", options["reasoning_effort"])
|
||||
require.Equal(t, "default", options["collaboration_mode"])
|
||||
}
|
||||
|
||||
func TestStringConfigOptionsCompatibility(t *testing.T) {
|
||||
typed := stringConfigOptions(map[string]string{"effort": "high"})
|
||||
if typed["effort"] != "high" {
|
||||
t.Fatalf("typed options = %#v", typed)
|
||||
}
|
||||
|
||||
loose := stringConfigOptions(map[string]interface{}{"effort": "low", "ignored": 42})
|
||||
if loose["effort"] != "low" {
|
||||
t.Fatalf("loose options = %#v", loose)
|
||||
}
|
||||
if _, ok := loose["ignored"]; ok {
|
||||
t.Fatalf("loose options retained non-string value: %#v", loose)
|
||||
}
|
||||
|
||||
if got := stringConfigOptions(nil); got != nil {
|
||||
t.Fatalf("nil options = %#v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func (nilTaskSessionRepo) GetTaskSession(context.Context, string) (*models.TaskSession, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { SettingsSaveProvider } from "./settings-save-provider";
|
||||
|
||||
const updateUserSettings = vi.fn();
|
||||
const ARIA_CHECKED = "aria-checked";
|
||||
const CURRENT_TASK_LABEL = "Current task profile";
|
||||
const CURRENT_TASK_LABEL = "Creating session profile";
|
||||
const WORKSPACE_DEFAULT_LABEL = "Workspace default profile";
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
@@ -63,10 +63,11 @@ describe("MCPTaskAgentProfileDefaultSettings", () => {
|
||||
expect(
|
||||
screen.getByRole("radio", { name: WORKSPACE_DEFAULT_LABEL }).getAttribute(ARIA_CHECKED),
|
||||
).toBe("false");
|
||||
screen.getByText(/follow-up work needs the same model and agent setup/i);
|
||||
screen.getByText(/may reuse a more expensive profile/i);
|
||||
screen.getByText(/workflow profile when one is set/i);
|
||||
screen.getByText(/keep agent-created tasks on your standard workspace model/i);
|
||||
screen.getByText(/effective model, mode, and options/i);
|
||||
screen.getByText(/workflow-selected profiles win first/i);
|
||||
screen.getByText(/higher-cost setup/i);
|
||||
screen.getByText(/skips the creating session and source or parent task profiles/i);
|
||||
screen.getByText(/consistent cost policy/i);
|
||||
});
|
||||
|
||||
it("explains why session creation does not use this preference", async () => {
|
||||
|
||||
@@ -1932,6 +1932,8 @@ export class ApiClient {
|
||||
id: string;
|
||||
task_id: string;
|
||||
agent_profile_id?: string;
|
||||
executor_id?: string;
|
||||
executor_profile_id?: string;
|
||||
state: string;
|
||||
started_at: string;
|
||||
task_environment_id?: string;
|
||||
@@ -2202,6 +2204,25 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async setSessionModel(sessionId: string, modelId: string): Promise<void> {
|
||||
await this.request("POST", `/api/v1/task-sessions/${sessionId}/set-model`, {
|
||||
model_id: modelId,
|
||||
});
|
||||
}
|
||||
|
||||
async setSessionMode(sessionId: string, modeId: string): Promise<void> {
|
||||
await this.request("POST", `/api/v1/task-sessions/${sessionId}/set-mode`, {
|
||||
mode_id: modeId,
|
||||
});
|
||||
}
|
||||
|
||||
async setSessionConfigOption(sessionId: string, configId: string, value: string): Promise<void> {
|
||||
await this.request("POST", `/api/v1/task-sessions/${sessionId}/set-config-option`, {
|
||||
config_id: configId,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
async queueMessage(
|
||||
taskId: string,
|
||||
sessionId: string,
|
||||
|
||||
@@ -105,5 +105,11 @@ test.describe("MCP-created task agent profile default", () => {
|
||||
{ timeout: 30_000, message: "MCP subtask should start with the workspace profile" },
|
||||
)
|
||||
.toBe(workspaceProfile.id);
|
||||
|
||||
const { sessions: subtaskSessions } = await apiClient.listTaskSessions(subtaskId!);
|
||||
const subtaskRuntime = subtaskSessions[0]?.metadata?.runtime_config_overrides as
|
||||
| { model?: string; mode?: string; config_options?: Record<string, string> }
|
||||
| undefined;
|
||||
expect(subtaskRuntime).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,11 @@ test.describe("MCP-created task agent profile default on mobile", () => {
|
||||
).toBeVisible();
|
||||
await expect(testPage.getByText("create_task_kandev", { exact: true })).toBeVisible();
|
||||
await expect(testPage.getByText("spawn_session_kandev", { exact: true })).toBeVisible();
|
||||
await expect(testPage.getByText(/effective model, mode, and options/i)).toBeVisible();
|
||||
await expect(testPage.getByText(/workflow-selected profiles win first/i)).toBeVisible();
|
||||
await expect(
|
||||
testPage.getByText(/skips the creating session and source or parent task profiles/i),
|
||||
).toBeVisible();
|
||||
|
||||
await testPage.getByRole("button", { name: "About affected Kandev MCP tools" }).tap();
|
||||
await expect(testPage.getByRole("tooltip")).toContainText(
|
||||
@@ -28,7 +33,7 @@ test.describe("MCP-created task agent profile default on mobile", () => {
|
||||
);
|
||||
await testPage.getByRole("heading", { name: "Task Actions", exact: true }).tap();
|
||||
|
||||
const currentTask = testPage.getByRole("radio", { name: "Current task profile" });
|
||||
const currentTask = testPage.getByRole("radio", { name: "Creating session profile" });
|
||||
const workspaceDefault = testPage.getByRole("radio", {
|
||||
name: "Workspace default profile",
|
||||
});
|
||||
|
||||
@@ -24,10 +24,51 @@ type TaskWithRepos = {
|
||||
type SessionInfo = {
|
||||
id: string;
|
||||
agent_profile_id: string;
|
||||
executor_id?: string;
|
||||
executor_profile_id: string;
|
||||
state: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type RuntimeConfig = {
|
||||
model?: string;
|
||||
mode?: string;
|
||||
config_options?: Record<string, string>;
|
||||
};
|
||||
|
||||
function runtimeConfigFromMetadata(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
): RuntimeConfig {
|
||||
return (metadata?.[key] as RuntimeConfig | undefined) ?? {};
|
||||
}
|
||||
|
||||
function firstRuntimeValue(...values: Array<string | undefined>): string | undefined {
|
||||
return values.find((value) => value);
|
||||
}
|
||||
|
||||
function effectiveRuntimeConfig(session: SessionInfo): RuntimeConfig {
|
||||
const snapshot = runtimeConfigFromMetadata(session.metadata, "agent_profile_snapshot");
|
||||
const runtime = runtimeConfigFromMetadata(session.metadata, "runtime_config");
|
||||
const overrides = runtimeConfigFromMetadata(session.metadata, "runtime_config_overrides");
|
||||
const configOptions = {
|
||||
...(runtime.config_options ?? snapshot.config_options ?? {}),
|
||||
...(overrides.config_options ?? {}),
|
||||
};
|
||||
delete configOptions.model;
|
||||
delete configOptions.mode;
|
||||
return {
|
||||
model: firstRuntimeValue(overrides.model, runtime.model, snapshot.model),
|
||||
mode: firstRuntimeValue(
|
||||
session.metadata?.session_mode as string | undefined,
|
||||
overrides.mode,
|
||||
runtime.mode,
|
||||
snapshot.mode,
|
||||
),
|
||||
config_options: configOptions,
|
||||
};
|
||||
}
|
||||
|
||||
async function selectRepositoryFromChip(repoChip: Locator, option: Locator, name: string) {
|
||||
await expect(async () => {
|
||||
if (!(await option.isVisible())) {
|
||||
@@ -971,6 +1012,134 @@ test.describe("Subtask dialog feature parity", () => {
|
||||
* agent_profile_id AND executor_profile_id.
|
||||
*/
|
||||
test.describe("Subtask inheritance", () => {
|
||||
test("a changed second session creates top-level and subtask tasks with its runtime", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
|
||||
const { agents } = await apiClient.listAgents();
|
||||
const agent = agents.find((item) => item.name === "mock-agent") ?? agents[0];
|
||||
expect(agent).toBeTruthy();
|
||||
const creatorProfile = await apiClient.createAgentProfile(
|
||||
agent!.id,
|
||||
"Creator Session Runtime E2E",
|
||||
{
|
||||
model: "mock-fast",
|
||||
mode: "default",
|
||||
config_options: { effort: "medium", profile_only: "stale" },
|
||||
},
|
||||
);
|
||||
const executor = await apiClient.createExecutor("Creator Runtime Executor E2E", "local_pc");
|
||||
const executorProfile = await apiClient.createExecutorProfile(
|
||||
executor.id,
|
||||
"Creator Runtime Executor Profile E2E",
|
||||
);
|
||||
const subtaskTitle = `Creator runtime subtask ${Date.now()}`;
|
||||
const topLevelTitle = `Creator runtime top-level ${Date.now()}`;
|
||||
|
||||
try {
|
||||
const parentTask = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Creator Runtime Parent E2E",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
executor_profile_id: executorProfile.id,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
await testPage.goto(`/t/${parentTask.id}`);
|
||||
await new SessionPage(testPage).waitForLoad();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(parentTask.id);
|
||||
return DONE_STATES.includes(sessions[0]?.state ?? "");
|
||||
},
|
||||
{
|
||||
timeout: 30_000,
|
||||
message: "Parent session should finish before the second session starts",
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const firstSession = (await apiClient.listTaskSessions(parentTask.id)).sessions[0];
|
||||
expect(firstSession?.agent_profile_id).toBe(seedData.agentProfileId);
|
||||
const second = await apiClient.launchSession({
|
||||
task_id: parentTask.id,
|
||||
agent_profile_id: creatorProfile.id,
|
||||
executor_id: firstSession?.executor_id,
|
||||
executor_profile_id: firstSession?.executor_profile_id,
|
||||
prompt: "/e2e:simple-message",
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(parentTask.id);
|
||||
return sessions.find((session) => session.id === second.session_id)?.state ?? "";
|
||||
},
|
||||
{ timeout: 45_000, message: "Creator session should finish before runtime changes" },
|
||||
)
|
||||
.toBe("WAITING_FOR_INPUT");
|
||||
|
||||
await apiClient.setSessionModel(second.session_id, "mock-smart");
|
||||
await apiClient.setSessionMode(second.session_id, "plan-mock");
|
||||
await apiClient.setSessionConfigOption(second.session_id, "effort", "max");
|
||||
|
||||
const script = [
|
||||
`e2e:mcp:kandev:create_task_kandev({"parent_id":"self","title":"${subtaskTitle}","description":"creator runtime subtask"})`,
|
||||
`e2e:mcp:kandev:create_task_kandev({"workspace_id":"${seedData.workspaceId}","workflow_id":"${seedData.workflowId}","title":"${topLevelTitle}","description":"creator runtime top-level","repository_id":"${seedData.repositoryId}"})`,
|
||||
'e2e:message("Done.")',
|
||||
].join("\n");
|
||||
await apiClient.addUserMessage(parentTask.id, second.session_id, script);
|
||||
|
||||
let subtask: { id: string } | undefined;
|
||||
let topLevel: { id: string } | undefined;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { tasks } = await apiClient.listTasks(seedData.workspaceId);
|
||||
subtask = tasks.find((task) => task.title === subtaskTitle);
|
||||
topLevel = tasks.find((task) => task.title === topLevelTitle);
|
||||
return Boolean(subtask && topLevel);
|
||||
},
|
||||
{ timeout: 60_000, message: "Both MCP-created task shapes should exist" },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
for (const task of [subtask!, topLevel!]) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
return sessions[0]?.agent_profile_id ?? "";
|
||||
},
|
||||
{ timeout: 45_000, message: `Task ${task.id} should start with the creator profile` },
|
||||
)
|
||||
.toBe(creatorProfile.id);
|
||||
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
const createdSession = sessions[0] as SessionInfo;
|
||||
expect(effectiveRuntimeConfig(createdSession)).toEqual({
|
||||
model: "mock-smart",
|
||||
mode: "plan-mock",
|
||||
config_options: { effort: "max" },
|
||||
});
|
||||
}
|
||||
|
||||
const { sessions: subtaskSessions } = await apiClient.listTaskSessions(subtask!.id);
|
||||
expect(subtaskSessions[0]?.executor_profile_id).toBe(executorProfile.id);
|
||||
} finally {
|
||||
await apiClient.deleteAgentProfile(creatorProfile.id, true);
|
||||
}
|
||||
});
|
||||
|
||||
test("MCP-created subtask inherits agent profile and executor profile", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -401,12 +401,12 @@
|
||||
"manualInstallRequired": "Manual install required",
|
||||
"markMessagesThatArrivedWhileA": "Mark messages that arrived while a task was out of view. Takes effect immediately.",
|
||||
"mcpAffectedToolsHelp": "<0>create_task_kandev</0> creates a separate task. <3>spawn_session_kandev</3> adds a session to the current task, so it does not use this preference.",
|
||||
"mcpCreateTaskScope": "<0>create_task_kandev</0> creates new tasks and subtasks. This setting applies only when the call omits <4>agent_profile_id</4>.",
|
||||
"mcpSpawnSessionScope": "<0>spawn_session_kandev</0> and tasks you create yourself are not affected. An explicitly selected profile always wins.",
|
||||
"mcpTaskProfileCurrentTask": "Current task profile",
|
||||
"mcpTaskProfileCurrentTaskDescription": "The new task uses the same profile as the task that created it. Choose this when follow-up work needs the same model and agent setup. This may reuse a more expensive profile.",
|
||||
"mcpCreateTaskScope": "<0>create_task_kandev</0> creates new tasks and subtasks. This setting applies only when the call omits <4>agent_profile_id</4>. In a live task-mode call, Creating session profile copies the verified session's effective model, mode, and options.",
|
||||
"mcpSpawnSessionScope": "<0>spawn_session_kandev</0> and tasks you create yourself are not affected. Workflow-selected profiles win first. Otherwise an explicit profile wins; Workspace default skips session and task profiles. External calls have no creating session and use the parent-task fallback for current_task.",
|
||||
"mcpTaskProfileCurrentTask": "Creating session profile",
|
||||
"mcpTaskProfileCurrentTaskDescription": "For a live task-mode call, the new task uses the verified creating session's profile and its effective model, mode, and dynamic options, including changes made during the session. A workflow launch profile wins first. Without one, this follows the creating session; without verified session context, it falls back to the source or parent task profile, then workflow and workspace defaults. This can reuse a higher-cost setup.",
|
||||
"mcpTaskProfileWorkspaceDefault": "Workspace default profile",
|
||||
"mcpTaskProfileWorkspaceDefaultDescription": "The new task uses its workflow profile when one is set; otherwise it uses the default profile of the workspace receiving the task. Choose this to keep agent-created tasks on your standard workspace model and avoid accidentally reusing an expensive profile.",
|
||||
"mcpTaskProfileWorkspaceDefaultDescription": "The new task skips the creating session and source or parent task profiles. It uses the workflow launch profile first, then the target workspace default. It does not copy the creating session's model, mode, or dynamic options. Choose this for a consistent cost policy. If neither workflow nor workspace supplies a profile, creation fails.",
|
||||
"memory": "Memory %",
|
||||
"mergeThisSplitIntoTheSplit": "Merge this split into the split containing {{target}}.",
|
||||
"mergeWithTarget": "Merge with {{target}}",
|
||||
|
||||
@@ -401,12 +401,12 @@
|
||||
"manualInstallRequired": "Ḿàńũàĺ ĩńśţàĺĺ ŕēqũĩŕēď",
|
||||
"markMessagesThatArrivedWhileA": "Ḿàŕķ ḿēśśàĝēś ţĥàţ àŕŕĩvēď ŵĥĩĺē à ţàśķ ŵàś ōũţ ōƒ vĩēŵ. Ţàķēś ēƒƒēćţ ĩḿḿēďĩàţēĺŷ.",
|
||||
"mcpAffectedToolsHelp": "<0>ćŕēàţē_ţàśķ_ķàńďēv</0> ćŕēàţēś à śēƥàŕàţē ţàśķ. <3>śƥàŵń_śēśśĩōń_ķàńďēv</3> àďďś à śēśśĩōń ţō ţĥē ćũŕŕēńţ ţàśķ, śō ĩţ ďōēś ńōţ ũśē ţĥĩś ƥŕēƒēŕēńćē.",
|
||||
"mcpCreateTaskScope": "<0>ćŕēàţē_ţàśķ_ķàńďēv</0> ćŕēàţēś ńēŵ ţàśķś àńď śũƀţàśķś. Ţĥĩś śēţţĩńĝ àƥƥĺĩēś ōńĺŷ ŵĥēń ţĥē ćàĺĺ ōḿĩţś <4>àĝēńţ_ƥŕōƒĩĺē_ĩď</4>.",
|
||||
"mcpSpawnSessionScope": "<0>śƥàŵń_śēśśĩōń_ķàńďēv</0> àńď ţàśķś ŷōũ ćŕēàţē ŷōũŕśēĺƒ àŕē ńōţ àƒƒēćţēď. Àń ēxƥĺĩćĩţĺŷ śēĺēćţēď ƥŕōƒĩĺē àĺŵàŷś ŵĩńś.",
|
||||
"mcpTaskProfileCurrentTask": "Ćũŕŕēńţ ţàśķ ƥŕōƒĩĺē",
|
||||
"mcpTaskProfileCurrentTaskDescription": "Ţĥē ńēŵ ţàśķ ũśēś ţĥē śàḿē ƥŕōƒĩĺē àś ţĥē ţàśķ ţĥàţ ćŕēàţēď ĩţ. Ćĥōōśē ţĥĩś ŵĥēń ƒōĺĺōŵ-ũƥ ŵōŕķ ńēēďś ţĥē śàḿē ḿōďēĺ àńď àĝēńţ śēţũƥ. Ţĥĩś ḿàŷ ŕēũśē à ḿōŕē ēxƥēńśĩvē ƥŕōƒĩĺē.",
|
||||
"mcpCreateTaskScope": "<0>ćŕēàţē_ţàśķ_ķàńďēv</0> ćŕēàţēś ńēŵ ţàśķś àńď śũƀţàśķś. Ţĥĩś śēţţĩńĝ àƥƥĺĩēś ōńĺŷ ŵĥēń ţĥē ćàĺĺ ōḿĩţś <4>àĝēńţ_ƥŕōƒĩĺē_ĩď</4>. Ĩń à ĺĩvē ţàśķ-ḿōďē ćàĺĺ, Ćŕēàţĩńĝ śēśśĩōń ƥŕōƒĩĺē ćōƥĩēś ţĥē vēŕĩƒĩēď śēśśĩōń'ś ēƒƒēćţĩvē ḿōďēĺ, ḿōďē, àńď ōƥţĩōńś.",
|
||||
"mcpSpawnSessionScope": "<0>śƥàŵń_śēśśĩōń_ķàńďēv</0> àńď ţàśķś ŷōũ ćŕēàţē ŷōũŕśēĺƒ àŕē ńōţ àƒƒēćţēď. Ŵōŕķƒĺōŵ-śēĺēćţēď ƥŕōƒĩĺēś ŵĩń ƒĩŕśţ. Ōţĥēŕŵĩśē àń ēxƥĺĩćĩţ ƥŕōƒĩĺē ŵĩńś; Ŵōŕķśƥàćē ďēƒàũĺţ śķĩƥś śēśśĩōń àńď ţàśķ ƥŕōƒĩĺēś. Ēxţēŕńàĺ ćàĺĺś ĥàvē ńō ćŕēàţĩńĝ śēśśĩōń àńď ũśē ţĥē ƥàŕēńţ-ţàśķ ƒàĺĺƀàćķ ƒōŕ ćũŕŕēńţ_ţàśķ.",
|
||||
"mcpTaskProfileCurrentTask": "Ćŕēàţĩńĝ śēśśĩōń ƥŕōƒĩĺē",
|
||||
"mcpTaskProfileCurrentTaskDescription": "Ƒōŕ à ĺĩvē ţàśķ-ḿōďē ćàĺĺ, ţĥē ńēŵ ţàśķ ũśēś ţĥē vēŕĩƒĩēď ćŕēàţĩńĝ śēśśĩōń'ś ƥŕōƒĩĺē àńď ĩţś ēƒƒēćţĩvē ḿōďēĺ, ḿōďē, àńď ďŷńàḿĩć ōƥţĩōńś, ĩńćĺũďĩńĝ ćĥàńĝēś ḿàďē ďũŕĩńĝ ţĥē śēśśĩōń. À ŵōŕķƒĺōŵ ĺàũńćĥ ƥŕōƒĩĺē ŵĩńś ƒĩŕśţ. Ŵĩţĥōũţ ōńē, ţĥĩś ƒōĺĺōŵś ţĥē ćŕēàţĩńĝ śēśśĩōń; ŵĩţĥōũţ vēŕĩƒĩēď śēśśĩōń ćōńţēxţ, ĩţ ƒàĺĺś ƀàćķ ţō ţĥē śōũŕćē ōŕ ƥàŕēńţ ţàśķ ƥŕōƒĩĺē, ţĥēń ŵōŕķƒĺōŵ àńď ŵōŕķśƥàćē ďēƒàũĺţś. Ţĥĩś ćàń ŕēũśē à ĥĩĝĥēŕ-ćōśţ śēţũƥ.",
|
||||
"mcpTaskProfileWorkspaceDefault": "Ŵōŕķśƥàćē ďēƒàũĺţ ƥŕōƒĩĺē",
|
||||
"mcpTaskProfileWorkspaceDefaultDescription": "Ţĥē ńēŵ ţàśķ ũśēś ĩţś ŵōŕķƒĺōŵ ƥŕōƒĩĺē ŵĥēń ōńē ĩś śēţ; ōţĥēŕŵĩśē ĩţ ũśēś ţĥē ďēƒàũĺţ ƥŕōƒĩĺē ōƒ ţĥē ŵōŕķśƥàćē ŕēćēĩvĩńĝ ţĥē ţàśķ. Ćĥōōśē ţĥĩś ţō ķēēƥ àĝēńţ-ćŕēàţēď ţàśķś ōń ŷōũŕ śţàńďàŕď ŵōŕķśƥàćē ḿōďēĺ àńď àvōĩď àććĩďēńţàĺĺŷ ŕēũśĩńĝ àń ēxƥēńśĩvē ƥŕōƒĩĺē.",
|
||||
"mcpTaskProfileWorkspaceDefaultDescription": "Ţĥē ńēŵ ţàśķ śķĩƥś ţĥē ćŕēàţĩńĝ śēśśĩōń àńď śōũŕćē ōŕ ƥàŕēńţ ţàśķ ƥŕōƒĩĺēś. Ĩţ ũśēś ţĥē ŵōŕķƒĺōŵ ĺàũńćĥ ƥŕōƒĩĺē ƒĩŕśţ, ţĥēń ţĥē ţàŕĝēţ ŵōŕķśƥàćē ďēƒàũĺţ. Ĩţ ďōēś ńōţ ćōƥŷ ţĥē ćŕēàţĩńĝ śēśśĩōń'ś ḿōďēĺ, ḿōďē, ōŕ ďŷńàḿĩć ōƥţĩōńś. Ćĥōōśē ţĥĩś ƒōŕ à ćōńśĩśţēńţ ćōśţ ƥōĺĩćŷ. Ĩƒ ńēĩţĥēŕ ŵōŕķƒĺōŵ ńōŕ ŵōŕķśƥàćē śũƥƥĺĩēś à ƥŕōƒĩĺē, ćŕēàţĩōń ƒàĩĺś.",
|
||||
"memory": "Ḿēḿōŕŷ %",
|
||||
"mergeThisSplitIntoTheSplit": "Ḿēŕĝē ţĥĩś śƥĺĩţ ĩńţō ţĥē śƥĺĩţ ćōńţàĩńĩńĝ {{target}}.",
|
||||
"mergeWithTarget": "Ḿēŕĝē ŵĩţĥ {{target}}",
|
||||
|
||||
@@ -394,12 +394,12 @@
|
||||
"manualInstallRequired": "É necessária instalação manual",
|
||||
"markMessagesThatArrivedWhileA": "Marca as mensagens que chegaram enquanto a tarefa esteve fora de vista. Tem efeito imediato.",
|
||||
"mcpAffectedToolsHelp": "<0>create_task_kandev</0> cria uma tarefa separada. <3>spawn_session_kandev</3> adiciona uma sessão à tarefa atual, pelo que não usa esta preferência.",
|
||||
"mcpCreateTaskScope": "<0>create_task_kandev</0> cria novas tarefas e subtarefas. Esta definição aplica-se apenas quando a chamada omite <4>agent_profile_id</4>.",
|
||||
"mcpSpawnSessionScope": "<0>spawn_session_kandev</0> e as tarefas que criar não são afetados. Um perfil explicitamente selecionado prevalece sempre.",
|
||||
"mcpTaskProfileCurrentTask": "Perfil da tarefa atual",
|
||||
"mcpTaskProfileCurrentTaskDescription": "A nova tarefa usa o mesmo perfil da tarefa que a criou. Escolha esta opção quando o trabalho de seguimento precisar do mesmo modelo e da mesma configuração de agente. Isto pode reutilizar um perfil mais dispendioso.",
|
||||
"mcpCreateTaskScope": "<0>create_task_kandev</0> cria novas tarefas e subtarefas. Esta definição aplica-se apenas quando a chamada omite <4>agent_profile_id</4>. Numa chamada de tarefa ativa, o perfil da sessão criadora copia o modelo, modo e opções efetivos da sessão verificada.",
|
||||
"mcpSpawnSessionScope": "<0>spawn_session_kandev</0> e as tarefas que criar não são afetados. Os perfis selecionados pelo fluxo de trabalho prevalecem primeiro. Caso contrário, um perfil explícito prevalece; o perfil predefinido da área de trabalho ignora os perfis da sessão e da tarefa. As chamadas externas não têm sessão criadora e usam a tarefa-pai como fallback de current_task.",
|
||||
"mcpTaskProfileCurrentTask": "Perfil da sessão criadora",
|
||||
"mcpTaskProfileCurrentTaskDescription": "Numa chamada de tarefa ativa, a nova tarefa usa o perfil da sessão criadora verificada e o seu modelo, modo e opções dinâmicos efetivos, incluindo alterações feitas durante a sessão. Um perfil de lançamento do fluxo de trabalho prevalece primeiro. Sem esse perfil, segue a sessão criadora; sem contexto de sessão verificado, recua para o perfil da tarefa de origem ou da tarefa-pai, depois para os predefinidos do fluxo de trabalho e da área de trabalho. Isto pode reutilizar uma configuração mais dispendiosa.",
|
||||
"mcpTaskProfileWorkspaceDefault": "Perfil pré-definido da área de trabalho",
|
||||
"mcpTaskProfileWorkspaceDefaultDescription": "A nova tarefa usa o perfil do seu fluxo de trabalho, se estiver definido; caso contrário, usa o perfil pré-definido da área de trabalho que recebe a tarefa. Escolha esta opção para manter as tarefas criadas por agentes no modelo habitual da sua área de trabalho e evitar reutilizar acidentalmente um perfil dispendioso.",
|
||||
"mcpTaskProfileWorkspaceDefaultDescription": "A nova tarefa ignora a sessão criadora e os perfis da tarefa de origem ou da tarefa-pai. Usa primeiro o perfil de lançamento do fluxo de trabalho e depois o perfil predefinido da área de trabalho de destino. Não copia o modelo, modo ou opções dinâmicos da sessão criadora. Escolha esta opção para uma política de custos consistente. Se nenhum dos dois fornecer um perfil, a criação falha.",
|
||||
"memory": "Memória %",
|
||||
"mergeThisSplitIntoTheSplit": "Fundir esta divisão na divisão que contém {{target}}.",
|
||||
"mergeWithTarget": "Fundir com {{target}}",
|
||||
|
||||
@@ -401,12 +401,12 @@
|
||||
"manualInstallRequired": "需要手动安装",
|
||||
"markMessagesThatArrivedWhileA": "标记任务不可见期间收到的消息。立即生效。",
|
||||
"mcpAffectedToolsHelp": "<0>create_task_kandev</0> 会创建单独任务。<3>spawn_session_kandev</3> 会向当前任务添加会话,因此不使用此偏好设置。",
|
||||
"mcpCreateTaskScope": "<0>create_task_kandev</0> 会创建新任务和子任务。该设置仅在调用时未提供 <4>agent_profile_id</4> 时生效。",
|
||||
"mcpSpawnSessionScope": "<0>spawn_session_kandev</0> 和你自行创建的任务不受影响。明确选择的配置始终优先。",
|
||||
"mcpTaskProfileCurrentTask": "当前任务配置",
|
||||
"mcpTaskProfileCurrentTaskDescription": "新任务使用创建它的任务所用的相同配置。若后续工作需要相同模型和代理设置,请选择此项。这可能会复用费用更高的配置。",
|
||||
"mcpCreateTaskScope": "<0>create_task_kandev</0> 会创建新任务和子任务。该设置仅在调用时未提供 <4>agent_profile_id</4> 时生效。在实时任务调用中,创建会话配置会复制已验证会话的有效模型、模式和选项。",
|
||||
"mcpSpawnSessionScope": "<0>spawn_session_kandev</0> 和你自行创建的任务不受影响。工作流选择的配置始终优先。否则,明确选择的配置优先;工作区默认配置会跳过会话和任务配置。外部调用没有创建会话,current_task 使用父任务回退配置。",
|
||||
"mcpTaskProfileCurrentTask": "创建会话配置",
|
||||
"mcpTaskProfileCurrentTaskDescription": "对于实时任务调用,新任务使用已验证创建会话的配置,以及该会话的有效模型、模式和动态选项,包括会话期间的更改。工作流启动配置优先。没有该配置时,使用创建会话;没有已验证的会话上下文时,依次回退到源任务或父任务配置,再回退到工作流和工作区默认配置。这可能会复用成本更高的设置。",
|
||||
"mcpTaskProfileWorkspaceDefault": "工作区默认配置",
|
||||
"mcpTaskProfileWorkspaceDefaultDescription": "设置了工作流配置时,新任务会使用该配置;否则使用接收任务的工作区默认配置。选择此项可让代理创建的任务使用标准工作区模型,避免意外复用费用较高的配置。",
|
||||
"mcpTaskProfileWorkspaceDefaultDescription": "新任务会跳过创建会话以及源任务或父任务配置。它先使用工作流启动配置,再使用目标工作区默认配置。它不会复制创建会话的模型、模式或动态选项。选择此项可保持一致的成本策略。如果工作流和工作区都没有配置,创建会失败。",
|
||||
"memory": "内存 %",
|
||||
"mergeThisSplitIntoTheSplit": "将此拆分区域合并到包含 {{target}} 的拆分区域。",
|
||||
"mergeWithTarget": "与 {{target}} 合并",
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
---
|
||||
spec: docs/specs/tasks/mcp-task-agent-profile-default/spec.md
|
||||
created: 2026-08-12
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Implementation Plan: Creator-Session Task Inheritance
|
||||
|
||||
## Overview
|
||||
|
||||
Replace task-origin profile inheritance with verified creator-session
|
||||
inheritance for session-bound `create_task_kandev` calls. First add a typed
|
||||
initial-session runtime seed. Then carry trusted session identity through the MCP
|
||||
transport and apply the seed during profile resolution. Update the visible
|
||||
setting, tool contract, public docs, and existing desktop/mobile E2E coverage.
|
||||
|
||||
## Backend
|
||||
|
||||
### Initial-session runtime seed
|
||||
|
||||
- Add a task metadata key in `apps/backend/internal/task/models/models.go` for
|
||||
the initial session runtime configuration.
|
||||
- Add a model helper that resolves one effective `SessionRuntimeConfig` from a
|
||||
task session. Start with `AgentProfileSnapshot`, then apply
|
||||
`runtime_config`, `session_mode`, and `runtime_config_overrides` in precedence
|
||||
order. Clone option maps so source and target metadata never alias.
|
||||
- In `apps/backend/internal/orchestrator/executor/executor_execute.go`, map the
|
||||
task launch seed to `SessionMetaKeyRuntimeConfigOverrides` only when
|
||||
`PrepareSession` creates the task's initial session. Remove the launch-only
|
||||
key from the session metadata copy.
|
||||
- Cover profile-only state, live runtime updates, explicit overrides, option
|
||||
replacement, and initial-versus-later session preparation.
|
||||
|
||||
### Trusted creator context
|
||||
|
||||
- In `apps/backend/internal/mcp/server/handlers.go`, add the server's own
|
||||
`sessionID` to the create-task backend payload as `source_session_id`. Keep it
|
||||
absent in external mode.
|
||||
- In `apps/backend/internal/mcp/handlers/handlers.go`, parse the internal field
|
||||
and load it through `taskSvc.GetTaskSession`. Require the session's `TaskID`
|
||||
to equal `source_task_id` before using any profile or runtime data.
|
||||
- Refactor `resolveMCPAutoStartConfigWithError` to return profile, executor, and
|
||||
optional initial runtime seed. In `current_task` mode, a verified creator
|
||||
session wins over parent/source task profile metadata for both top-level tasks
|
||||
and subtasks.
|
||||
- Preserve the existing executor chain. A subtask still inherits executor state
|
||||
from its parent. A top-level task still inherits it from its source task.
|
||||
- Persist the runtime seed beside the resolved task launch profile before
|
||||
`CreateTask`, including when `start_agent=false`.
|
||||
- Suppress the seed when `agent_profile_id` is explicit, when a workflow launch
|
||||
profile wins, or when the user selected `workspace_default`.
|
||||
- Fail before task persistence when supplied session context cannot be verified.
|
||||
Calls with no session context retain the current parent/source compatibility
|
||||
fallback.
|
||||
|
||||
### MCP contract text
|
||||
|
||||
- Update `apps/backend/internal/mcp/server/server.go` so task-mode descriptions
|
||||
name the creating session and its effective configuration.
|
||||
- Keep external-mode descriptions explicit that there is no creating session
|
||||
and that `current_task` falls back to the parent task.
|
||||
- Add focused schema/description and forwarded-payload tests in
|
||||
`apps/backend/internal/mcp/server/handlers_test.go` and `server_test.go`.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Task Actions setting
|
||||
|
||||
- Change the visible `current_task` label to **Creating session profile** in
|
||||
`apps/web/src/locales/en/settings.json` and every locale catalog.
|
||||
- Rewrite the setting description and option help in
|
||||
`apps/web/components/settings/mcp-task-agent-profile-default-settings.tsx`.
|
||||
State the affected tool, effective model/options behavior, precedence, cost
|
||||
risk, external fallback, and exclusions without relying on hover help.
|
||||
- Update the component test to prove that the saved enum remains `current_task`
|
||||
and that the new explanation is visible.
|
||||
|
||||
Mobile uses the existing Task Behavior page and radio-card layout. The nearest
|
||||
mobile exemplar is the shipped settings card in
|
||||
`mobile-mcp-task-agent-profile-default.spec.ts`. The page remains the single
|
||||
scroll owner. Radio rows retain 44px touch targets, and all explanatory text
|
||||
wraps inside the card without horizontal overflow.
|
||||
|
||||
## Public documentation
|
||||
|
||||
- Update the explanation section in `docs/public/tasks-and-workflows.md`. This
|
||||
page remains an explanation page with a short configuration procedure.
|
||||
- Explain that `current_task` follows the creating session for live task-mode
|
||||
calls, including model and reasoning changes. Document the external fallback
|
||||
and the cases that prevent runtime inheritance.
|
||||
|
||||
## Tests
|
||||
|
||||
- **What:** effective creator configuration merges profile snapshot, provider
|
||||
state, session mode, and explicit overrides.
|
||||
**File:** `apps/backend/internal/task/models/session_runtime_config_test.go`.
|
||||
**How:** table-driven unit tests with cloned-map assertions.
|
||||
- **What:** only the initial task session receives the persisted runtime seed.
|
||||
**File:** `apps/backend/internal/orchestrator/executor/executor_test.go`.
|
||||
**How:** prepare two sessions and inspect their persisted metadata.
|
||||
- **What:** the MCP server forwards its bound session identity and external mode
|
||||
does not invent one.
|
||||
**File:** `apps/backend/internal/mcp/server/handlers_test.go`.
|
||||
**How:** focused handler payload tests.
|
||||
- **What:** a verified non-primary session wins task profile resolution for a
|
||||
subtask and a top-level task, while executor inheritance remains unchanged.
|
||||
**File:** `apps/backend/internal/mcp/handlers/create_task_creator_session_test.go`.
|
||||
**How:** service-backed handler tests with two source-task sessions.
|
||||
- **What:** explicit, workflow, and workspace-default profiles suppress copied
|
||||
runtime values. Mismatched session identity fails before persistence.
|
||||
**File:** `apps/backend/internal/mcp/handlers/create_task_creator_session_test.go`.
|
||||
**How:** table-driven handler and resolver tests.
|
||||
- **What:** the renamed setting retains the `current_task` wire value and shows
|
||||
the precedence contract.
|
||||
**File:**
|
||||
`apps/web/components/settings/mcp-task-agent-profile-default-settings.test.tsx`.
|
||||
**How:** rendered component assertions and save-payload assertion.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
- **Scenario:** a second session uses another profile and changes model/options,
|
||||
then creates a subtask and a top-level task.
|
||||
**File:** `apps/web/e2e/tests/task/subtask.spec.ts`.
|
||||
**What to verify:** both created initial sessions use the creator profile and
|
||||
effective runtime values. The subtask keeps the parent executor profile.
|
||||
- **Scenario:** workspace-default policy prevents creator-session inheritance.
|
||||
**File:**
|
||||
`apps/web/e2e/tests/task/mcp-task-agent-profile-default.spec.ts`.
|
||||
**What to verify:** the created session uses the target workspace profile and
|
||||
does not contain the creator runtime seed.
|
||||
- **Scenario:** the revised setting is touch-readable and viewport-safe.
|
||||
**File:**
|
||||
`apps/web/e2e/tests/task/mobile-mcp-task-agent-profile-default.spec.ts`.
|
||||
**What to verify:** the new label and explanation are visible, the option
|
||||
persists, each choice remains touch-usable, and no horizontal overflow occurs.
|
||||
|
||||
## Verification Results
|
||||
|
||||
All implementation waves are complete. The focused backend, frontend, i18n,
|
||||
documentation, and managed E2E checks pass:
|
||||
|
||||
```text
|
||||
rtk go test ./internal/mcp/server ./internal/mcp/handlers ./internal/task/models ./internal/task/service ./internal/orchestrator/executor -count=1 PASS
|
||||
rtk go test ./internal/task/repository/sqlite -run TestCreateTaskSessionWithInitialRuntimeSeedConsumesOnceAcrossConcurrentAndReplacementSessions -count=1 PASS
|
||||
GOCACHE=/tmp/kandev-go-build GOLANGCI_LINT_CACHE=/tmp/kandev-golangci-lint make lint PASS
|
||||
pnpm --filter @kandev/web test -- components/settings/mcp-task-agent-profile-default-settings.test.tsx PASS (4 tests)
|
||||
pnpm --filter @kandev/web typecheck PASS
|
||||
pnpm --filter @kandev/web i18n:check PASS (existing locale parity/orphan advisories only)
|
||||
node --test scripts/validate-public-docs.test.mjs PASS (60 tests)
|
||||
node scripts/validate-public-docs.mjs PASS (41 public pages)
|
||||
pnpm e2e:run --no-build tests/task/subtask.spec.ts PASS (15 tests)
|
||||
pnpm e2e:run --no-build tests/task/mcp-task-agent-profile-default.spec.ts PASS (1 test)
|
||||
pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-mcp-task-agent-profile-default.spec.ts PASS (1 test)
|
||||
```
|
||||
|
||||
The PR capture run also passed and produced validated, compressed desktop and
|
||||
mobile settings screenshots in the ignored `.pr-assets` directory.
|
||||
|
||||
Review follow-up added the repository-level persistence guarantee for the
|
||||
launch-only seed. Session creation now reads and removes the seed in the same
|
||||
transaction as the first-session insert, and the SQLite regression covers
|
||||
concurrent launches plus deletion and replacement of the seed-bearing session.
|
||||
The MCP server also omits `source_session_id` unless its bound task context is
|
||||
present.
|
||||
|
||||
## Implementation Waves And Parallel Candidates
|
||||
|
||||
The default is sequential execution in the primary conversation. These waves do
|
||||
not authorize subagents.
|
||||
|
||||
Wave 1:
|
||||
- [x] [task-01-initial-runtime-seed](task-01-initial-runtime-seed.md)
|
||||
|
||||
Wave 2:
|
||||
- [x] [task-02-creator-session-resolution](task-02-creator-session-resolution.md)
|
||||
|
||||
Wave 3:
|
||||
- [x] [task-03-explain-session-default](task-03-explain-session-default.md)
|
||||
|
||||
Wave 4:
|
||||
- [x] [task-04-end-to-end-inheritance](task-04-end-to-end-inheritance.md)
|
||||
|
||||
## Risks
|
||||
|
||||
- Task metadata is copied into every prepared session today. The executor must
|
||||
translate the launch-only seed only for the initial session and must not leak
|
||||
the task-only key into session metadata.
|
||||
- Runtime options are provider-specific. Copy them only with the verified
|
||||
creator profile. Never mix them into another resolved profile.
|
||||
- Workflow profile precedence is subtle. Tests must cover a pinned step, an
|
||||
unpinned step with a workflow default, and a stepless workflow.
|
||||
- `source_session_id` is trusted attribution. It must stay server-injected and
|
||||
must be checked against `source_task_id` before use.
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
id: "01-initial-runtime-seed"
|
||||
title: "Initial runtime seed"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/mcp-task-agent-profile-default/spec.md"
|
||||
---
|
||||
|
||||
# Task 01: Initial runtime seed
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A typed helper returns the source session's effective model, mode, and option
|
||||
values with profile, provider, and explicit-override precedence.
|
||||
- A task launch seed becomes runtime overrides only on the initial prepared
|
||||
session. Later sessions do not receive it.
|
||||
- All copied maps are independent, and the task-only launch key does not remain
|
||||
in session metadata.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/task/models ./internal/orchestrator/executor
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/task/models/models.go`
|
||||
- `apps/backend/internal/task/models/session_runtime_config_test.go`
|
||||
- `apps/backend/internal/orchestrator/executor/executor_execute.go`
|
||||
- `apps/backend/internal/orchestrator/executor/executor_test.go`
|
||||
- `apps/backend/internal/task/repository/sqlite/session.go`
|
||||
- `apps/backend/internal/task/repository/sqlite/session_test.go`
|
||||
- `apps/backend/internal/task/repository/sqlite/task.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. Task 02 consumes the metadata contract from this task.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec: **What**, **Data model**, **Persistence guarantees**
|
||||
- Plan: **Initial-session runtime seed**
|
||||
- `docs/decisions/2026-07-18-turn-configuration-snapshots.md`
|
||||
- `docs/decisions/2026-08-01-workflow-session-original-configuration.md`
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the metadata key and merge precedence, files changed, exact tests run,
|
||||
results, blockers, risks, and synchronized task/plan status.
|
||||
|
||||
## Results
|
||||
|
||||
Implemented the typed `MetaKeyInitialSessionRuntimeConfig` task metadata seed
|
||||
and `LoadEffectiveSessionRuntimeConfig` merge helper. The merge order is agent
|
||||
profile snapshot, provider `runtime_config`, persisted `session_mode`, then
|
||||
explicit `runtime_config_overrides`; every returned option map is cloned.
|
||||
Effective dynamic options remove duplicated reserved `model` and `mode` entries
|
||||
when provider metadata stores those values in both locations.
|
||||
`PrepareSession` consumes the seed only for the first task session, writes it as
|
||||
session runtime overrides, and removes the launch-only key from every session
|
||||
metadata copy. Later sessions do not receive the seed.
|
||||
|
||||
The concrete SQLite repository now claims and removes the seed in the same
|
||||
transaction as session creation. This closes concurrent first-session and
|
||||
delete-and-replace races that could otherwise reuse a stale task seed. The
|
||||
regression test covers both races.
|
||||
|
||||
Files changed:
|
||||
|
||||
- `apps/backend/internal/task/models/models.go`
|
||||
- `apps/backend/internal/task/models/models_test.go`
|
||||
- `apps/backend/internal/task/models/session_runtime_config_test.go`
|
||||
- `apps/backend/internal/orchestrator/executor/executor_execute.go`
|
||||
- `apps/backend/internal/orchestrator/executor/executor_test.go`
|
||||
- `apps/backend/internal/task/service/service_turns.go`
|
||||
- `apps/backend/internal/task/service/service_turns_test.go`
|
||||
|
||||
Verification:
|
||||
|
||||
```text
|
||||
rtk go test ./internal/task/models PASS (71 tests)
|
||||
rtk go test ./internal/orchestrator/executor PASS (377 tests)
|
||||
rtk go test ./internal/task/service -run 'Test(BuildTurnRuntimeConfigSnapshotFallsBackToSelectorModel|BuildTurnRuntimeConfigSnapshotUsesSessionModeOverRuntimeMode|StartTurnPersistsImmutableEffectiveRuntimeConfigSnapshot)' PASS (3 tests)
|
||||
```
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
id: "02-creator-session-resolution"
|
||||
title: "Creator-session resolution"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-initial-runtime-seed"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/mcp-task-agent-profile-default/spec.md"
|
||||
---
|
||||
|
||||
# Task 02: Creator-session resolution
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Session-bound `create_task_kandev` calls use the verified creating session's
|
||||
profile and effective runtime configuration for top-level tasks and subtasks.
|
||||
- Explicit, workflow, and workspace-default profiles do not receive copied
|
||||
creator runtime values. Calls without session context keep the task fallback.
|
||||
- Executor inheritance remains parent-owned for subtasks and source-owned for
|
||||
top-level tasks. Invalid creator attribution creates no task.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/mcp/server ./internal/mcp/handlers
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/mcp/server/handlers.go`
|
||||
- `apps/backend/internal/mcp/server/handlers_test.go`
|
||||
- `apps/backend/internal/mcp/handlers/handlers.go`
|
||||
- `apps/backend/internal/mcp/handlers/create_task_creator_session_test.go`
|
||||
- `apps/backend/internal/mcp/handlers/handlers_test.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Task 01 supplies the typed runtime seed and initial-session mapping.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. This task changes the profile-resolution boundary used by Tasks 03
|
||||
and 04.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec: **What**, **Permissions**, **Failure modes**, **Scenarios**
|
||||
- Plan: **Trusted creator context**
|
||||
- Existing pattern:
|
||||
`apps/backend/internal/mcp/handlers/spawn_session.go::resolveSpawnerSession`
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the trusted identity path, precedence table, files changed, exact tests
|
||||
run, results, blockers, risks, and synchronized task/plan status.
|
||||
|
||||
## Results
|
||||
|
||||
Implemented trusted creator-session resolution for session-bound MCP task
|
||||
creation. The MCP server now forwards its bound session ID as internal
|
||||
`source_session_id`; external mode omits it. The handler verifies that the
|
||||
session exists and belongs to `source_task_id` before resolving the request.
|
||||
|
||||
`current_task` now prefers the verified creator session profile and effective
|
||||
runtime seed for top-level and subtask creation. Workflow-selected profiles,
|
||||
explicit profiles, and `workspace_default` suppress the copied runtime. The
|
||||
existing executor inheritance chain remains unchanged, and invalid creator
|
||||
context fails before task persistence.
|
||||
|
||||
The server forwards the bound session ID only when the bound task ID is also
|
||||
present, so the backend never receives an unpairable creator identity.
|
||||
|
||||
Files changed:
|
||||
|
||||
- `apps/backend/internal/mcp/server/handlers.go`
|
||||
- `apps/backend/internal/mcp/server/handlers_test.go`
|
||||
- `apps/backend/internal/mcp/handlers/handlers.go`
|
||||
- `apps/backend/internal/mcp/handlers/create_task_creator_session_test.go`
|
||||
- `apps/backend/internal/mcp/server/server.go`
|
||||
- `apps/backend/internal/mcp/server/server_test.go`
|
||||
|
||||
Verification:
|
||||
|
||||
```text
|
||||
rtk go test ./internal/mcp/server ./internal/mcp/handlers ./internal/task/models ./internal/task/service ./internal/orchestrator/executor -count=1 PASS
|
||||
```
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
id: "03-explain-session-default"
|
||||
title: "Explain creator-session default"
|
||||
status: done
|
||||
wave: 3
|
||||
depends_on: ["02-creator-session-resolution"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/mcp-task-agent-profile-default/spec.md"
|
||||
---
|
||||
|
||||
# Task 03: Explain creator-session default
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The `current_task` option displays as **Creating session profile** and explains
|
||||
effective model/options inheritance, precedence, cost, and exclusions.
|
||||
- Task-mode and external-mode MCP descriptions accurately state their different
|
||||
session-context behavior without changing the public input schema.
|
||||
- Public task documentation matches the shipped setting and fallback chain.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
(cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- components/settings/mcp-task-agent-profile-default-settings.test.tsx && pnpm --filter @kandev/web i18n:check)
|
||||
(cd apps/backend && go test ./internal/mcp/server)
|
||||
(node --test scripts/validate-public-docs.test.mjs)
|
||||
(node scripts/validate-public-docs.mjs)
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/mcp/server/server.go`
|
||||
- `apps/backend/internal/mcp/server/server_test.go`
|
||||
- `apps/backend/internal/mcp/server/handlers_test.go`
|
||||
- `apps/web/components/settings/mcp-task-agent-profile-default-settings.tsx`
|
||||
- `apps/web/components/settings/mcp-task-agent-profile-default-settings.test.tsx`
|
||||
- `apps/web/src/locales/*/settings.json`
|
||||
- `docs/public/tasks-and-workflows.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Task 02 fixes the exact behavior that this copy describes.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. Copy and contract assertions must describe the final resolution
|
||||
behavior from Task 02.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec: **What**, **API surface**, **Scenarios**, **Out of scope**
|
||||
- Plan: **MCP contract text**, **Task Actions setting**, **Public documentation**
|
||||
- Mobile exemplar:
|
||||
`apps/web/e2e/tests/task/mobile-mcp-task-agent-profile-default.spec.ts`
|
||||
|
||||
## Output contract
|
||||
|
||||
Report changed terminology, public docs type, locale updates, files changed,
|
||||
exact tests run, results, blockers, risks, and synchronized task/plan status.
|
||||
|
||||
## Results
|
||||
|
||||
Updated the setting copy and MCP tool descriptions to explain verified
|
||||
creating-session inheritance, effective model/mode/options, precedence, cost
|
||||
risk, external fallback, and the cases that suppress runtime copying. The
|
||||
stored enum remains `current_task`, while the visible label is now
|
||||
**Creating session profile**. English, pseudo, Portuguese, and Simplified
|
||||
Chinese catalogs are synchronized, and the public task/workflow guide matches
|
||||
the behavior.
|
||||
|
||||
Files changed:
|
||||
|
||||
- `apps/backend/internal/mcp/server/server.go`
|
||||
- `apps/backend/internal/mcp/server/server_test.go`
|
||||
- `apps/backend/internal/mcp/server/handlers_test.go`
|
||||
- `apps/web/components/settings/mcp-task-agent-profile-default-settings.test.tsx`
|
||||
- `apps/web/src/locales/en/settings.json`
|
||||
- `apps/web/src/locales/pseudo/settings.json`
|
||||
- `apps/web/src/locales/pt-pt/settings.json`
|
||||
- `apps/web/src/locales/zh-cn/settings.json`
|
||||
- `docs/public/tasks-and-workflows.md`
|
||||
|
||||
Verification:
|
||||
|
||||
```text
|
||||
pnpm --filter @kandev/web test -- components/settings/mcp-task-agent-profile-default-settings.test.tsx PASS (4 tests)
|
||||
pnpm --filter @kandev/web typecheck PASS
|
||||
pnpm --filter @kandev/web i18n:check PASS (existing locale parity/orphan advisories only)
|
||||
node --test scripts/validate-public-docs.test.mjs && node scripts/validate-public-docs.mjs PASS
|
||||
rtk go test ./internal/mcp/server -count=1 PASS
|
||||
```
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
id: "04-end-to-end-inheritance"
|
||||
title: "End-to-end inheritance"
|
||||
status: done
|
||||
wave: 4
|
||||
depends_on: ["02-creator-session-resolution", "03-explain-session-default"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/mcp-task-agent-profile-default/spec.md"
|
||||
---
|
||||
|
||||
# Task 04: End-to-end inheritance
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Desktop E2E proves that a non-primary creating session transfers its profile
|
||||
and changed runtime values to subtask and top-level initial sessions.
|
||||
- Workspace-default E2E proves that the saved policy prevents creator runtime
|
||||
inheritance.
|
||||
- Mobile E2E proves that the revised setting is touch-usable, readable, and free
|
||||
of horizontal overflow.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
(cd apps && pnpm install --frozen-lockfile)
|
||||
(cd apps/web && pnpm e2e:run tests/task/subtask.spec.ts)
|
||||
(cd apps/web && pnpm e2e:run tests/task/mcp-task-agent-profile-default.spec.ts)
|
||||
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-mcp-task-agent-profile-default.spec.ts)
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/e2e/tests/task/subtask.spec.ts`
|
||||
- `apps/web/e2e/tests/task/mcp-task-agent-profile-default.spec.ts`
|
||||
- `apps/web/e2e/tests/task/mobile-mcp-task-agent-profile-default.spec.ts`
|
||||
- `apps/web/e2e/helpers/api-client.ts` only if no existing session-config helper
|
||||
can drive the scenario
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Task 02 supplies creator-session inheritance.
|
||||
- Task 03 supplies the final setting label and explanation.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. These scenarios verify the integrated behavior and production web
|
||||
build after Tasks 02 and 03.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec: **Scenarios**
|
||||
- Plan: **E2E Tests** and **Risks**
|
||||
- E2E references: `fixture-state.md` and `ui-state-and-cleanup.md`
|
||||
|
||||
## Output contract
|
||||
|
||||
Report discovered test counts, files changed, exact managed-runner commands,
|
||||
results, failure artifact paths, cleanup evidence, blockers, risks, and
|
||||
synchronized task/plan status.
|
||||
|
||||
## Results
|
||||
|
||||
Added the desktop creator-session scenario, workspace-default runtime
|
||||
suppression assertion, and mobile touch/viewport coverage. The desktop scenario
|
||||
uses a changed second session to create both a subtask and a top-level task,
|
||||
then verifies the creator profile, model, mode, and dynamic option values while
|
||||
checking that the subtask keeps its executor profile.
|
||||
|
||||
Verification used the managed Docker runner after a final backend and web
|
||||
build:
|
||||
|
||||
```text
|
||||
pnpm e2e:run --no-build tests/task/subtask.spec.ts PASS (15 tests)
|
||||
pnpm e2e:run --no-build tests/task/mcp-task-agent-profile-default.spec.ts PASS (1 test)
|
||||
pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-mcp-task-agent-profile-default.spec.ts PASS (1 test)
|
||||
```
|
||||
|
||||
The disposable PR capture spec was removed after producing and validating the
|
||||
compressed desktop and mobile assets. No temporary test file remains.
|
||||
@@ -106,12 +106,14 @@ tool. Config and Office sessions never receive the title tool.
|
||||
|
||||
### Choose the profile for tasks created by agents
|
||||
|
||||
Open **Settings → General → Task Actions → Profile for Tasks Created by Agents** to choose which agent profile Kandev assigns when an agent calls a Kandev MCP tool that creates a task without choosing `agent_profile_id`. The profile determines the agent, model, and setup used when the new task starts:
|
||||
Open **Settings → General → Task Actions → Profile for Tasks Created by Agents** to choose which agent profile Kandev assigns when an agent calls `create_task_kandev` without choosing `agent_profile_id`. The preference covers new tasks and subtasks, and it also controls the effective model, mode, and dynamic options used by the first session:
|
||||
|
||||
- **Current task profile** is useful when follow-up work needs the same model and agent setup as the task creating it. It preserves compatibility with existing behavior and is selected by default. Kandev first inherits the parent or calling task profile, then checks the workflow step or workflow default, and finally checks the target workspace's **Default Agent Profile**. This option can unintentionally reuse a more expensive profile.
|
||||
- **Workspace default profile** is useful when you want agent-created tasks to use your standard workspace model and cost policy. It skips the parent and calling task profiles. Kandev still uses the workflow step or workflow default first, then the **Default Agent Profile** from the workspace that will own the new task.
|
||||
- **Creating session profile** is useful when follow-up work needs the same live setup. For a session-bound task-mode call, Kandev uses the verified creating session's profile and its effective model, mode, and dynamic options, including changes made during that session. A workflow launch profile wins first. When no workflow profile wins, the creating session profile is used. This option can reuse a more expensive setup.
|
||||
- **Workspace default profile** is useful when you want agent-created tasks to follow a consistent workspace cost policy. It skips the creating session and source, parent, or current task profiles. Kandev uses the workflow launch profile first, then the **Default Agent Profile** from the workspace that will own the new task. It does not copy the creating session's model, mode, or dynamic options. If neither source supplies a profile, task creation fails.
|
||||
|
||||
Select an option, then choose **Save changes**. The only affected Kandev MCP tool is `create_task_kandev`; the preference covers both new tasks and subtasks when the call omits `agent_profile_id`. It does not affect `spawn_session_kandev`, because that tool adds a session to the current task instead of creating a task. It also does not affect tasks you create in the UI. The preference applies across workspaces, but **Workspace default profile** resolves the default from each new task's target workspace. An explicit `agent_profile_id` in the tool call always overrides the saved preference. The one case where an explicit `agent_profile_id` does not run is a task that lands on a workflow step: the step's launch profile (its pinned profile, or the workflow default when unpinned) is what the agent starts with, and the created task records that profile. A task lands on a step whenever its workflow has steps, using the start step when `workflow_step_id` is omitted. If **Workspace default profile** is selected and neither the workflow nor the target workspace supplies a profile, task creation fails without creating the task, including when `start_agent=false`.
|
||||
Select an option, then choose **Save changes**. Workflow-selected profiles always win when the new task lands on a workflow step. Away from a workflow step, an explicit `agent_profile_id` wins and prevents creator-session runtime inheritance. The only affected Kandev MCP tool is `create_task_kandev`. `spawn_session_kandev` adds a session to the current task, so it does not use this preference. Tasks you create in the UI are not affected.
|
||||
|
||||
External MCP calls have no verified creating session. With **Creating session profile**, those calls keep the compatibility fallback to the parent task when one exists, then workflow and target-workspace defaults. The preference applies across workspaces, but **Workspace default profile** resolves the default from each new task's target workspace. A resolved profile and runtime seed are stored even when `start_agent=false`, so a later manual start uses the same decision.
|
||||
|
||||
### Navigate long chat transcripts
|
||||
|
||||
|
||||
@@ -8,91 +8,174 @@ owner: kandev
|
||||
|
||||
## Why
|
||||
|
||||
Agents can create tasks and subtasks through `create_task_kandev`. When the tool call omits `agent_profile_id`, inheriting the calling task's profile can unintentionally select an expensive model instead of the workspace's configured default. Users need a durable choice between preserving that inheritance behavior and using the target workspace's default profile.
|
||||
Agents create tasks and subtasks through `create_task_kandev`. Most coding
|
||||
harnesses start delegated work with the configuration of the agent session that
|
||||
created it. Kandev instead uses the profile that originally started the source
|
||||
task, which is opaque when a task has multiple sessions or a session changes its
|
||||
model.
|
||||
|
||||
Users need a predictable default that follows the creating session. They also
|
||||
need an explicit workspace-default policy for cost control.
|
||||
|
||||
## What
|
||||
|
||||
- Task Actions settings exposes a per-user **MCP-created task agent profile** choice with two values:
|
||||
- **Current task profile** (`current_task`): preserve the existing profile-resolution behavior.
|
||||
- **Workspace default profile** (`workspace_default`): skip the current task profile, honor any workflow profile, then use the default agent profile of the workspace that owns the new task.
|
||||
- `current_task` is the default for new users and for existing settings that do not contain the preference. Selecting `workspace_default` is opt-in.
|
||||
- The preference applies only to tasks and subtasks created by `create_task_kandev` when `agent_profile_id` is omitted.
|
||||
- An explicit `agent_profile_id` wins over the preference and does not require the preference to be read. The one exception is a task that sits on a workflow step at launch: the orchestrator applies that step's launch profile (the step's pinned profile, or the workflow default when the step has none) over any caller-provided profile, so `create_task_kandev` reports and stores that workflow-derived profile to match what actually runs. A `create_task_kandev` task lands on a step whenever its workflow has steps — the explicit `workflow_step_id`, or the start step assigned when the step is omitted. Off a workflow step (no workflow, or a workflow with no steps), an explicit `agent_profile_id` always wins.
|
||||
- The setting explains in visible, plain language that Kandev makes this decision when an agent calls a task-creating Kandev MCP tool without `agent_profile_id`. It identifies `create_task_kandev` as the only affected tool, covers both new tasks and subtasks, names `spawn_session_kandev` as unaffected, and states that an explicitly chosen profile wins. A secondary help tooltip explains why adding a session does not create a new profile-resolution decision. Each option describes both its resolution behavior and when to choose it, including the risk that current-task inheritance can reuse a more expensive profile.
|
||||
- In `current_task` mode, the existing fallback order remains unchanged: parent task or calling source task, workflow step or workflow default, then target workspace default.
|
||||
- In `workspace_default` mode, parent/source task inheritance is skipped. The profile resolves from the workflow step or workflow default first, then from the target workspace default. This preserves workflow policy while avoiding inheritance from the calling task.
|
||||
- Executor and executor-profile inheritance are unchanged in both modes.
|
||||
- The selected profile is persisted in the created task's launch metadata even when `start_agent=false`, matching the existing deferred-start contract.
|
||||
- The setting is portable per-user state. It applies across workspaces, while `workspace_default` dynamically resolves against each new task's target workspace.
|
||||
- The setting is usable at narrow mobile widths without horizontal page scrolling, clipped labels, or hover-only interaction.
|
||||
- Task Actions settings exposes a per-user **MCP-created task agent profile**
|
||||
choice with two stored values:
|
||||
- **Creating session profile** (`current_task`) uses the verified session that
|
||||
called `create_task_kandev`. It copies that session's agent profile and its
|
||||
effective model, mode, and dynamic options.
|
||||
- **Workspace default profile** (`workspace_default`) skips the creating
|
||||
session and task profiles. It uses the workflow profile, then the target
|
||||
workspace default.
|
||||
- `current_task` remains the stored default for new users and existing settings.
|
||||
The label changes because the creating session, not the task's original
|
||||
profile, owns the default.
|
||||
- The preference applies to top-level tasks and subtasks created by
|
||||
`create_task_kandev` when `agent_profile_id` is omitted.
|
||||
- The session-bound MCP server supplies `source_task_id` and `source_session_id`.
|
||||
These fields are server-owned context and are not tool arguments.
|
||||
- The backend uses `source_session_id` only when the session exists and belongs
|
||||
to `source_task_id`. A live agent cannot select another session as the source.
|
||||
- The creating session's effective configuration is its profile snapshot plus
|
||||
the latest provider runtime state and explicit runtime overrides. Later values
|
||||
override earlier values.
|
||||
- Kandev stores the copied effective configuration as the initial session's
|
||||
runtime overrides. The copied values therefore beat later edits to the shared
|
||||
agent profile.
|
||||
- The copied runtime configuration applies only when the creating session's
|
||||
profile wins profile resolution. Kandev does not mix the values into an
|
||||
explicit profile, a workflow-selected profile, or the workspace-default
|
||||
profile.
|
||||
- An explicit `agent_profile_id` wins when the task does not land on a workflow
|
||||
step. It also prevents a user-settings read and prevents creator runtime
|
||||
inheritance.
|
||||
- A task that lands on a workflow step uses that step's launch profile. This is
|
||||
the step's pinned profile, or the workflow default when the step has no pinned
|
||||
profile. This workflow profile wins over an explicit or inherited profile.
|
||||
- In `current_task` mode, a call without verified session context keeps the
|
||||
compatibility fallback: parent or source task, workflow default, then target
|
||||
workspace default. This supports external MCP calls and deferred tasks that
|
||||
have no session.
|
||||
- In `workspace_default` mode, parent and source task profiles remain skipped.
|
||||
The workflow profile wins before the target workspace default.
|
||||
- Executor and executor-profile inheritance do not change. Subtasks continue to
|
||||
inherit executor configuration from the parent. Top-level tasks continue to
|
||||
inherit it from the source task.
|
||||
- The resolved profile and initial runtime configuration persist when
|
||||
`start_agent=false`. The first session uses both values when a user starts the
|
||||
task later.
|
||||
- The setting explains that `create_task_kandev` is the only affected tool. It
|
||||
states that `spawn_session_kandev` and UI-created tasks are not affected.
|
||||
- The setting remains usable at narrow mobile widths without clipped text,
|
||||
hover-only information, or horizontal page scrolling.
|
||||
|
||||
## Data model
|
||||
|
||||
The existing JSON settings object in `users.settings` gains:
|
||||
The existing JSON settings object in `users.settings` retains:
|
||||
|
||||
```text
|
||||
mcp_task_agent_profile_default string enum: current_task | workspace_default
|
||||
```
|
||||
|
||||
The value is non-null in the normalized user-settings model. Missing or unrecognized stored values normalize to `current_task` for backward and forward compatibility. PATCH requests accept only the two documented values.
|
||||
No user-settings migration is required. The `current_task` value now means the
|
||||
verified creating session when one exists.
|
||||
|
||||
No relational schema migration is required because user settings are stored as JSON.
|
||||
Task launch metadata gains a typed initial-session runtime configuration. It
|
||||
contains the effective `model`, `mode`, and `config_options`. It contains no
|
||||
credentials, prompts, or provider capability catalog.
|
||||
|
||||
The task session copies this launch configuration into its existing
|
||||
`runtime_config_overrides` metadata when Kandev creates the initial session.
|
||||
Later sessions do not copy the initial-session value.
|
||||
|
||||
## API surface
|
||||
|
||||
The existing user-settings contracts gain `mcp_task_agent_profile_default`:
|
||||
|
||||
- `GET /api/v1/user/settings` returns `settings.mcp_task_agent_profile_default` as `current_task` or `workspace_default`.
|
||||
- `PATCH /api/v1/user/settings` accepts an optional `mcp_task_agent_profile_default`. Omission leaves the value unchanged; an unsupported value returns the existing validation response and does not update settings.
|
||||
- `user.settings.updated` includes `mcp_task_agent_profile_default` so open clients converge on the saved value.
|
||||
- The Go-served SPA boot settings include `userSettings.mcpTaskAgentProfileDefault` with the same normalized enum value.
|
||||
|
||||
The `create_task_kandev` input and response schemas do not change. The preference changes server-side resolution only when its existing optional `agent_profile_id` input is empty. Its MCP tool description explains the two server-side default modes and states that workflow step/default profiles retain precedence over the workspace default.
|
||||
- The `create_task_kandev` public input and output schemas do not change.
|
||||
- The session-bound MCP transport adds internal `source_session_id` context to
|
||||
the backend request. External MCP mode sends no session ID.
|
||||
- `GET /api/v1/user/settings`, `PATCH /api/v1/user/settings`, the
|
||||
`user.settings.updated` event, and the SPA boot payload retain the existing
|
||||
`current_task | workspace_default` values.
|
||||
- The MCP tool description explains creating-session inheritance, effective
|
||||
runtime configuration, override precedence, and the external fallback.
|
||||
|
||||
## Permissions
|
||||
|
||||
- A user who can read or update their existing user settings can read or update this preference.
|
||||
- The preference does not grant task-creation or agent-profile permissions. Existing `create_task_kandev`, workspace, workflow, and profile authorization rules remain in force.
|
||||
- Existing user-settings and task-creation permissions remain in force.
|
||||
- The backend derives creator identity from the MCP server bound to the live
|
||||
session. The tool caller cannot supply or override that identity.
|
||||
- Session lookup uses the existing task-session access rules.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- If `workspace_default` is selected and neither the task's workflow nor the target workspace supplies an agent profile, `create_task_kandev` returns a validation error and creates no task, regardless of `start_agent`.
|
||||
- If a workspace lookup required by the selected resolution chain fails, task creation fails and creates no partial task.
|
||||
- If user settings cannot be read for an omitted-profile request, task creation fails and creates no partial task; the server does not silently choose another policy.
|
||||
- An explicit `agent_profile_id` continues through existing validation and resolution even if reading the preference would fail.
|
||||
- Changing the choice creates a local settings draft. The preference is persisted only when the user chooses **Save changes**; a failed save keeps the draft selected and leaves the stored preference unchanged.
|
||||
- If a request contains server-supplied creator context that does not resolve to
|
||||
the declared source task, task creation fails and creates no partial task.
|
||||
- If Kandev cannot read the verified creator session, task creation fails and
|
||||
creates no partial task.
|
||||
- If `workspace_default` is selected and no workflow or target-workspace profile
|
||||
exists, task creation returns a validation error and creates no task.
|
||||
- If user settings cannot be read for an omitted-profile request, task creation
|
||||
fails. An explicit profile does not read this setting.
|
||||
- If a provider no longer supports a copied model or option, the existing
|
||||
session-start error and model-fallback policy apply. Kandev does not silently
|
||||
substitute the source task's original profile configuration.
|
||||
|
||||
## Persistence guarantees
|
||||
|
||||
- The preference survives backend restarts and frontend reloads as part of backend-owned portable user settings.
|
||||
- Existing installations with no stored field behave exactly as `current_task`.
|
||||
- Saving the preference publishes the existing user-settings update event; no browser storage is a durable fallback.
|
||||
- Created tasks retain the resolved agent profile in task metadata, so later session creation does not re-evaluate a changed preference.
|
||||
- The user preference survives restart as portable user settings.
|
||||
- The resolved profile and initial runtime configuration survive restart in task
|
||||
metadata, including for `start_agent=false`.
|
||||
- The initial task session receives an immutable copy of the launch seed in its
|
||||
own runtime overrides. Later changes to the source session do not change it.
|
||||
- Existing installations with no stored preference continue to normalize to
|
||||
`current_task`.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** an existing user with no stored preference, **WHEN** Task Actions settings loads, **THEN** **Current task profile** is selected.
|
||||
- **GIVEN** a user opens the setting without knowing MCP terminology, **WHEN** they read the control, **THEN** they can tell when it applies, what each option does, which option controls accidental model cost, and that an explicit profile selection overrides it.
|
||||
- **GIVEN** a user needs to know which Kandev MCP calls use the setting, **WHEN** they read the control, **THEN** they can see that `create_task_kandev` is affected for new tasks and subtasks while `spawn_session_kandev` and UI-created tasks are not, without relying on the help tooltip.
|
||||
- **GIVEN** `current_task` is selected and the calling task uses profile A while its workspace default is profile B, **WHEN** the agent creates a task without `agent_profile_id`, **THEN** the created task records profile A under the existing resolution rules.
|
||||
- **GIVEN** `workspace_default` is selected, the calling task uses profile A, no workflow profile applies, and the target workspace default is profile B, **WHEN** the agent creates a top-level task without `agent_profile_id`, **THEN** the created task records profile B.
|
||||
- **GIVEN** `workspace_default` is selected, a parent task uses profile A, no workflow profile applies, and its workspace default is profile B, **WHEN** the agent creates a subtask without `agent_profile_id`, **THEN** the subtask records profile B and retains the existing executor inheritance.
|
||||
- **GIVEN** `workspace_default` is selected and the target workflow step or workflow default supplies profile W, **WHEN** the agent creates a task without `agent_profile_id`, **THEN** the created task records profile W instead of the caller or workspace-default profile.
|
||||
- **GIVEN** `workspace_default` is selected and no workflow profile applies, **WHEN** the agent creates a task in a different workspace without `agent_profile_id`, **THEN** the new task uses that target workspace's default rather than the caller's workspace default.
|
||||
- **GIVEN** either preference is selected and the task is not on a workflow step at launch (no workflow, or a workflow with no steps), **WHEN** `create_task_kandev` includes profile C explicitly, **THEN** the created task records profile C.
|
||||
- **GIVEN** the target workflow has a start step whose launch profile resolves to profile W (the step's pinned profile, or the workflow default when the step is unpinned), **WHEN** `create_task_kandev` includes profile C explicitly but omits `workflow_step_id`, **THEN** the created task lands on that start step and records profile W, matching the profile the orchestrator launches.
|
||||
- **GIVEN** `workspace_default` is selected and neither the workflow nor the target workspace has a default agent profile, **WHEN** an agent creates a task without `agent_profile_id`, **THEN** the tool returns a validation error and no task exists.
|
||||
- **GIVEN** `workspace_default` is selected, no workflow profile applies, and a workspace default exists, **WHEN** an agent creates a task with `start_agent=false` and no `agent_profile_id`, **THEN** the task is created without a session and records the target workspace default for later launch.
|
||||
- **GIVEN** the setting is open on a mobile viewport, **WHEN** the user selects **Workspace default profile** and chooses **Save changes**, **THEN** the choice saves and remains selected after reload without horizontal overflow.
|
||||
- **GIVEN** a settings update fails, **WHEN** the user chooses **Save changes**, **THEN** the draft remains selected, the page shows the save failure, and the stored preference is unchanged.
|
||||
- **GIVEN** a task started with profile A and a second live session that uses
|
||||
profile B, **WHEN** session B creates a task without `agent_profile_id`,
|
||||
**THEN** the created task records profile B.
|
||||
- **GIVEN** a live session changed from its profile model to another model and
|
||||
selected a new reasoning value, **WHEN** it creates a task without
|
||||
`agent_profile_id`, **THEN** the initial child session starts with the changed
|
||||
model and reasoning value.
|
||||
- **GIVEN** a live session creates a top-level task in the same workspace,
|
||||
**WHEN** it omits `agent_profile_id`, **THEN** the new task inherits that
|
||||
session's profile and effective runtime configuration.
|
||||
- **GIVEN** a live session creates a subtask under another task, **WHEN** it
|
||||
omits `agent_profile_id`, **THEN** the subtask inherits the creating session's
|
||||
agent configuration and the parent task's executor configuration.
|
||||
- **GIVEN** `current_task` is selected and an external MCP caller has no session,
|
||||
**WHEN** it creates a subtask without `agent_profile_id`, **THEN** profile
|
||||
resolution uses the parent task compatibility fallback.
|
||||
- **GIVEN** `workspace_default` is selected, **WHEN** a live session creates a
|
||||
task without `agent_profile_id`, **THEN** Kandev does not copy the source
|
||||
session profile or runtime configuration.
|
||||
- **GIVEN** a call supplies `agent_profile_id`, **WHEN** the target task does not
|
||||
land on a workflow step, **THEN** Kandev uses that profile and does not copy
|
||||
the creating session runtime configuration.
|
||||
- **GIVEN** a workflow step selects profile W, **WHEN** a live session creates a
|
||||
task on that step, **THEN** the created task uses profile W and does not copy
|
||||
the creating session runtime configuration.
|
||||
- **GIVEN** a live session creates a task with `start_agent=false`, **WHEN** the
|
||||
user later starts its first session, **THEN** that session uses the persisted
|
||||
creator profile and runtime configuration.
|
||||
- **GIVEN** creator context names a session from another task, **WHEN** Kandev
|
||||
handles the request, **THEN** creation fails before any task persists.
|
||||
- **GIVEN** the Task Actions setting is open on a phone, **WHEN** the user reads
|
||||
and changes the choice, **THEN** all precedence information is touch-visible
|
||||
and the page has no horizontal overflow.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing profile resolution for UI-created tasks, API-created tasks, automations, Office routing, or `spawn_session_kandev`.
|
||||
- Changing workspace default profile configuration or agent-profile lifecycle rules.
|
||||
- Changing profile resolution for UI-created tasks, API-created tasks,
|
||||
automations, Office routing, utility agents, or `spawn_session_kandev`.
|
||||
- Changing executor or executor-profile inheritance.
|
||||
- Selecting a specific profile directly from Task Actions settings; the tool's explicit `agent_profile_id` remains the per-call override.
|
||||
- Adding a third preference value or changing the stored enum names.
|
||||
- Copying credentials, environment variables, CLI flags, or MCP configuration
|
||||
from the creating session.
|
||||
- Changing provider model fallback policy.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
See [`../../../plans/mcp-task-agent-profile-default/plan.md`](../../../plans/mcp-task-agent-profile-default/plan.md).
|
||||
See [`../../../plans/creator-session-task-inheritance/plan.md`](../../../plans/creator-session-task-inheritance/plan.md).
|
||||
|
||||
Reference in New Issue
Block a user