diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go index 1627ac73b7..adbb1704ee 100644 --- a/apps/backend/internal/backendapp/adapters.go +++ b/apps/backend/internal/backendapp/adapters.go @@ -1046,10 +1046,12 @@ func (w *orchestratorWrapper) StartCreatedSessionWithPromptContext( attachments []v1.MessageAttachment, references []v1.EntityReference, promptReferenceContext string, + promptReferencesPrepared bool, ) (*executor.TaskExecution, error) { return w.svc.StartCreatedSessionWithPromptContext( ctx, taskID, sessionID, agentProfileID, prompt, skipMessageRecord, planMode, autoStart, attachments, references, promptReferenceContext, + promptReferencesPrepared, ) } @@ -1063,12 +1065,33 @@ func (w *orchestratorWrapper) StartCreatedSessionWithPromptContextAndCanvasGuida attachments []v1.MessageAttachment, references []v1.EntityReference, promptReferenceContext string, + promptReferencesPrepared bool, canvasGuidanceResolved, includeCanvasGuidance bool, ) (*executor.TaskExecution, error) { return w.svc.StartCreatedSessionWithPromptContextAndCanvasGuidance( ctx, taskID, sessionID, agentProfileID, prompt, skipMessageRecord, planMode, autoStart, attachments, references, promptReferenceContext, - canvasGuidanceResolved, includeCanvasGuidance, + promptReferencesPrepared, canvasGuidanceResolved, includeCanvasGuidance, + ) +} + +// StartCreatedSessionWithPromptContextAndCanvasGuidancePreservingDirectPrompt +// forwards a first direct prompt whose task brief was admitted and persisted +// before launch. +func (w *orchestratorWrapper) StartCreatedSessionWithPromptContextAndCanvasGuidancePreservingDirectPrompt( + ctx context.Context, + taskID, sessionID, agentProfileID, prompt string, + skipMessageRecord, planMode, autoStart bool, + attachments []v1.MessageAttachment, + references []v1.EntityReference, + promptReferenceContext string, + promptReferencesPrepared bool, + canvasGuidanceResolved, includeCanvasGuidance bool, +) (*executor.TaskExecution, error) { + return w.svc.StartCreatedSessionWithPromptContextAndCanvasGuidancePreservingDirectPrompt( + ctx, taskID, sessionID, agentProfileID, prompt, + skipMessageRecord, planMode, autoStart, attachments, references, promptReferenceContext, + promptReferencesPrepared, canvasGuidanceResolved, includeCanvasGuidance, ) } diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index 038ca4d752..f01da7f1a4 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -5287,6 +5287,11 @@ const metaKeyUserMessageRecorded = "user_message_recorded" // one prompt. const MetaKeyTurnStartAlreadyProcessed = "turn_start_already_processed" +// MetaKeyInitialTaskBriefDispatchPending marks a queued user prompt that lost +// the atomic initial-task-brief admission race. Its enqueue path must wait for +// the admitted candidate to launch before attempting a fast-path drain. +const MetaKeyInitialTaskBriefDispatchPending = "initial_task_brief_dispatch_pending" + func turnStartAlreadyProcessed(metadata map[string]interface{}) bool { processed, _ := metadata[MetaKeyTurnStartAlreadyProcessed].(bool) return processed diff --git a/apps/backend/internal/orchestrator/prompt_launch_fallback_test.go b/apps/backend/internal/orchestrator/prompt_launch_fallback_test.go index 288f4cc2c8..f9e3d4b372 100644 --- a/apps/backend/internal/orchestrator/prompt_launch_fallback_test.go +++ b/apps/backend/internal/orchestrator/prompt_launch_fallback_test.go @@ -152,6 +152,59 @@ func TestApplyWorkflowAndPlanMode_PreservesAcceptedContextWithoutWorkflow(t *tes require.Equal(t, 1, strings.Count(got, sysprompt.Wrap(trustedContext))) } +// @covers AC-TASKS-SAVED-PROMPT-DELIVERY-001.11 +func TestApplyWorkflowAndPlanMode_PreservesEmptyAcceptedPromptSnapshot(t *testing.T) { + ctx := context.Background() + promptService := newPromptServiceForLaunchFallbackTest(t) + preparedPrompt, trustedContext := promptService.AppendReferenceExpansionsWithContext( + ctx, "Follow @rules.", zap.NewNop(), + ) + require.Equal(t, "Follow @rules.", preparedPrompt) + require.Empty(t, trustedContext) + + changedContent := "Apply the rules created after admission." + _, err := promptService.CreatePrompt(ctx, "rules", changedContent) + require.NoError(t, err) + + tests := []struct { + name string + workflowStepID string + stepGetter *mockStepGetter + }{ + {name: "without workflow"}, + { + name: "with workflow", + workflowStepID: "step-accepted-empty", + stepGetter: func() *mockStepGetter { + getter := newMockStepGetter() + getter.steps["step-accepted-empty"] = &wfmodels.WorkflowStep{ + ID: "step-accepted-empty", WorkflowID: "workflow-accepted-empty", Prompt: "Apply the step template:\n\n{{task_prompt}}", + } + return getter + }(), + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stepGetter := test.stepGetter + if stepGetter == nil { + stepGetter = newMockStepGetter() + } + svc := createTestService(setupTestRepo(t), stepGetter, newMockTaskRepo()) + svc.promptExpander = promptService + + got, _, gotTrustedContext := svc.applyWorkflowAndPlanModeWithPromptContextOptions( + ctx, preparedPrompt, "task-accepted-empty", "session-accepted-empty", test.workflowStepID, + false, false, false, false, trustedContext, true, false, + ) + + require.Empty(t, gotTrustedContext) + require.Contains(t, got, "Follow @rules.") + require.NotContains(t, got, changedContent) + }) + } +} + // @covers AC-TASKS-SAVED-PROMPT-DELIVERY-001.4, AC-TASKS-SAVED-PROMPT-DELIVERY-001.5, AC-TASKS-SAVED-PROMPT-DELIVERY-001.8 func TestApplyWorkflowAndPlanMode_WithoutWorkflowGuards(t *testing.T) { ctx := context.Background() @@ -307,6 +360,66 @@ func TestStartCreatedSession_ExpandsSavedPromptsWithoutWorkflowStep(t *testing.T require.Equal(t, 1, strings.Count(dispatchedPrompt, "### @principles")) } +// @covers AC-TASKS-INITIAL-TASK-BRIEF-001.2, AC-TASKS-INITIAL-TASK-BRIEF-001.4 +func TestStartCreatedSession_InitialTaskBrief(t *testing.T) { + ctx := context.Background() + const ( + taskBrief = "The task brief must remain visible." + instruction = "Start with the user instruction." + stepID = "step-initial-task-brief" + ) + + for _, test := range []struct { + name string + stepPrompt string + }{ + {name: "empty step template"}, + {name: "placeholder step template", stepPrompt: "Follow the step guidance.\n\n{{task_prompt}}"}, + {name: "replacing step template", stepPrompt: "Follow the step guidance."}, + } { + t.Run(test.name, func(t *testing.T) { + repo := setupTestRepo(t) + taskID := "task-initial-brief-" + strings.ReplaceAll(test.name, " ", "-") + sessionID := "session-initial-brief-" + strings.ReplaceAll(test.name, " ", "-") + seedTaskAndSessionWithStep(t, repo, taskID, sessionID, stepID) + stepGetter := newMockStepGetter() + stepGetter.steps[stepID] = &wfmodels.WorkflowStep{ + ID: stepID, WorkflowID: "wf1", Name: "Initial brief step", Prompt: test.stepPrompt, + } + taskRepo := newMockTaskRepo() + taskRepo.tasks[taskID] = &v1.Task{ + ID: taskID, Title: "Initial brief task", Description: taskBrief, State: v1.TaskStateInProgress, + } + var launchedPrompt string + agentMgr := &mockAgentManager{ + repoForExecutionLookup: repo, + launchAgentFunc: func(_ context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + launchedPrompt = req.TaskDescription + return &executor.LaunchAgentResponse{AgentExecutionID: "exec-initial-brief"}, nil + }, + } + svc := createTestServiceWithScheduler(repo, stepGetter, taskRepo, agentMgr) + messages := &mockMessageCreator{} + svc.messageCreator = messages + + _, err := svc.StartCreatedSessionWithPromptContextAndCanvasGuidancePreservingDirectPrompt( + ctx, taskID, sessionID, "profile1", taskBrief+"\n\n"+instruction, + false, false, false, nil, nil, "", false, true, false, + ) + require.NoError(t, err) + require.NotEmpty(t, launchedPrompt) + visible := sysprompt.StripSystemContent(launchedPrompt) + require.Contains(t, visible, taskBrief) + require.Contains(t, visible, instruction) + require.Equal(t, 1, strings.Count(visible, taskBrief)) + require.Equal(t, 1, strings.Count(visible, instruction)) + if test.stepPrompt != "" { + require.Contains(t, visible, "Follow the step guidance.") + } + }) + } +} + // @covers AC-TASKS-SAVED-PROMPT-DELIVERY-001.11 func TestStartCreatedSession_PreservesAcceptedPromptContextWithoutWorkflowStep(t *testing.T) { ctx := context.Background() @@ -344,6 +457,7 @@ func TestStartCreatedSession_PreservesAcceptedPromptContextWithoutWorkflowStep(t _, err = svc.StartCreatedSessionWithPromptContext( ctx, "task1", "session1", "profile1", preparedPrompt, false, false, false, nil, nil, trustedContext, + true, ) require.NoError(t, err) require.Len(t, messages.userMessages, 1) @@ -392,6 +506,7 @@ func TestStartCreatedSession_DropsAcceptedPromptContextWhenDynamicRouteIsPassthr _, err = svc.StartCreatedSessionWithPromptContext( ctx, "task1", "session1", dynamicProfileID, preparedPrompt, false, false, false, nil, nil, trustedContext, + true, ) require.NoError(t, err) require.Contains(t, dispatchedPrompt, "Follow @principles.") diff --git a/apps/backend/internal/orchestrator/queue_user_prompt_fastpath_test.go b/apps/backend/internal/orchestrator/queue_user_prompt_fastpath_test.go index 4a25a68d75..886c288c15 100644 --- a/apps/backend/internal/orchestrator/queue_user_prompt_fastpath_test.go +++ b/apps/backend/internal/orchestrator/queue_user_prompt_fastpath_test.go @@ -157,6 +157,27 @@ func TestQueueUserPrompt_T2SkipsFastPathWhenInFlight(t *testing.T) { } } +// TestQueueUserPrompt_T2DefersInitialTaskBriefContender pins the first-prompt +// ordering contract: a candidate that lost atomic admission remains queued +// while the admitted candidate launches, even if the session is otherwise +// promptable. The ready/boot-ready lifecycle drain delivers it afterward. +func TestQueueUserPrompt_T2DefersInitialTaskBriefContender(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "t1", "s1", models.TaskSessionStateWaitingForInput) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + + if err := svc.QueueUserPrompt( + ctx, "t1", "s1", "later first-message contender", "", false, nil, + map[string]interface{}{MetaKeyInitialTaskBriefDispatchPending: true}, true, + ); err != nil { + t.Fatalf("QueueUserPrompt: %v", err) + } + if got := svc.messageQueue.GetStatus(ctx, "s1").Count; got != 1 { + t.Fatalf("post-enqueue queue count = %d, want 1 (initial contender must wait for admitted launch)", got) + } +} + // TestQueueUserPrompt_T2SkipsFastPathOnWIPWait pins the WIP admission // contract: a task that has not been WIP-admitted and is queued for // admission (QueuedForStepID != "") must NOT fast-path drain. The diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 5fe5adb2cc..6b15946e96 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -299,6 +299,7 @@ type DirectPromptStarter interface { attachments []v1.MessageAttachment, references []v1.EntityReference, promptReferenceContext string, + promptReferencesPrepared bool, ) (*executor.TaskExecution, error) } @@ -315,6 +316,23 @@ type DirectPromptStarterWithCanvasGuidance interface { attachments []v1.MessageAttachment, references []v1.EntityReference, promptReferenceContext string, + promptReferencesPrepared bool, + canvasGuidanceResolved, includeCanvasGuidance bool, + ) (*executor.TaskExecution, error) +} + +// DirectPromptStarterWithCanvasGuidanceAndPreservedPrompt starts a prepared +// direct-message session without reapplying workflow replacement semantics to +// a prompt that already contains the task brief and user instruction. +type DirectPromptStarterWithCanvasGuidanceAndPreservedPrompt interface { + StartCreatedSessionWithPromptContextAndCanvasGuidancePreservingDirectPrompt( + ctx context.Context, + taskID, sessionID, agentProfileID, prompt string, + skipMessageRecord, planMode, autoStart bool, + attachments []v1.MessageAttachment, + references []v1.EntityReference, + promptReferenceContext string, + promptReferencesPrepared bool, canvasGuidanceResolved, includeCanvasGuidance bool, ) (*executor.TaskExecution, error) } @@ -3820,6 +3838,12 @@ func (s *Service) QueueUserPrompt( return err } s.publishQueueStatusEvent(ctx, sessionID) + if deferFastPath, _ := queueMetadata[MetaKeyInitialTaskBriefDispatchPending].(bool); deferFastPath { + // A later first-message contender is already durably queued. Let the + // admitted candidate launch first; the normal agent-ready/boot-ready + // drains will deliver this entry in FIFO order. + return nil + } // T2: enqueue-side fast-path drain. The user's WIP wait is a // first-class contract (the dispatcher gates on diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index e0ac65037e..ced8c0e670 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -512,10 +512,12 @@ func (s *Service) StartCreatedSessionWithPromptContext( attachments []v1.MessageAttachment, references []v1.EntityReference, promptReferenceContext string, + promptReferencesPrepared bool, ) (*executor.TaskExecution, error) { return s.startCreatedSession( ctx, taskID, sessionID, agentProfileID, prompt, - skipMessageRecord, planMode, autoStart, attachments, references, promptReferenceContext, startCreatedSessionOptions{}, + skipMessageRecord, planMode, autoStart, attachments, references, promptReferenceContext, + startCreatedSessionOptions{promptReferencesPrepared: promptReferencesPrepared}, ) } @@ -530,14 +532,41 @@ func (s *Service) StartCreatedSessionWithPromptContextAndCanvasGuidance( attachments []v1.MessageAttachment, references []v1.EntityReference, promptReferenceContext string, + promptReferencesPrepared bool, canvasGuidanceResolved, includeCanvasGuidance bool, ) (*executor.TaskExecution, error) { return s.startCreatedSession( ctx, taskID, sessionID, agentProfileID, prompt, skipMessageRecord, planMode, autoStart, attachments, references, promptReferenceContext, startCreatedSessionOptions{ - canvasGuidanceResolved: canvasGuidanceResolved, - includeCanvasGuidance: includeCanvasGuidance, + canvasGuidanceResolved: canvasGuidanceResolved, + includeCanvasGuidance: includeCanvasGuidance, + promptReferencesPrepared: promptReferencesPrepared, + }, + ) +} + +// StartCreatedSessionWithPromptContextAndCanvasGuidancePreservingDirectPrompt +// starts a prepared direct-message session with a prompt whose visible task +// brief and instruction were already admitted and persisted together. +func (s *Service) StartCreatedSessionWithPromptContextAndCanvasGuidancePreservingDirectPrompt( + ctx context.Context, + taskID, sessionID, agentProfileID, prompt string, + skipMessageRecord, planMode, autoStart bool, + attachments []v1.MessageAttachment, + references []v1.EntityReference, + promptReferenceContext string, + promptReferencesPrepared bool, + canvasGuidanceResolved, includeCanvasGuidance bool, +) (*executor.TaskExecution, error) { + return s.startCreatedSession( + ctx, taskID, sessionID, agentProfileID, prompt, + skipMessageRecord, planMode, autoStart, attachments, references, promptReferenceContext, + startCreatedSessionOptions{ + canvasGuidanceResolved: canvasGuidanceResolved, + includeCanvasGuidance: includeCanvasGuidance, + promptReferencesPrepared: promptReferencesPrepared, + preserveDirectPrompt: true, }, ) } @@ -576,6 +605,10 @@ type startCreatedSessionOptions struct { // locally because no earlier producer has made a trusted decision. canvasGuidanceResolved bool includeCanvasGuidance bool + // promptReferencesPrepared records that the caller accepted the exact + // server-owned expansion snapshot, including an empty snapshot. + promptReferencesPrepared bool + preserveDirectPrompt bool } //nolint:cyclop,funlen,gocognit // Existing complexity inherited from session-lifecycle handling. @@ -762,10 +795,19 @@ func (s *Service) startCreatedSession( planModeActive := planMode if !options.promptAlreadyComposed { var generatedPromptReferenceContext string - effectivePrompt, planModeActive, generatedPromptReferenceContext = s.applyWorkflowAndPlanModeWithPromptContext( - ctx, effectivePrompt, taskID, sessionID, dbTask.WorkflowStepID, - planMode, task.IsEphemeral, session.IsPassthrough, false, promptReferenceContext, - ) + if options.preserveDirectPrompt { + effectivePrompt, planModeActive, generatedPromptReferenceContext = s.applyWorkflowAndPlanModeWithPromptContextOptions( + ctx, effectivePrompt, taskID, sessionID, dbTask.WorkflowStepID, + planMode, task.IsEphemeral, session.IsPassthrough, false, promptReferenceContext, + options.promptReferencesPrepared, true, + ) + } else { + effectivePrompt, planModeActive, generatedPromptReferenceContext = s.applyWorkflowAndPlanModeWithPromptContextOptions( + ctx, effectivePrompt, taskID, sessionID, dbTask.WorkflowStepID, + planMode, task.IsEphemeral, session.IsPassthrough, false, promptReferenceContext, + options.promptReferencesPrepared, false, + ) + } if generatedPromptReferenceContext != "" { // The workflow helper preserves a direct message's acceptance-time // context. Auto-started workflow prompts have no prior context, so their @@ -2212,6 +2254,26 @@ func (s *Service) applyWorkflowAndPlanModeWithPromptContext( isPassthrough bool, skipStepPrompt bool, trustedPromptContext string, +) (string, bool, string) { + return s.applyWorkflowAndPlanModeWithPromptContextOptions( + ctx, prompt, taskID, sessionID, workflowStepID, planMode, + isEphemeral, isPassthrough, skipStepPrompt, trustedPromptContext, false, false, + ) +} + +func (s *Service) applyWorkflowAndPlanModeWithPromptContextOptions( + ctx context.Context, + prompt string, + taskID string, + sessionID string, + workflowStepID string, + planMode bool, + isEphemeral bool, + isPassthrough bool, + skipStepPrompt bool, + trustedPromptContext string, + promptReferencesPrepared bool, + preserveDirectPrompt bool, ) (string, bool, string) { effectivePrompt := prompt promptReferenceContext := "" @@ -2238,29 +2300,16 @@ func (s *Service) applyWorkflowAndPlanModeWithPromptContext( } else if step != nil { workflowPromptComposed = true stepHasPlanMode = step.HasOnEnterAction(wfmodels.OnEnterEnablePlanMode) - if trustedPromptContext != "" { - effectivePrompt, promptReferenceContext = s.buildWorkflowPromptWithTrustedContext( - ctx, effectivePrompt, step, taskID, sessionID, isPassthrough, skipStepPrompt, trustedPromptContext, - ) - } else { - effectivePrompt, promptReferenceContext = s.buildWorkflowPromptWithContext( - ctx, effectivePrompt, step, taskID, sessionID, isPassthrough, skipStepPrompt, - ) - } + effectivePrompt, promptReferenceContext = s.buildWorkflowStepPrompt( + ctx, effectivePrompt, step, taskID, sessionID, isPassthrough, skipStepPrompt, + trustedPromptContext, promptReferencesPrepared, preserveDirectPrompt, + ) } } if !workflowPromptComposed { - if trustedPromptContext != "" { - trustedBlock := sysprompt.Wrap(trustedPromptContext) - if !strings.Contains(effectivePrompt, trustedBlock) { - effectivePrompt += "\n\n" + trustedBlock - } - promptReferenceContext = trustedPromptContext - } else { - effectivePrompt, promptReferenceContext = s.expandPromptReferencesWithContext( - ctx, effectivePrompt, isPassthrough, - ) - } + effectivePrompt, promptReferenceContext = s.applyDirectPromptReferenceContext( + ctx, effectivePrompt, isPassthrough, trustedPromptContext, promptReferencesPrepared, + ) } if planMode && !stepHasPlanMode { @@ -2273,6 +2322,52 @@ func (s *Service) applyWorkflowAndPlanModeWithPromptContext( return effectivePrompt, planMode || stepHasPlanMode, promptReferenceContext } +func (s *Service) applyDirectPromptReferenceContext( + ctx context.Context, + prompt string, + isPassthrough bool, + trustedPromptContext string, + promptReferencesPrepared bool, +) (string, string) { + if trustedPromptContext != "" { + trustedBlock := sysprompt.Wrap(trustedPromptContext) + if !strings.Contains(prompt, trustedBlock) { + prompt += "\n\n" + trustedBlock + } + return prompt, trustedPromptContext + } + if promptReferencesPrepared { + // An accepted empty snapshot is authoritative. Re-expanding here + // would observe saved-prompt definitions changed after admission. + return prompt, "" + } + return s.expandPromptReferencesWithContext(ctx, prompt, isPassthrough) +} + +func (s *Service) buildWorkflowStepPrompt( + ctx context.Context, + basePrompt string, + step *wfmodels.WorkflowStep, + taskID string, + sessionID string, + isPassthrough bool, + skipStepPrompt bool, + trustedPromptContext string, + promptReferencesPrepared bool, + preserveDirectPrompt bool, +) (string, string) { + if trustedPromptContext != "" { + return s.buildWorkflowPromptWithTrustedContextOptions( + ctx, basePrompt, step, taskID, sessionID, isPassthrough, skipStepPrompt, + trustedPromptContext, preserveDirectPrompt, promptReferencesPrepared, + ) + } + return s.buildWorkflowPromptWithContextOptions( + ctx, basePrompt, step, taskID, sessionID, isPassthrough, skipStepPrompt, preserveDirectPrompt, + promptReferencesPrepared, + ) +} + // backfillInitialUserMessageIfMissing records the task's description as the // first user message when the session has no messages at all. This covers the // edge case where the initial launch failed before recordInitialMessage was @@ -2372,8 +2467,26 @@ func (s *Service) buildWorkflowPromptWithContext( isPassthrough bool, skipStepPrompt bool, ) (string, string) { - return s.buildWorkflowPromptWithTrustedContext( + return s.buildWorkflowPromptWithContextOptions( + ctx, basePrompt, step, taskID, sessionID, isPassthrough, skipStepPrompt, false, + false, + ) +} + +func (s *Service) buildWorkflowPromptWithContextOptions( + ctx context.Context, + basePrompt string, + step *wfmodels.WorkflowStep, + taskID string, + sessionID string, + isPassthrough bool, + skipStepPrompt bool, + preserveDirectPrompt bool, + promptReferencesPrepared bool, +) (string, string) { + return s.buildWorkflowPromptWithTrustedContextOptions( ctx, basePrompt, step, taskID, sessionID, isPassthrough, skipStepPrompt, "", + preserveDirectPrompt, promptReferencesPrepared, ) } @@ -2390,6 +2503,24 @@ func (s *Service) buildWorkflowPromptWithTrustedContext( isPassthrough bool, skipStepPrompt bool, trustedPromptContext string, +) (string, string) { + return s.buildWorkflowPromptWithTrustedContextOptions( + ctx, basePrompt, step, taskID, sessionID, isPassthrough, skipStepPrompt, + trustedPromptContext, false, false, + ) +} + +func (s *Service) buildWorkflowPromptWithTrustedContextOptions( + ctx context.Context, + basePrompt string, + step *wfmodels.WorkflowStep, + taskID string, + sessionID string, + isPassthrough bool, + skipStepPrompt bool, + trustedPromptContext string, + preserveDirectPrompt bool, + promptReferencesPrepared bool, ) (string, string) { _ = sessionID var parts []string @@ -2402,7 +2533,7 @@ func (s *Service) buildWorkflowPromptWithTrustedContext( // fallback for this one entry; only the workflow-level block above (and any // one-time move instructions appended by the caller) remain. if !skipStepPrompt { - parts = append(parts, stepPromptBody(step, taskID, basePrompt)) + parts = append(parts, stepPromptBodyWithOptions(step, taskID, basePrompt, preserveDirectPrompt)) } joined := strings.Join(parts, "\n\n") @@ -2413,6 +2544,9 @@ func (s *Service) buildWorkflowPromptWithTrustedContext( } return joined, trustedPromptContext } + if promptReferencesPrepared { + return joined, "" + } return s.expandPromptReferencesWithContext(ctx, joined, isPassthrough) } @@ -2421,6 +2555,10 @@ func (s *Service) buildWorkflowPromptWithTrustedContext( // without that placeholder used verbatim, or the base prompt when the step has // no prompt of its own. func stepPromptBody(step *wfmodels.WorkflowStep, taskID, basePrompt string) string { + return stepPromptBodyWithOptions(step, taskID, basePrompt, false) +} + +func stepPromptBodyWithOptions(step *wfmodels.WorkflowStep, taskID, basePrompt string, preserveDirectPrompt bool) string { if step.Prompt == "" { return basePrompt } @@ -2428,6 +2566,9 @@ func stepPromptBody(step *wfmodels.WorkflowStep, taskID, basePrompt string) stri if strings.Contains(interpolatedPrompt, "{{task_prompt}}") { return strings.Replace(interpolatedPrompt, "{{task_prompt}}", basePrompt, 1) } + if preserveDirectPrompt && strings.TrimSpace(basePrompt) != "" { + return interpolatedPrompt + "\n\n" + basePrompt + } // A step prompt without {{task_prompt}} is treated as the full visible prompt. return interpolatedPrompt } diff --git a/apps/backend/internal/task/handlers/cancellation_pending_test.go b/apps/backend/internal/task/handlers/cancellation_pending_test.go index 528a838620..8743e1c379 100644 --- a/apps/backend/internal/task/handlers/cancellation_pending_test.go +++ b/apps/backend/internal/task/handlers/cancellation_pending_test.go @@ -66,7 +66,7 @@ func (o *orchestratorWithCancellation) CancellationPending(string) bool { return o.pending } -func (o messageOrchestratorWithCancellation) CancellationPending(sessionID string) bool { +func (o *messageOrchestratorWithCancellation) CancellationPending(sessionID string) bool { return o.pendingBySession[sessionID] } diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go index f849e80d95..a03b5b6fdb 100644 --- a/apps/backend/internal/task/handlers/message_handlers.go +++ b/apps/backend/internal/task/handlers/message_handlers.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "fmt" "net/http" "strconv" "strings" @@ -73,8 +74,10 @@ type taskCanvasGuidanceResolver interface { } type canvasGuidanceProjection struct { - resolved bool - include bool + resolved bool + include bool + preserveDirectPrompt bool + promptReferencesPrepared bool } // MessageHandlers handles WebSocket requests for messages @@ -225,17 +228,18 @@ func (h *MessageHandlers) prepareDirectPrompt( ctx context.Context, content string, isPassthrough bool, -) (string, string) { +) (string, string, bool) { if h.orchestrator == nil || isPassthrough { - return content, "" + return content, "", false } preparer, ok := h.orchestrator.(orchestrator.DirectPromptPreparer) if !ok { // Keep test and compatibility doubles that do not provide the optional // seam functional. The production wrapper always implements it. - return content, "" + return content, "", false } - return preparer.PrepareDirectPrompt(ctx, content, isPassthrough) + prepared, trustedContext := preparer.PrepareDirectPrompt(ctx, content, isPassthrough) + return prepared, trustedContext, true } // RegisterMessageRoutes registers message HTTP + WebSocket handlers @@ -471,8 +475,10 @@ type wsAddMessageRequest struct { RequirePrimarySession bool `json:"require_primary_session,omitempty"` // These fields are server-owned and are carried only from message admission // to the created-session dispatch. They are intentionally not JSON fields. - canvasGuidanceResolved bool - includeCanvasGuidance bool + canvasGuidanceResolved bool + includeCanvasGuidance bool + initialTaskBriefSelected bool + promptReferencesPrepared bool } type addMessageReplayIdentity struct { @@ -566,6 +572,19 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w return response, nil } } + // Validate the task/session relationship before loading either mutable task + // state or its description. Authorizing the two IDs independently would let + // a mismatched request compose one task's brief for another task's session. + if err := h.service.AuthorizeTaskSessionPromptAccess(admissionCtx, req.TaskID, req.TaskSessionID); err != nil { + if service.IsForbidden(err) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeForbidden, "Cannot send messages to this session", nil) + } + if errors.Is(err, repoerrors.ErrTaskNotFound) || errors.Is(err, sql.ErrNoRows) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "Task and session do not match", nil) + } + h.logger.Error("failed to authorize task and session", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to authorize message", nil) + } // Check session state — may block the message or flag it as a create-start sessionResp, wsErr := h.checkSessionStateForMessage(ctx, msg, req.TaskSessionID) @@ -689,12 +708,14 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w // Passthrough sessions skip the wrap: the prompt is typed straight into // the agent CLI's TTY and the user sees it verbatim — they don't want a // wall of MCP-tool boilerplate prepended to "hello". - if len(req.PlanCommentRefs) > 0 { - req.Content = plancomments.WithPlaceholder(req.Content) - } - storedContent, trustedPromptContext := h.prepareDirectPrompt( + preparedContent, trustedPromptContext, promptReferencesPrepared := h.prepareDirectPrompt( ctx, req.Content, sessionResp.Session.IsPassthrough, ) + req.promptReferencesPrepared = promptReferencesPrepared + storedContent := preparedContent + if len(req.PlanCommentRefs) > 0 { + storedContent = plancomments.WithPlaceholder(storedContent) + } // Resolve browser prompt definitions before appending the server-owned // entity block. The prompt sanitizer removes untrusted browser blocks and // must not consume the opening tag of this trusted context. @@ -737,6 +758,9 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w req.canvasGuidanceResolved = canvasGuidanceResolved req.includeCanvasGuidance = includeCanvasGuidance } + initialTaskBrief := h.prepareInitialTaskBriefCandidate( + ctx, req, sessionResp, task, configMode, startCreatedSession, titleOwner, hasMessageContent, + ) req.Content = storedContent if planCommentAttachmentClaim == nil { err = h.service.ClaimMessageAttachments(ctx, req.TaskID, req.TaskSessionID, req.Attachments) @@ -763,41 +787,69 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w RequirePrimarySession: req.RequirePrimarySession, ExpectedSessionState: sessionResp.Session.State, AttachmentClaim: planCommentAttachmentClaim, + InitialTaskBrief: initialTaskBrief, } var message *models.Message // Every comment-bearing message uses the queue row as a durable dispatch // receipt. Promptable sessions drain it immediately; workflow waits and // process restarts retain the same caller-owned delivery identity. atomicQueuedPlanComments := len(req.PlanCommentRefs) > 0 - switch { - case atomicQueuedPlanComments: - coordinator, ok := h.orchestrator.(AtomicQueuedPromptCoordinator) + var queuedCoordinator AtomicQueuedPromptCoordinator + if atomicQueuedPlanComments { + var ok bool + queuedCoordinator, ok = h.orchestrator.(AtomicQueuedPromptCoordinator) if !ok { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Queued prompt admission is unavailable", nil) } - queueMetadata := make(map[string]interface{}, len(createRequest.Metadata)+1) - for key, value := range createRequest.Metadata { - queueMetadata[key] = value + } + createMessage := func() (*models.Message, error) { + if atomicQueuedPlanComments { + queueMetadata := make(map[string]interface{}, len(createRequest.Metadata)+1) + for key, value := range createRequest.Metadata { + queueMetadata[key] = value + } + queueMetadata["user_message_recorded"] = true + queueMetadata[messagequeue.MetadataDurableTranscriptMessageID] = req.ClientMessageID + queueMetadata[orchestrator.MetaKeyTurnStartAlreadyProcessed] = true + queued := &messagequeue.QueuedMessage{ + ID: req.ClientMessageID, SessionID: req.TaskSessionID, TaskID: req.TaskID, + Content: storedContent, Model: req.Model, PlanMode: req.PlanMode, + Attachments: queuedMessageAttachments(req.Attachments), Metadata: queueMetadata, + QueuedBy: messagequeue.QueuedByUser, + } + created, createErr := h.service.CreateQueuedMessageIdempotent( + admissionCtx, req.ClientMessageID, createRequest, queued, queuedCoordinator.MaxQueuedPromptsPerSession(), + ) + if createErr == nil && (createRequest.InitialTaskBrief == nil || + createRequest.InitialTaskBrief.Selected || (created != nil && created.PromptIndex == 1)) { + queuedCoordinator.NotifyQueuedUserPrompt(ctx, req.TaskID, req.TaskSessionID) + } + return created, createErr } - queueMetadata["user_message_recorded"] = true - queueMetadata[messagequeue.MetadataDurableTranscriptMessageID] = req.ClientMessageID - queueMetadata[orchestrator.MetaKeyTurnStartAlreadyProcessed] = true - queued := &messagequeue.QueuedMessage{ - ID: req.ClientMessageID, SessionID: req.TaskSessionID, TaskID: req.TaskID, - Content: storedContent, Model: req.Model, PlanMode: req.PlanMode, - Attachments: queuedMessageAttachments(req.Attachments), Metadata: queueMetadata, - QueuedBy: messagequeue.QueuedByUser, + if req.ClientMessageID != "" { + return h.service.CreateMessageIdempotent(admissionCtx, req.ClientMessageID, createRequest) } - message, err = h.service.CreateQueuedMessageIdempotent( - admissionCtx, req.ClientMessageID, createRequest, queued, coordinator.MaxQueuedPromptsPerSession(), + return h.service.CreateMessage(admissionCtx, createRequest) + } + for refreshAttempt := 0; ; refreshAttempt++ { + message, err = createMessage() + if !errors.Is(err, repoerrors.ErrInitialTaskBriefStale) || initialTaskBrief == nil || refreshAttempt > 0 { + break + } + // The repository owns the final description snapshot. If it changed after + // preparation, rebuild only this candidate and retry admission. Turn-start + // and title ownership already ran for this accepted request and must not run + // again while the candidate catches up with the current task description. + freshTask, refreshErr := h.service.GetTask(admissionCtx, req.TaskID) + if refreshErr != nil { + err = fmt.Errorf("refresh task for initial brief admission: %w", refreshErr) + break + } + task = freshTask + initialTaskBrief = h.prepareInitialTaskBriefCandidate( + admissionCtx, req, sessionResp, task, configMode, startCreatedSession, titleOwner, hasMessageContent, ) - if err == nil { - coordinator.NotifyQueuedUserPrompt(ctx, req.TaskID, req.TaskSessionID) - } - case req.ClientMessageID != "": - message, err = h.service.CreateMessageIdempotent(admissionCtx, req.ClientMessageID, createRequest) - default: - message, err = h.service.CreateMessage(admissionCtx, createRequest) + createRequest.InitialTaskBrief = initialTaskBrief } if err != nil { if response := planCommentMessageError(msg, err); response != nil { @@ -813,7 +865,46 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create message", nil) } req.Content = message.Content - if turnStartResult.Queued && !atomicQueuedPlanComments { + initialTaskBriefQueued := initialTaskBrief != nil && !initialTaskBrief.Selected + // An idempotent create can return a row committed by another process, so + // the candidate pointer is not necessarily the object that selected the + // first slot. Recover that result from the committed row before deciding who + // owns created-session launch. + if initialTaskBriefQueued && message.PromptIndex == 1 && message.Content == initialTaskBrief.Content { + initialTaskBrief.Selected = true + initialTaskBriefQueued = false + } + if initialTaskBrief != nil && initialTaskBrief.Selected { + req.initialTaskBriefSelected = true + trustedPromptContext = initialTaskBrief.PromptReferenceContext + promptReferencesPrepared = initialTaskBrief.PromptReferencesPrepared + req.promptReferencesPrepared = promptReferencesPrepared + } + if initialTaskBriefQueued && h.orchestrator != nil && !atomicQueuedPlanComments { + queueMetadata := meta.ToMap() + if queueMetadata == nil { + queueMetadata = make(map[string]interface{}) + } + queueMetadata[orchestrator.MetaKeyTurnStartAlreadyProcessed] = true + queueMetadata[orchestrator.MetaKeyInitialTaskBriefDispatchPending] = true + if err := h.orchestrator.QueueUserPrompt( + ctx, + req.TaskID, + req.TaskSessionID, + req.Content, + req.Model, + req.PlanMode, + req.Attachments, + queueMetadata, + true, + ); err != nil { + h.logger.Warn("failed to queue competing initial task brief prompt", + zap.String("task_id", req.TaskID), + zap.String("session_id", req.TaskSessionID), + zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to queue prompt", nil) + } + } else if turnStartResult.Queued && !atomicQueuedPlanComments { if err := h.orchestrator.QueueUserPrompt( ctx, req.TaskID, @@ -843,7 +934,7 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w // an orchestrator is available. This runs async so the WS request can // respond immediately. Plan mode changes the execution prompt and agent // behavior; it does not make message.add a record-only operation. - if h.orchestrator != nil && !turnStartResult.Queued && !atomicQueuedPlanComments { + if h.orchestrator != nil && !turnStartResult.Queued && !atomicQueuedPlanComments && !initialTaskBriefQueued { h.dispatchPromptAsync( ctx, req, sessionResp.Session.AgentProfileID, startCreatedSession, steer, trustedPromptContext, ) @@ -1217,13 +1308,26 @@ func (h *MessageHandlers) dispatchPromptAsync( promptCtx, taskID, sessionID, agentProfileID, content, model, planMode, attachments, req.EntityReferences, isCreatedSession, trustedPromptContext, canvasGuidanceProjection{ - resolved: req.canvasGuidanceResolved, - include: req.includeCanvasGuidance, + resolved: req.canvasGuidanceResolved, + include: req.includeCanvasGuidance, + preserveDirectPrompt: req.initialTaskBriefSelected, + promptReferencesPrepared: req.promptReferencesPrepared, }, ) }() } +func eligibleForInitialTaskBrief( + task *models.Task, + sessionResp *dto.GetTaskSessionResponse, + configMode, startCreatedSession, hasMessageContent bool, +) bool { + return task != nil && sessionResp != nil && + startCreatedSession && hasMessageContent && + !task.IsEphemeral && !task.IsFromOffice && !configMode && + strings.TrimSpace(task.Description) != "" +} + // forwardMessageAsSteer delivers a message into a still-generating turn. // SteerTask returns nil whether it dispatched the steer or enqueued it behind // pending work (both are success — order is preserved either way). @@ -1291,16 +1395,26 @@ func (h *MessageHandlers) forwardMessageAsPrompt( if len(canvasGuidance) > 0 { projection = canvasGuidance[0] } - if starter, ok := h.orchestrator.(orchestrator.DirectPromptStarterWithCanvasGuidance); ok && len(canvasGuidance) > 0 { + if starter, ok := h.orchestrator.(orchestrator.DirectPromptStarterWithCanvasGuidanceAndPreservedPrompt); ok && + projection.preserveDirectPrompt && len(canvasGuidance) > 0 { + _, err = starter.StartCreatedSessionWithPromptContextAndCanvasGuidancePreservingDirectPrompt( + ctx, taskID, sessionID, agentProfileID, + content, true, planMode, false, attachments, references, trustedPromptContext, + projection.promptReferencesPrepared, + projection.resolved, projection.include, + ) + } else if starter, ok := h.orchestrator.(orchestrator.DirectPromptStarterWithCanvasGuidance); ok && len(canvasGuidance) > 0 { _, err = starter.StartCreatedSessionWithPromptContextAndCanvasGuidance( ctx, taskID, sessionID, agentProfileID, content, true, planMode, false, attachments, references, trustedPromptContext, + projection.promptReferencesPrepared, projection.resolved, projection.include, ) } else if starter, ok := h.orchestrator.(orchestrator.DirectPromptStarter); ok { _, err = starter.StartCreatedSessionWithPromptContext( ctx, taskID, sessionID, agentProfileID, content, true, planMode, false, attachments, references, trustedPromptContext, + projection.promptReferencesPrepared, ) } else { err = h.orchestrator.StartCreatedSession( diff --git a/apps/backend/internal/task/handlers/message_handlers_blocked_test.go b/apps/backend/internal/task/handlers/message_handlers_blocked_test.go index be8e881dc3..1b07f21e8e 100644 --- a/apps/backend/internal/task/handlers/message_handlers_blocked_test.go +++ b/apps/backend/internal/task/handlers/message_handlers_blocked_test.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "github.com/kandev/kandev/internal/task/repository" + "maps" "strings" "sync" "testing" @@ -38,8 +39,8 @@ type messageAddSwitchRepo struct { tasks map[string]*models.Task sessions map[string]*models.TaskSession primaryID string - // messagesMu guards messages: async dispatch goroutines (e.g. a steer that - // falls back and writes an error message) can append while a test reads. + // messagesMu guards this fake's shared state: concurrent handler requests + // exercise the same repository methods while admission is being tested. messagesMu sync.Mutex messages []*models.Message queuedMessage *messagequeue.QueuedMessage @@ -72,6 +73,8 @@ func (r *messageAddSwitchRepo) firstMessageContent() string { } func (r *messageAddSwitchRepo) GetMessage(_ context.Context, id string) (*models.Message, error) { + r.messagesMu.Lock() + defer r.messagesMu.Unlock() if r.idempotentMessage != nil && r.idempotentMessage.ID == id { return r.idempotentMessage, nil } @@ -80,6 +83,8 @@ func (r *messageAddSwitchRepo) GetMessage(_ context.Context, id string) (*models // GetMessageWithPromptIndex returns the message for id with its derived prompt index, mirroring the repository contract. func (r *messageAddSwitchRepo) GetMessageWithPromptIndex(_ context.Context, id string) (*models.Message, error) { + r.messagesMu.Lock() + defer r.messagesMu.Unlock() r.messageLookupCalls++ messageVisible := r.idempotentMessageAfterLookup == 0 || r.messageLookupCalls >= r.idempotentMessageAfterLookup @@ -90,14 +95,22 @@ func (r *messageAddSwitchRepo) GetMessageWithPromptIndex(_ context.Context, id s } func (r *messageAddSwitchRepo) GetTask(_ context.Context, id string) (*models.Task, error) { + r.messagesMu.Lock() + defer r.messagesMu.Unlock() r.taskGetCalls++ if task, ok := r.tasks[id]; ok { - return task, nil + copy := *task + copy.Metadata = maps.Clone(task.Metadata) + copy.Repositories = append([]*models.TaskRepository(nil), task.Repositories...) + copy.WorkspaceFolders = append([]*models.TaskWorkspaceFolder(nil), task.WorkspaceFolders...) + return ©, nil } return nil, sql.ErrNoRows } func (r *messageAddSwitchRepo) UpdateTaskState(_ context.Context, id string, state v1.TaskState) error { + r.messagesMu.Lock() + defer r.messagesMu.Unlock() r.taskStateUpdateCalls++ if task, ok := r.tasks[id]; ok { task.State = state @@ -127,20 +140,25 @@ func (v *fakeReferenceSubmissionValidator) ValidateForSubmission( } type capturedFirstTurn struct { - content string - references []v1.EntityReference + content string + references []v1.EntityReference + promptReferenceContext string } type firstTurnCaptureOrchestrator struct { - started chan capturedFirstTurn - turnStartResult orchestrator.ProcessOnTurnStartResult - queuedPromptCall *queuedPromptCall - queuedNotified bool + started chan capturedFirstTurn + turnStartResult orchestrator.ProcessOnTurnStartResult + mu sync.Mutex + turnStartCalls int + queuedPromptCall *queuedPromptCall + queuedPromptCalls []queuedPromptCall + queuedNotified bool } type queuedPromptCall struct { taskID, sessionID, prompt string userMessageRecorded bool + metadata map[string]interface{} } func (o *firstTurnCaptureOrchestrator) PromptTask( @@ -168,20 +186,45 @@ func (o *firstTurnCaptureOrchestrator) StartCreatedSession( } func (o *firstTurnCaptureOrchestrator) ProcessOnTurnStart(context.Context, string, string) (orchestrator.ProcessOnTurnStartResult, error) { + o.mu.Lock() + o.turnStartCalls++ + o.mu.Unlock() return o.turnStartResult, nil } -func (o *firstTurnCaptureOrchestrator) QueueUserPrompt(_ context.Context, taskID, sessionID, prompt, _ string, _ bool, _ []v1.MessageAttachment, _ map[string]interface{}, userMessageRecorded bool) error { - o.queuedPromptCall = &queuedPromptCall{taskID: taskID, sessionID: sessionID, prompt: prompt, userMessageRecorded: userMessageRecorded} +func (o *firstTurnCaptureOrchestrator) QueueUserPrompt(_ context.Context, taskID, sessionID, prompt, _ string, _ bool, _ []v1.MessageAttachment, metadata map[string]interface{}, userMessageRecorded bool) error { + metadataCopy := make(map[string]interface{}, len(metadata)) + for key, value := range metadata { + metadataCopy[key] = value + } + call := queuedPromptCall{taskID: taskID, sessionID: sessionID, prompt: prompt, userMessageRecorded: userMessageRecorded, metadata: metadataCopy} + o.mu.Lock() + o.queuedPromptCall = &call + o.queuedPromptCalls = append(o.queuedPromptCalls, call) + o.mu.Unlock() return nil } func (*firstTurnCaptureOrchestrator) MaxQueuedPromptsPerSession() int { return 10 } func (o *firstTurnCaptureOrchestrator) NotifyQueuedUserPrompt(context.Context, string, string) { + o.mu.Lock() + defer o.mu.Unlock() o.queuedNotified = true } +func (o *firstTurnCaptureOrchestrator) queueCalls() []queuedPromptCall { + o.mu.Lock() + defer o.mu.Unlock() + return append([]queuedPromptCall(nil), o.queuedPromptCalls...) +} + +func (o *firstTurnCaptureOrchestrator) onTurnStartCount() int { + o.mu.Lock() + defer o.mu.Unlock() + return o.turnStartCalls +} + func (o *firstTurnCaptureOrchestrator) StepRequiresCompletionSignal(context.Context, string) bool { return false } @@ -365,6 +408,8 @@ func TestWSAddMessageRejectsEntityReferencesBeforeTaskMutation(t *testing.T) { } func (r *messageAddSwitchRepo) GetTaskSession(_ context.Context, id string) (*models.TaskSession, error) { + r.messagesMu.Lock() + defer r.messagesMu.Unlock() if r.getCalls == nil { r.getCalls = make(map[string]int) } @@ -382,6 +427,8 @@ func (r *messageAddSwitchRepo) ClaimPromptableTaskSessionIfActive( _ context.Context, id string, ) (models.PromptableTaskSessionClaim, error) { + r.messagesMu.Lock() + defer r.messagesMu.Unlock() session, ok := r.sessions[id] if !ok { return models.PromptableTaskSessionClaim{Status: models.PromptableTaskSessionInactive}, nil @@ -394,6 +441,8 @@ func (r *messageAddSwitchRepo) ClaimPromptableTaskSessionIfActive( } func (r *messageAddSwitchRepo) GetPrimarySessionByTaskID(_ context.Context, taskID string) (*models.TaskSession, error) { + r.messagesMu.Lock() + defer r.messagesMu.Unlock() session, ok := r.sessions[r.primaryID] if !ok || session.TaskID != taskID { return nil, sql.ErrNoRows @@ -413,6 +462,8 @@ func (r *messageAddSwitchRepo) GetActiveTurnBySessionID(_ context.Context, _ str } func (r *messageAddSwitchRepo) CreateTurn(_ context.Context, turn *models.Turn) error { + r.messagesMu.Lock() + defer r.messagesMu.Unlock() r.turns = append(r.turns, turn) return nil } diff --git a/apps/backend/internal/task/handlers/message_handlers_initial_task_brief.go b/apps/backend/internal/task/handlers/message_handlers_initial_task_brief.go new file mode 100644 index 0000000000..05bddcf1b8 --- /dev/null +++ b/apps/backend/internal/task/handlers/message_handlers_initial_task_brief.go @@ -0,0 +1,61 @@ +package handlers + +import ( + "context" + "strings" + + "github.com/kandev/kandev/internal/orchestrator" + "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/plancomments" + "github.com/kandev/kandev/internal/task/repository/admission" +) + +// composeInitialTaskBrief keeps the prepared session's original brief in the +// first direct prompt while preserving the user's instruction as a separate +// paragraph. Trimmed equality avoids repeating a brief that the user already +// supplied verbatim. +func composeInitialTaskBrief(brief, instruction string) string { + brief = strings.TrimSpace(brief) + instruction = strings.TrimSpace(instruction) + if brief == "" { + return instruction + } + if instruction == "" || brief == instruction { + return brief + } + return brief + "\n\n" + instruction +} + +func (h *MessageHandlers) prepareInitialTaskBriefCandidate( + ctx context.Context, + req wsAddMessageRequest, + sessionResp *dto.GetTaskSessionResponse, + task *models.Task, + configMode, startCreatedSession, titleOwner, hasMessageContent bool, +) *admission.InitialTaskBriefCandidate { + if !eligibleForInitialTaskBrief(task, sessionResp, configMode, startCreatedSession, hasMessageContent) { + return nil + } + // Compose the raw brief and instruction before the server-owned preparer sees + // either value. This keeps equality deduplication and saved-prompt expansion + // on one acceptance-time snapshot. + candidateRawContent := composeInitialTaskBrief(task.Description, req.Content) + candidateContent, candidateTrustedPromptContext, promptReferencesPrepared := h.prepareDirectPrompt( + ctx, candidateRawContent, sessionResp.Session.IsPassthrough, + ) + if len(req.PlanCommentRefs) > 0 { + candidateContent = plancomments.WithPlaceholder(candidateContent) + } + candidateContent = orchestrator.AppendEntityReferenceContext(candidateContent, req.EntityReferences) + candidateContent = h.injectMessageContext( + ctx, req, sessionResp, task, configMode, startCreatedSession, titleOwner, + req.includeCanvasGuidance, candidateContent, candidateTrustedPromptContext, + ) + return &admission.InitialTaskBriefCandidate{ + DescriptionSnapshot: task.Description, + Content: candidateContent, + PromptReferenceContext: candidateTrustedPromptContext, + PromptReferencesPrepared: promptReferencesPrepared, + } +} diff --git a/apps/backend/internal/task/handlers/message_handlers_initial_task_brief_test.go b/apps/backend/internal/task/handlers/message_handlers_initial_task_brief_test.go new file mode 100644 index 0000000000..4aaa666440 --- /dev/null +++ b/apps/backend/internal/task/handlers/message_handlers_initial_task_brief_test.go @@ -0,0 +1,620 @@ +package handlers + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "testing" + "testing/synctest" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/orchestrator" + "github.com/kandev/kandev/internal/orchestrator/executor" + "github.com/kandev/kandev/internal/orchestrator/messagequeue" + "github.com/kandev/kandev/internal/sysprompt" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/admission" + "github.com/kandev/kandev/internal/task/repository/repoerrors" + "github.com/kandev/kandev/internal/task/service" + v1 "github.com/kandev/kandev/pkg/api/v1" + ws "github.com/kandev/kandev/pkg/websocket" + "github.com/stretchr/testify/require" +) + +func (r *messageAddSwitchRepo) CreateMessageWithInitialTaskBrief( + _ context.Context, + message *models.Message, + candidate *admission.InitialTaskBriefCandidate, +) error { + r.messagesMu.Lock() + defer r.messagesMu.Unlock() + if len(r.messages) == 0 { + message.Content = candidate.Content + candidate.Selected = true + } else { + candidate.Selected = false + } + message.PromptIndex = len(r.messages) + 1 + r.messages = append(r.messages, message) + r.idempotentMessage = message + return nil +} + +type staleInitialTaskBriefRepo struct { + *messageAddSwitchRepo + staleDescription string + admissionCalls int +} + +func (r *staleInitialTaskBriefRepo) CreateMessageWithInitialTaskBrief( + ctx context.Context, + message *models.Message, + candidate *admission.InitialTaskBriefCandidate, +) error { + if r.admissionCalls == 0 { + r.admissionCalls++ + r.tasks[message.TaskID].Description = r.staleDescription + return repoerrors.ErrInitialTaskBriefStale + } + return r.messageAddSwitchRepo.CreateMessageWithInitialTaskBrief(ctx, message, candidate) +} + +func (r *messageAddSwitchRepo) CreateMessageWithPlanCommentsWithInitialTaskBrief( + ctx context.Context, + message *models.Message, + candidate *admission.InitialTaskBriefCandidate, + refs []models.TaskPlanCommentRef, + requirePrimary bool, + expectedState models.TaskSessionState, + claim *messagequeue.QueueAttachmentClaim, +) (*models.TaskPlanCommentSnapshot, error) { + r.messagesMu.Lock() + if len(r.messages) == 0 { + message.Content = candidate.Content + candidate.Selected = true + } else { + candidate.Selected = false + } + r.messagesMu.Unlock() + return r.CreateMessageWithPlanComments(ctx, message, refs, requirePrimary, expectedState, claim) +} + +func (r *messageAddSwitchRepo) CreateMessageWithPlanCommentsAndQueueWithInitialTaskBrief( + ctx context.Context, + message *models.Message, + queued *messagequeue.QueuedMessage, + candidate *admission.InitialTaskBriefCandidate, + refs []models.TaskPlanCommentRef, + requirePrimary bool, + expectedState models.TaskSessionState, + claim *messagequeue.QueueAttachmentClaim, + maxPerSession int, +) (*models.TaskPlanCommentSnapshot, error) { + snapshot, err := r.CreateMessageWithPlanCommentsWithInitialTaskBrief( + ctx, message, candidate, refs, requirePrimary, expectedState, claim, + ) + if err != nil { + return nil, err + } + queued.Content = message.Content + copy := *queued + r.queuedMessage = © + _ = maxPerSession + return snapshot, nil +} + +type initialTaskBriefPromptOrchestrator struct { + firstTurnCaptureOrchestrator + expansions map[string]string + prepareInputs []string +} + +func (o *initialTaskBriefPromptOrchestrator) PrepareDirectPrompt( + _ context.Context, + prompt string, + _ bool, +) (string, string) { + o.prepareInputs = append(o.prepareInputs, prompt) + trustedContext := initialTaskBriefTrustedContext(prompt, o.expansions) + if trustedContext == "" { + return prompt, "" + } + return prompt + "\n\n" + sysprompt.Wrap(trustedContext), trustedContext +} + +func initialTaskBriefTrustedContext(prompt string, expansions map[string]string) string { + type reference struct { + name string + index int + } + refs := make([]reference, 0, len(expansions)) + for name := range expansions { + if index := strings.Index(prompt, "@"+name); index >= 0 { + refs = append(refs, reference{name: name, index: index}) + } + } + sort.Slice(refs, func(i, j int) bool { return refs[i].index < refs[j].index }) + if len(refs) == 0 { + return "" + } + lines := []string{"EXPANDED PROMPT REFERENCES:"} + for _, ref := range refs { + lines = append(lines, fmt.Sprintf("### @%s", ref.name), expansions[ref.name]) + } + return strings.Join(lines, "\n") +} + +func (o *firstTurnCaptureOrchestrator) StartCreatedSessionWithPromptContextAndCanvasGuidancePreservingDirectPrompt( + _ context.Context, + _, _, _, content string, + _, _, _ bool, + _ []v1.MessageAttachment, + references []v1.EntityReference, + promptReferenceContext string, + _ bool, + _, _ bool, +) (*executor.TaskExecution, error) { + o.started <- capturedFirstTurn{ + content: content, + references: append([]v1.EntityReference(nil), references...), + promptReferenceContext: promptReferenceContext, + } + return &executor.TaskExecution{}, nil +} + +// @covers AC-TASKS-INITIAL-TASK-BRIEF-001.1, AC-TASKS-INITIAL-TASK-BRIEF-001.2 +func TestWSAddMessage_InitialTaskBrief(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + const ( + taskID = "task-initial-brief" + sessionID = "session-initial-brief" + brief = "Ship the authenticated task view." + instruction = "Use the existing session and keep the change small." + ) + now := time.Now().UTC() + repo := &messageAddSwitchRepo{ + tasks: map[string]*models.Task{ + taskID: { + ID: taskID, + Description: brief, + State: v1.TaskStateInProgress, + UpdatedAt: now, + }, + }, + sessions: map[string]*models.TaskSession{ + sessionID: { + ID: sessionID, + TaskID: taskID, + State: models.TaskSessionStateCreated, + AgentProfileID: "profile-initial-brief", + UpdatedAt: now, + }, + }, + primaryID: sessionID, + } + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + require.NoError(t, err) + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + orch := &firstTurnCaptureOrchestrator{started: make(chan capturedFirstTurn, 1)} + h := NewMessageHandlers(svc, orch, log) + + req, err := ws.NewRequest("initial-brief-request", ws.ActionMessageAdd, map[string]interface{}{ + "task_id": taskID, "session_id": sessionID, + "message_id": "initial-brief-message", "content": instruction, + }) + require.NoError(t, err) + + resp, err := h.wsAddMessage(context.Background(), req) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, resp.Type) + require.Len(t, repo.messages, 1) + + stored := repo.firstMessageContent() + require.Contains(t, stored, brief) + require.Contains(t, stored, instruction) + require.Equal(t, 1, strings.Count(stored, brief)) + require.Equal(t, 1, strings.Count(stored, instruction)) + + synctest.Wait() + dispatched := <-orch.started + require.Contains(t, dispatched.content, brief) + require.Contains(t, dispatched.content, instruction) + require.Equal(t, stored, dispatched.content) + }) +} + +// @covers AC-TASKS-INITIAL-TASK-BRIEF-001.4, AC-TASKS-INITIAL-TASK-BRIEF-001.8 +func TestWSAddMessage_InitialTaskBriefExpandsCombinedPromptAtAdmission(t *testing.T) { + tests := []struct { + name string + brief string + instruction string + expansions map[string]string + wantVisible []string + wantTrusted []string + wantContext string + }{ + { + name: "distinct references in brief and instruction", + brief: "Implement @brief_rules", + instruction: "First follow @prep_rules", + expansions: map[string]string{ + "brief_rules": "brief rules v1", + "prep_rules": "prep rules v1", + }, + wantVisible: []string{"Implement @brief_rules", "First follow @prep_rules"}, + wantTrusted: []string{"brief rules v1", "prep rules v1"}, + wantContext: "EXPANDED PROMPT REFERENCES:\n### @brief_rules\nbrief rules v1\n### @prep_rules\nprep rules v1", + }, + { + name: "reference only in brief", + brief: "Implement @brief_rules", + instruction: "Keep the existing session", + expansions: map[string]string{"brief_rules": "brief rules v1"}, + wantVisible: []string{"Implement @brief_rules", "Keep the existing session"}, + wantTrusted: []string{"brief rules v1"}, + wantContext: "EXPANDED PROMPT REFERENCES:\n### @brief_rules\nbrief rules v1", + }, + { + name: "identical brief and instruction", + brief: "Follow @rules", + instruction: "Follow @rules", + expansions: map[string]string{"rules": "shared rules v1"}, + wantVisible: []string{"Follow @rules"}, + wantTrusted: []string{"shared rules v1"}, + wantContext: "EXPANDED PROMPT REFERENCES:\n### @rules\nshared rules v1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + const ( + taskID = "task-initial-brief-prepared" + sessionID = "session-initial-brief-prepared" + ) + now := time.Now().UTC() + repo := &messageAddSwitchRepo{ + tasks: map[string]*models.Task{ + taskID: { + ID: taskID, Description: tt.brief, State: v1.TaskStateInProgress, + UpdatedAt: now, + }, + }, + sessions: map[string]*models.TaskSession{ + sessionID: { + ID: sessionID, TaskID: taskID, State: models.TaskSessionStateCreated, + AgentProfileID: "profile-initial-brief-prepared", UpdatedAt: now, + }, + }, + primaryID: sessionID, + } + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + require.NoError(t, err) + orch := &initialTaskBriefPromptOrchestrator{ + firstTurnCaptureOrchestrator: firstTurnCaptureOrchestrator{ + started: make(chan capturedFirstTurn, 1), + }, + expansions: tt.expansions, + } + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + h := NewMessageHandlers(svc, orch, log) + + req, err := ws.NewRequest("initial-brief-prepared-request", ws.ActionMessageAdd, map[string]interface{}{ + "task_id": taskID, "session_id": sessionID, + "message_id": "initial-brief-prepared-message", "content": tt.instruction, + }) + require.NoError(t, err) + + resp, err := h.wsAddMessage(context.Background(), req) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, resp.Type) + require.Len(t, repo.messages, 1) + require.Len(t, orch.prepareInputs, 2) + require.Equal(t, tt.instruction, orch.prepareInputs[0]) + require.Equal(t, composeInitialTaskBrief(tt.brief, tt.instruction), orch.prepareInputs[1]) + + stored := repo.firstMessageContent() + for _, visible := range tt.wantVisible { + require.Equal(t, 1, strings.Count(stored, visible), "visible prompt %q in %q", visible, stored) + } + for _, trusted := range tt.wantTrusted { + require.Equal(t, 1, strings.Count(stored, trusted), "trusted expansion %q in %q", trusted, stored) + } + require.Contains(t, stored, sysprompt.Wrap(tt.wantContext)) + + synctest.Wait() + dispatched := <-orch.started + require.Equal(t, stored, dispatched.content) + require.Equal(t, tt.wantContext, dispatched.promptReferenceContext) + }) + }) + } +} + +// @covers AC-TASKS-INITIAL-TASK-BRIEF-001.8 +func TestWSAddMessage_InitialTaskBriefKeepsAcceptedExpansionWhenDefinitionsChange(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + const ( + taskID = "task-initial-brief-snapshot" + sessionID = "session-initial-brief-snapshot" + brief = "Implement @brief_rules" + instruction = "First follow @prep_rules" + ) + now := time.Now().UTC() + repo := &messageAddSwitchRepo{ + tasks: map[string]*models.Task{taskID: { + ID: taskID, Description: brief, State: v1.TaskStateInProgress, UpdatedAt: now, + }}, + sessions: map[string]*models.TaskSession{sessionID: { + ID: sessionID, TaskID: taskID, State: models.TaskSessionStateCreated, + AgentProfileID: "profile-initial-brief-snapshot", UpdatedAt: now, + }}, + primaryID: sessionID, + } + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + require.NoError(t, err) + orch := &initialTaskBriefPromptOrchestrator{ + firstTurnCaptureOrchestrator: firstTurnCaptureOrchestrator{started: make(chan capturedFirstTurn, 1)}, + expansions: map[string]string{ + "brief_rules": "brief rules accepted", + "prep_rules": "prep rules accepted", + }, + } + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + h := NewMessageHandlers(svc, orch, log) + req, err := ws.NewRequest("initial-brief-snapshot-request", ws.ActionMessageAdd, map[string]interface{}{ + "task_id": taskID, "session_id": sessionID, + "message_id": "initial-brief-snapshot-message", "content": instruction, + }) + require.NoError(t, err) + + resp, err := h.wsAddMessage(context.Background(), req) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, resp.Type) + orch.expansions["brief_rules"] = "brief rules changed before dispatch" + orch.expansions["prep_rules"] = "prep rules changed before dispatch" + + stored := repo.firstMessageContent() + expectedContext := "EXPANDED PROMPT REFERENCES:\n### @brief_rules\nbrief rules accepted\n### @prep_rules\nprep rules accepted" + require.Contains(t, stored, sysprompt.Wrap(expectedContext)) + require.NotContains(t, stored, "changed before dispatch") + synctest.Wait() + dispatched := <-orch.started + require.Equal(t, expectedContext, dispatched.promptReferenceContext) + }) +} + +func TestWSAddMessage_RejectsMismatchedTaskSessionBeforeReadingTask(t *testing.T) { + now := time.Now().UTC() + repo := &messageAddSwitchRepo{ + tasks: map[string]*models.Task{ + "task-a": {ID: "task-a", State: v1.TaskStateInProgress, UpdatedAt: now}, + "task-b": {ID: "task-b", State: v1.TaskStateInProgress, UpdatedAt: now}, + }, + sessions: map[string]*models.TaskSession{ + "session-b": { + ID: "session-b", TaskID: "task-b", State: models.TaskSessionStateCreated, + AgentProfileID: "profile-b", UpdatedAt: now, + }, + }, + primaryID: "session-b", + } + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + require.NoError(t, err) + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + orch := &firstTurnCaptureOrchestrator{started: make(chan capturedFirstTurn, 1)} + h := NewMessageHandlers(svc, orch, log) + + request, err := ws.NewRequest("mismatched-pair", ws.ActionMessageAdd, map[string]any{ + "task_id": "task-a", "session_id": "session-b", "content": "send this", + }) + require.NoError(t, err) + response, err := h.wsAddMessage(context.Background(), request) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeError, response.Type) + require.Contains(t, string(response.Payload), "Task and session do not match") + require.Empty(t, repo.messages) + require.Zero(t, repo.taskGetCalls) + require.Zero(t, orch.onTurnStartCount()) +} + +func TestWSAddMessage_ConcurrentInitialBriefStartsOnlyAdmittedCandidate(t *testing.T) { + now := time.Now().UTC() + const taskID = "concurrent-initial-brief-task" + const sessionID = "concurrent-initial-brief-session" + repo := &messageAddSwitchRepo{ + tasks: map[string]*models.Task{ + taskID: { + ID: taskID, Description: "Concurrent task brief", State: v1.TaskStateInProgress, + UpdatedAt: now, + }, + }, + sessions: map[string]*models.TaskSession{ + sessionID: { + ID: sessionID, TaskID: taskID, State: models.TaskSessionStateCreated, + AgentProfileID: "profile-concurrent", UpdatedAt: now, + }, + }, + primaryID: sessionID, + } + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + require.NoError(t, err) + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + orch := &firstTurnCaptureOrchestrator{started: make(chan capturedFirstTurn, 2)} + h := NewMessageHandlers(svc, orch, log) + start := make(chan struct{}) + responses := make(chan *ws.Message, 2) + var wg sync.WaitGroup + for index, instruction := range []string{"first contender", "second contender"} { + request, requestErr := ws.NewRequest( + fmt.Sprintf("concurrent-initial-brief-%d", index), ws.ActionMessageAdd, + map[string]any{ + "task_id": taskID, "session_id": sessionID, + "message_id": fmt.Sprintf("concurrent-initial-brief-message-%d", index), + "content": instruction, + }, + ) + require.NoError(t, requestErr) + wg.Add(1) + go func(request *ws.Message) { + defer wg.Done() + <-start + response, requestErr := h.wsAddMessage(context.Background(), request) + require.NoError(t, requestErr) + responses <- response + }(request) + } + close(start) + wg.Wait() + close(responses) + for response := range responses { + require.Equal(t, ws.MessageTypeResponse, response.Type) + } + + require.Eventually(t, func() bool { return len(orch.queueCalls()) == 1 }, time.Second, time.Millisecond) + require.Len(t, orch.started, 1) + require.Len(t, repo.messages, 2) + queuedCalls := orch.queueCalls() + require.True(t, queuedCalls[0].userMessageRecorded) + require.True(t, queuedCalls[0].metadata[orchestrator.MetaKeyInitialTaskBriefDispatchPending].(bool)) + require.Contains(t, queuedCalls[0].prompt, "contender") + for _, message := range repo.messages { + if strings.Contains(message.Content, "Concurrent task brief") { + require.Equal(t, 1, message.PromptIndex) + } else { + require.Equal(t, 2, message.PromptIndex) + } + } +} + +func TestWSAddMessage_RefreshesStaleBriefWithoutRepeatingTurnStart(t *testing.T) { + now := time.Now().UTC() + baseRepo := &messageAddSwitchRepo{ + tasks: map[string]*models.Task{ + "stale-brief-task": { + ID: "stale-brief-task", Description: "Original task brief", State: v1.TaskStateInProgress, + UpdatedAt: now, + }, + }, + sessions: map[string]*models.TaskSession{ + "stale-brief-session": { + ID: "stale-brief-session", TaskID: "stale-brief-task", State: models.TaskSessionStateCreated, + AgentProfileID: "profile-stale", UpdatedAt: now, + }, + }, + primaryID: "stale-brief-session", + } + repo := &staleInitialTaskBriefRepo{messageAddSwitchRepo: baseRepo, staleDescription: "Fresh task brief"} + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + require.NoError(t, err) + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + orch := &initialTaskBriefPromptOrchestrator{ + firstTurnCaptureOrchestrator: firstTurnCaptureOrchestrator{started: make(chan capturedFirstTurn, 1)}, + expansions: map[string]string{}, + } + h := NewMessageHandlers(svc, orch, log) + request, err := ws.NewRequest("stale-brief-request", ws.ActionMessageAdd, map[string]any{ + "task_id": "stale-brief-task", "session_id": "stale-brief-session", "content": "follow the brief", + }) + require.NoError(t, err) + response, err := h.wsAddMessage(context.Background(), request) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, response.Type) + require.Len(t, baseRepo.messages, 1) + stored := baseRepo.firstMessageContent() + require.Contains(t, stored, "Fresh task brief") + require.NotContains(t, stored, "Original task brief") + require.Contains(t, stored, "follow the brief") + require.Equal(t, 1, orch.onTurnStartCount()) + require.Eventually(t, func() bool { return len(orch.started) == 1 }, time.Second, time.Millisecond) + dispatched := <-orch.started + require.Equal(t, stored, dispatched.content) +} + +// @covers AC-TASKS-INITIAL-TASK-BRIEF-001.8 +func TestWSAddMessage_QueuedInitialTaskBriefPersistsAcceptedExpansion(t *testing.T) { + now := time.Now().UTC() + repo := &messageAddSwitchRepo{ + tasks: map[string]*models.Task{"task-initial-brief-queued": { + ID: "task-initial-brief-queued", Description: "Implement @brief_rules", + State: v1.TaskStateInProgress, UpdatedAt: now, + }}, + sessions: map[string]*models.TaskSession{"session-initial-brief-queued": { + ID: "session-initial-brief-queued", TaskID: "task-initial-brief-queued", + State: models.TaskSessionStateCreated, AgentProfileID: "profile-initial-brief-queued", UpdatedAt: now, + }}, + primaryID: "session-initial-brief-queued", + } + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + require.NoError(t, err) + orch := &initialTaskBriefPromptOrchestrator{ + firstTurnCaptureOrchestrator: firstTurnCaptureOrchestrator{started: make(chan capturedFirstTurn, 1)}, + expansions: map[string]string{"brief_rules": "queued brief rules accepted"}, + } + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + h := NewMessageHandlers(svc, orch, log) + req, err := ws.NewRequest("initial-brief-queued-request", ws.ActionMessageAdd, map[string]interface{}{ + "task_id": "task-initial-brief-queued", "session_id": "session-initial-brief-queued", + "message_id": "initial-brief-queued-message", "content": "Keep the existing session", + "plan_comment_refs": []map[string]interface{}{{"id": "comment-handler", "version": 3}}, + "require_primary_session": true, + }) + require.NoError(t, err) + + resp, err := h.wsAddMessage(context.Background(), req) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, resp.Type) + require.Len(t, repo.messages, 1) + require.NotNil(t, repo.queuedMessage) + require.Equal(t, repo.messages[0].Content, repo.queuedMessage.Content) + expectedContext := "EXPANDED PROMPT REFERENCES:\n### @brief_rules\nqueued brief rules accepted" + require.Contains(t, repo.queuedMessage.Content, sysprompt.Wrap(expectedContext)) + require.NotContains(t, repo.queuedMessage.Content, "\x00") + require.Empty(t, orch.started) +} diff --git a/apps/backend/internal/task/handlers/message_handlers_saved_prompt_test.go b/apps/backend/internal/task/handlers/message_handlers_saved_prompt_test.go index d34d48f69f..513d136da5 100644 --- a/apps/backend/internal/task/handlers/message_handlers_saved_prompt_test.go +++ b/apps/backend/internal/task/handlers/message_handlers_saved_prompt_test.go @@ -101,6 +101,7 @@ func (o *savedPromptDeliveryOrchestrator) StartCreatedSessionWithPromptContext( _ []v1.MessageAttachment, _ []v1.EntityReference, promptReferenceContext string, + _ bool, ) (*executor.TaskExecution, error) { if o.independentlyResolvesCanvas { includeCanvasGuidance, resolveErr := o.TaskSessionCanvasGuidanceEnabled(ctx, taskID, sessionID) @@ -122,6 +123,7 @@ func (o *savedPromptDeliveryOrchestrator) StartCreatedSessionWithPromptContextAn _ []v1.MessageAttachment, _ []v1.EntityReference, promptReferenceContext string, + _ bool, canvasGuidanceResolved, includeCanvasGuidance bool, ) (*executor.TaskExecution, error) { o.started <- savedPromptStarted{ diff --git a/apps/backend/internal/task/repository/admission/initial_task_brief.go b/apps/backend/internal/task/repository/admission/initial_task_brief.go new file mode 100644 index 0000000000..bb5f1f8b37 --- /dev/null +++ b/apps/backend/internal/task/repository/admission/initial_task_brief.go @@ -0,0 +1,18 @@ +// Package admission contains internal data passed across task admission +// boundaries. It has no repository implementation dependencies so both the +// service and concrete repositories can share the same write contract. +package admission + +// InitialTaskBriefCandidate is the server-owned candidate for a prepared +// session's first direct prompt. The repository validates the description +// snapshot and sets Selected only when this candidate owns the first prompt +// slot. +type InitialTaskBriefCandidate struct { + DescriptionSnapshot string + Content string + PromptReferenceContext string + // PromptReferencesPrepared distinguishes an accepted empty expansion from + // content that never passed the server-owned prompt preparer. + PromptReferencesPrepared bool + Selected bool +} diff --git a/apps/backend/internal/task/repository/repoerrors/errors.go b/apps/backend/internal/task/repository/repoerrors/errors.go index f4e9b0c937..4be41666f0 100644 --- a/apps/backend/internal/task/repository/repoerrors/errors.go +++ b/apps/backend/internal/task/repository/repoerrors/errors.go @@ -12,6 +12,10 @@ var ErrWorkspaceNotFound = errors.New("workspace not found") // ErrTaskNotFound reports that no task row matched the supplied id. var ErrTaskNotFound = errors.New("task not found") +// ErrInitialTaskBriefStale reports that a prepared task's description changed +// before its first direct message could be admitted. +var ErrInitialTaskBriefStale = errors.New("initial task brief is stale") + // ErrMessageNotFound reports that no message row matched the supplied id. var ErrMessageNotFound = errors.New("message not found") diff --git a/apps/backend/internal/task/repository/sqlite/message.go b/apps/backend/internal/task/repository/sqlite/message.go index 50ef03ec37..65fee58a9f 100644 --- a/apps/backend/internal/task/repository/sqlite/message.go +++ b/apps/backend/internal/task/repository/sqlite/message.go @@ -14,6 +14,7 @@ import ( "github.com/kandev/kandev/internal/agentctl/tracing" "github.com/kandev/kandev/internal/db/dialect" "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/admission" ) // Message operations @@ -63,6 +64,49 @@ func (r *Repository) CreateMessage(ctx context.Context, message *models.Message) return r.insertMessageWithSessionLock(ctx, message, requestsInput, messageType, metadataJSON) } +// CreateMessageWithInitialTaskBrief persists a user message while atomically +// deciding whether the supplied prepared-session candidate owns the session's +// first prompt slot. +func (r *Repository) CreateMessageWithInitialTaskBrief( + ctx context.Context, + message *models.Message, + candidate *admission.InitialTaskBriefCandidate, +) error { + if candidate == nil { + return r.CreateMessage(ctx, message) + } + if message.ID == "" { + message.ID = uuid.New().String() + } + if message.AuthorType == "" { + message.AuthorType = models.MessageAuthorUser + } + if message.AuthorType != models.MessageAuthorUser { + return fmt.Errorf("initial task brief requires a user message") + } + + requestsInput := 0 + if message.RequestsInput { + requestsInput = 1 + } + messageType := string(message.Type) + if messageType == "" { + messageType = string(models.MessageTypeMessage) + } + metadataJSON := "{}" + if message.Metadata != nil { + metadataBytes, err := json.Marshal(message.Metadata) + if err != nil { + return fmt.Errorf("failed to serialize message metadata: %w", err) + } + metadataJSON = string(metadataBytes) + } + + return r.createUserMessageWithBoundaryAndInitialTaskBrief( + ctx, message, requestsInput, messageType, metadataJSON, candidate, + ) +} + func (r *Repository) insertMessageRow( ctx context.Context, execer taskSessionExecutor, diff --git a/apps/backend/internal/task/repository/sqlite/message_initial_task_brief_postgres_test.go b/apps/backend/internal/task/repository/sqlite/message_initial_task_brief_postgres_test.go new file mode 100644 index 0000000000..92d1eae6ad --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/message_initial_task_brief_postgres_test.go @@ -0,0 +1,77 @@ +package sqlite + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/admission" + "github.com/kandev/kandev/internal/testutil" +) + +// @covers AC-TASKS-INITIAL-TASK-BRIEF-001.3, AC-TASKS-INITIAL-TASK-BRIEF-001.5 +func TestInitialTaskBriefAdmissionPostgres(t *testing.T) { + db := openIsolatedPostgresMultiConn(t, testutil.PostgresDSNFromEnv(t), 4) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("new postgres repo: %v", err) + } + ctx := context.Background() + const ( + taskID = "task-pg-initial-brief" + sessionID = "session-pg-initial-brief" + turnID = "turn-pg-initial-brief" + brief = "Postgres initial task brief" + ) + seedPostgresSession(t, repo, taskID, sessionID, turnID, time.Now().UTC()) + setInitialTaskBriefDescription(t, repo, taskID, brief) + writer := requireInitialTaskBriefMessageWriter(t, repo) + + messages := []*models.Message{ + initialTaskBriefMessage(taskID, sessionID, turnID, "pg-initial-brief-a", "instruction a"), + initialTaskBriefMessage(taskID, sessionID, turnID, "pg-initial-brief-b", "instruction b"), + } + candidates := []*admission.InitialTaskBriefCandidate{ + initialTaskBriefCandidate(brief, "Postgres initial task brief\n\ninstruction a"), + initialTaskBriefCandidate(brief, "Postgres initial task brief\n\ninstruction b"), + } + errs := make([]error, len(messages)) + start := make(chan struct{}) + var wg sync.WaitGroup + for index := range messages { + wg.Add(1) + go func(index int) { + defer wg.Done() + <-start + errs[index] = writer.CreateMessageWithInitialTaskBrief(ctx, messages[index], candidates[index]) + }(index) + } + close(start) + wg.Wait() + for index, err := range errs { + if err != nil { + t.Fatalf("postgres initial admission %d: %v", index, err) + } + } + + winners := 0 + ordinals := map[int]bool{} + for index, candidate := range candidates { + ordinals[messages[index].PromptIndex] = true + if candidate.Selected { + winners++ + if messages[index].Content != candidate.Content || messages[index].PromptIndex != 1 { + t.Fatalf("postgres winner message=%+v candidate=%+v", messages[index], candidate) + } + continue + } + if messages[index].Content == candidate.Content || messages[index].PromptIndex != 2 { + t.Fatalf("postgres loser message=%+v candidate=%+v", messages[index], candidate) + } + } + if winners != 1 || len(ordinals) != 2 || !ordinals[1] || !ordinals[2] { + t.Fatalf("postgres winners=%d ordinals=%v, want one winner and {1, 2}", winners, ordinals) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/message_initial_task_brief_test.go b/apps/backend/internal/task/repository/sqlite/message_initial_task_brief_test.go new file mode 100644 index 0000000000..9c0b54696d --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/message_initial_task_brief_test.go @@ -0,0 +1,502 @@ +package sqlite + +import ( + "context" + "errors" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/orchestrator/messagequeue" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/plancomments" + "github.com/kandev/kandev/internal/task/repository/admission" + "github.com/kandev/kandev/internal/task/repository/repoerrors" +) + +type initialTaskBriefMessageWriter interface { + CreateMessageWithInitialTaskBrief(context.Context, *models.Message, *admission.InitialTaskBriefCandidate) error +} + +type initialTaskBriefPlanCommentWriter interface { + CreateMessageWithPlanCommentsWithInitialTaskBrief( + context.Context, + *models.Message, + *admission.InitialTaskBriefCandidate, + []models.TaskPlanCommentRef, + bool, + models.TaskSessionState, + *messagequeue.QueueAttachmentClaim, + ) (*models.TaskPlanCommentSnapshot, error) +} + +type initialTaskBriefQueuedPlanCommentWriter interface { + CreateMessageWithPlanCommentsAndQueueWithInitialTaskBrief( + context.Context, + *models.Message, + *messagequeue.QueuedMessage, + *admission.InitialTaskBriefCandidate, + []models.TaskPlanCommentRef, + bool, + models.TaskSessionState, + *messagequeue.QueueAttachmentClaim, + int, + ) (*models.TaskPlanCommentSnapshot, error) +} + +func requireInitialTaskBriefMessageWriter(t *testing.T, repo *Repository) initialTaskBriefMessageWriter { + t.Helper() + writer, ok := any(repo).(initialTaskBriefMessageWriter) + if !ok { + t.Fatal("Repository does not implement initial task brief message admission") + } + return writer +} + +func setInitialTaskBriefDescription(t *testing.T, repo *Repository, taskID, description string) { + t.Helper() + if _, err := repo.db.ExecContext(context.Background(), repo.db.Rebind( + `UPDATE tasks SET description = ? WHERE id = ?`, + ), description, taskID); err != nil { + t.Fatalf("update task description: %v", err) + } +} + +func initialTaskBriefMessage(taskID, sessionID, turnID, id, content string) *models.Message { + return &models.Message{ + ID: id, TaskID: taskID, TaskSessionID: sessionID, TurnID: turnID, + AuthorType: models.MessageAuthorUser, Content: content, + } +} + +func initialTaskBriefCandidate(description, content string) *admission.InitialTaskBriefCandidate { + return &admission.InitialTaskBriefCandidate{ + DescriptionSnapshot: description, + Content: content, + } +} + +// @covers AC-TASKS-INITIAL-TASK-BRIEF-001.3, AC-TASKS-INITIAL-TASK-BRIEF-001.5, AC-TASKS-INITIAL-TASK-BRIEF-001.6 +func TestInitialTaskBriefAdmission(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + const ( + taskID = "task-initial-brief-admission" + sessionID = "session-initial-brief-admission" + turnID = "turn-initial-brief-admission" + brief = "Original task brief" + ) + seedForMsgTest(t, repo, taskID, sessionID, turnID) + setInitialTaskBriefDescription(t, repo, taskID, brief) + writer := requireInitialTaskBriefMessageWriter(t, repo) + + first := initialTaskBriefMessage(taskID, sessionID, turnID, "initial-brief-first", "user instruction") + firstCandidate := initialTaskBriefCandidate(brief, "Original task brief\n\nuser instruction") + if err := writer.CreateMessageWithInitialTaskBrief(ctx, first, firstCandidate); err != nil { + t.Fatalf("first admission: %v", err) + } + if !firstCandidate.Selected || first.Content != firstCandidate.Content || first.PromptIndex != 1 { + t.Fatalf("first admission message=%+v candidate=%+v", first, firstCandidate) + } + + second := initialTaskBriefMessage(taskID, sessionID, turnID, "initial-brief-second", "later instruction") + secondCandidate := initialTaskBriefCandidate(brief, "Original task brief\n\nlater instruction") + if err := writer.CreateMessageWithInitialTaskBrief(ctx, second, secondCandidate); err != nil { + t.Fatalf("second admission: %v", err) + } + if secondCandidate.Selected || second.Content != "later instruction" || second.PromptIndex != 2 { + t.Fatalf("second admission message=%+v candidate=%+v", second, secondCandidate) + } + + stored, err := repo.GetMessageWithPromptIndex(ctx, first.ID) + if err != nil { + t.Fatalf("read first message: %v", err) + } + if stored.Content != first.Content || stored.PromptIndex != 1 { + t.Fatalf("stored first message=%+v", stored) + } + + if err := repo.DeleteMessage(ctx, first.ID); err != nil { + t.Fatalf("delete first message: %v", err) + } + third := initialTaskBriefMessage(taskID, sessionID, turnID, "initial-brief-third", "after deletion") + thirdCandidate := initialTaskBriefCandidate(brief, "Original task brief\n\nafter deletion") + if err := writer.CreateMessageWithInitialTaskBrief(ctx, third, thirdCandidate); err != nil { + t.Fatalf("post-delete admission: %v", err) + } + if thirdCandidate.Selected || third.PromptIndex != 3 || third.Content != "after deletion" { + t.Fatalf("post-delete admission message=%+v candidate=%+v", third, thirdCandidate) + } + hasHistory, err := repo.HasUserPromptHistory(ctx, sessionID) + if err != nil || !hasHistory { + t.Fatalf("prompt history after deletion = %v, err=%v", hasHistory, err) + } +} + +// @covers AC-TASKS-INITIAL-TASK-BRIEF-001.5, AC-TASKS-INITIAL-TASK-BRIEF-001.6 +func TestInitialTaskBriefAdmissionRollbackAndStaleSnapshot(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + const ( + taskID = "task-initial-brief-rollback" + sessionID = "session-initial-brief-rollback" + turnID = "turn-initial-brief-rollback" + brief = "Stable task brief" + ) + seedForMsgTest(t, repo, taskID, sessionID, turnID) + setInitialTaskBriefDescription(t, repo, taskID, brief) + writer := requireInitialTaskBriefMessageWriter(t, repo) + + message := initialTaskBriefMessage(taskID, sessionID, "missing-turn", "initial-brief-retry", "instruction") + originalContent := message.Content + candidate := initialTaskBriefCandidate(brief, "Stable task brief\n\ninstruction") + message.PromptIndex = 99 + if err := writer.CreateMessageWithInitialTaskBrief(ctx, message, candidate); err == nil { + t.Fatal("invalid turn admission succeeded") + } + if message.Content != originalContent || message.PromptIndex != 99 || candidate.Selected { + t.Fatalf("rollback changed caller state: message=%+v candidate=%+v", message, candidate) + } + hasHistory, err := repo.HasUserPromptHistory(ctx, sessionID) + if err != nil || hasHistory { + t.Fatalf("history after rollback = %v, err=%v", hasHistory, err) + } + + message.TurnID = turnID + if err := writer.CreateMessageWithInitialTaskBrief(ctx, message, candidate); err != nil { + t.Fatalf("retry after rollback: %v", err) + } + if !candidate.Selected || message.Content != candidate.Content || message.PromptIndex != 1 { + t.Fatalf("retry admission message=%+v candidate=%+v", message, candidate) + } + + staleTaskID := "task-initial-brief-stale" + staleSessionID := "session-initial-brief-stale" + staleTurnID := "turn-initial-brief-stale" + seedForMsgTest(t, repo, staleTaskID, staleSessionID, staleTurnID) + setInitialTaskBriefDescription(t, repo, staleTaskID, brief) + staleMessage := initialTaskBriefMessage(staleTaskID, staleSessionID, staleTurnID, "initial-brief-stale", "instruction") + staleCandidate := initialTaskBriefCandidate(brief, "Stable task brief\n\ninstruction") + setInitialTaskBriefDescription(t, repo, staleTaskID, "Changed task brief") + if err := writer.CreateMessageWithInitialTaskBrief(ctx, staleMessage, staleCandidate); !errors.Is(err, repoerrors.ErrInitialTaskBriefStale) { + t.Fatalf("stale admission error = %v, want ErrInitialTaskBriefStale", err) + } + if staleCandidate.Selected || staleMessage.Content != "instruction" { + t.Fatalf("stale admission changed caller state: message=%+v candidate=%+v", staleMessage, staleCandidate) + } + if hasHistory, err := repo.HasUserPromptHistory(ctx, staleSessionID); err != nil || hasHistory { + t.Fatalf("stale history = %v, err=%v", hasHistory, err) + } + staleCandidate.DescriptionSnapshot = "Changed task brief" + if err := writer.CreateMessageWithInitialTaskBrief(ctx, staleMessage, staleCandidate); err != nil { + t.Fatalf("fresh stale retry: %v", err) + } + if !staleCandidate.Selected || staleMessage.PromptIndex != 1 { + t.Fatalf("fresh stale retry message=%+v candidate=%+v", staleMessage, staleCandidate) + } +} + +func TestInitialTaskBriefAdmissionRejectsOversizedRenderedPrompt(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + const ( + taskID = "task-initial-brief-size" + sessionID = "session-initial-brief-size" + turnID = "turn-initial-brief-size" + ) + seedForMsgTest(t, repo, taskID, sessionID, turnID) + setInitialTaskBriefDescription(t, repo, taskID, "Size constrained task brief") + message := initialTaskBriefMessage(taskID, sessionID, turnID, "initial-brief-size-message", "instruction") + originalContent := message.Content + candidate := initialTaskBriefCandidate("Size constrained task brief", strings.Repeat("x", plancomments.MaxRenderedPromptBytes+1)) + err := requireInitialTaskBriefMessageWriter(t, repo).CreateMessageWithInitialTaskBrief(ctx, message, candidate) + if !errors.Is(err, plancomments.ErrRenderedTooLarge) { + t.Fatalf("oversized admission error = %v, want ErrRenderedTooLarge", err) + } + if candidate.Selected || message.Content != originalContent || message.PromptIndex != 0 { + t.Fatalf("oversized admission changed caller state: message=%+v candidate=%+v", message, candidate) + } + if hasHistory, historyErr := repo.HasUserPromptHistory(ctx, sessionID); historyErr != nil || hasHistory { + t.Fatalf("history after oversized admission = %v, err=%v", hasHistory, historyErr) + } +} + +// @covers AC-TASKS-INITIAL-TASK-BRIEF-001.5 +func TestInitialTaskBriefAdmissionSerializesConcurrentFirstSends(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + const ( + taskID = "task-initial-brief-race" + sessionID = "session-initial-brief-race" + turnID = "turn-initial-brief-race" + brief = "Concurrent task brief" + ) + seedForMsgTest(t, repo, taskID, sessionID, turnID) + setInitialTaskBriefDescription(t, repo, taskID, brief) + writer := requireInitialTaskBriefMessageWriter(t, repo) + messages := []*models.Message{ + initialTaskBriefMessage(taskID, sessionID, turnID, "initial-brief-race-a", "instruction a"), + initialTaskBriefMessage(taskID, sessionID, turnID, "initial-brief-race-b", "instruction b"), + } + candidates := []*admission.InitialTaskBriefCandidate{ + initialTaskBriefCandidate(brief, "Concurrent task brief\n\ninstruction a"), + initialTaskBriefCandidate(brief, "Concurrent task brief\n\ninstruction b"), + } + errs := make([]error, len(messages)) + var wg sync.WaitGroup + start := make(chan struct{}) + for index := range messages { + wg.Add(1) + go func(index int) { + defer wg.Done() + <-start + errs[index] = writer.CreateMessageWithInitialTaskBrief(ctx, messages[index], candidates[index]) + }(index) + } + close(start) + wg.Wait() + for index, err := range errs { + if err != nil { + t.Fatalf("concurrent admission %d: %v", index, err) + } + } + winners := 0 + for index := range candidates { + if candidates[index].Selected { + winners++ + if messages[index].Content != candidates[index].Content || messages[index].PromptIndex != 1 { + t.Fatalf("winner %d message=%+v candidate=%+v", index, messages[index], candidates[index]) + } + } else if messages[index].PromptIndex != 2 { + t.Fatalf("loser %d message=%+v", index, messages[index]) + } + } + if winners != 1 { + t.Fatalf("concurrent winners = %d, want one", winners) + } +} + +// @covers AC-TASKS-INITIAL-TASK-BRIEF-001.3, AC-TASKS-INITIAL-TASK-BRIEF-001.5 +func TestInitialTaskBriefAdmissionCompetesWithWorkflowFallback(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + const ( + taskID = "task-initial-brief-fallback-race" + sessionID = "session-initial-brief-fallback-race" + turnID = "turn-initial-brief-fallback-race" + brief = "Fallback race task brief" + ) + seedForMsgTest(t, repo, taskID, sessionID, turnID) + setInitialTaskBriefDescription(t, repo, taskID, brief) + writer := requireInitialTaskBriefMessageWriter(t, repo) + claimer, ok := any(repo).(promptHistoryClaimer) + if !ok { + t.Fatal("repository does not expose ClaimInitialPromptFallback") + } + + message := initialTaskBriefMessage(taskID, sessionID, turnID, "initial-brief-fallback-race", "direct instruction") + candidate := initialTaskBriefCandidate(brief, "Fallback race task brief\n\ndirect instruction") + start := make(chan struct{}) + var wg sync.WaitGroup + var claimed bool + var claimErr error + var createErr error + wg.Add(2) + go func() { + defer wg.Done() + <-start + claimed, claimErr = claimer.ClaimInitialPromptFallback(ctx, sessionID) + }() + go func() { + defer wg.Done() + <-start + createErr = writer.CreateMessageWithInitialTaskBrief(ctx, message, candidate) + }() + close(start) + wg.Wait() + if claimErr != nil { + t.Fatalf("fallback claim: %v", claimErr) + } + if createErr != nil { + t.Fatalf("direct admission: %v", createErr) + } + if claimed == candidate.Selected { + t.Fatalf("fallback/direct winners are not exclusive: fallback=%t candidate=%+v", claimed, candidate) + } + if candidate.Selected && message.Content != candidate.Content { + t.Fatalf("direct winner content = %q, want %q", message.Content, candidate.Content) + } + if !candidate.Selected && message.Content != "direct instruction" { + t.Fatalf("fallback winner retained content = %q, want direct instruction", message.Content) + } + if message.PromptIndex != 1 { + t.Fatalf("fallback race prompt index = %d, want 1", message.PromptIndex) + } +} + +// @covers AC-TASKS-INITIAL-TASK-BRIEF-001.3, AC-TASKS-INITIAL-TASK-BRIEF-001.5 +func TestInitialTaskBriefAdmissionRespectsZeroValuedFallbackReservation(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + const ( + taskID = "task-initial-brief-zero-reservation" + sessionID = "session-initial-brief-zero-reservation" + turnID = "turn-initial-brief-zero-reservation" + brief = "Reserved fallback task brief" + ) + seedForMsgTest(t, repo, taskID, sessionID, turnID) + setInitialTaskBriefDescription(t, repo, taskID, brief) + claimer := any(repo).(promptHistoryClaimer) + claimed, err := claimer.ClaimInitialPromptFallback(ctx, sessionID) + if err != nil || !claimed { + t.Fatalf("fallback reservation = %t, %v; want claimed", claimed, err) + } + + message := initialTaskBriefMessage(taskID, sessionID, turnID, "initial-brief-zero-reservation-message", "direct instruction") + candidate := initialTaskBriefCandidate(brief, "Reserved fallback task brief\n\ndirect instruction") + if err := requireInitialTaskBriefMessageWriter(t, repo).CreateMessageWithInitialTaskBrief(ctx, message, candidate); err != nil { + t.Fatalf("admission after fallback reservation: %v", err) + } + if candidate.Selected || message.Content != "direct instruction" || message.PromptIndex != 1 { + t.Fatalf("zero reservation admission message=%+v candidate=%+v", message, candidate) + } +} + +// @covers AC-TASKS-INITIAL-TASK-BRIEF-001.5, AC-TASKS-INITIAL-TASK-BRIEF-001.8 +func TestInitialTaskBriefAdmissionSharesPlanCommentAndQueueBoundaries(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + queueDB := sqlx.NewDb(repo.DB(), "sqlite3") + if _, err := messagequeue.NewSQLiteRepository(queueDB, queueDB); err != nil { + t.Fatalf("initialize queue schema: %v", err) + } + seedMessagePlanComment(t, ctx, repo, "initial-brief-plan") + setInitialTaskBriefDescription(t, repo, "task-message-comments-initial-brief-plan", "Plan task brief") + candidate := initialTaskBriefCandidate( + "Plan task brief", + plancomments.WithPlaceholder("Plan task brief\n\ntyped content"), + ) + message := planCommentMessage("initial-brief-plan", "initial-brief-plan-message") + writer, ok := any(repo).(initialTaskBriefPlanCommentWriter) + if !ok { + t.Fatal("Repository does not implement plan-comment initial task brief admission") + } + if _, err := writer.CreateMessageWithPlanCommentsWithInitialTaskBrief( + ctx, message, candidate, []models.TaskPlanCommentRef{{ID: "comment-initial-brief-plan", Version: 1}}, + true, "", nil, + ); err != nil { + t.Fatalf("plan-comment initial admission: %v", err) + } + if !candidate.Selected || message.Content == "" { + t.Fatalf("plan-comment candidate=%+v message=%+v", candidate, message) + } + if !containsExactlyOnce(message.Content, "Plan task brief") || message.PromptIndex != 1 { + t.Fatalf("plan-comment message content=%q index=%d", message.Content, message.PromptIndex) + } + + queueSuffix := "initial-brief-queue" + seedMessagePlanComment(t, ctx, repo, queueSuffix) + setInitialTaskBriefDescription(t, repo, "task-message-comments-"+queueSuffix, "Queued task brief") + queuedMessage := planCommentMessage(queueSuffix, "initial-brief-queue-message") + queued := &messagequeue.QueuedMessage{ + ID: queuedMessage.ID, TaskID: queuedMessage.TaskID, SessionID: queuedMessage.TaskSessionID, + Content: queuedMessage.Content, QueuedBy: messagequeue.QueuedByUser, + } + queuedCandidate := initialTaskBriefCandidate( + "Queued task brief", + plancomments.WithPlaceholder("Queued task brief\n\ntyped content"), + ) + queueWriter, ok := any(repo).(initialTaskBriefQueuedPlanCommentWriter) + if !ok { + t.Fatal("Repository does not implement queued initial task brief admission") + } + if _, err := queueWriter.CreateMessageWithPlanCommentsAndQueueWithInitialTaskBrief( + ctx, queuedMessage, queued, queuedCandidate, + []models.TaskPlanCommentRef{{ID: "comment-" + queueSuffix, Version: 1}}, + true, "", nil, 10, + ); err != nil { + t.Fatalf("queued initial admission: %v", err) + } + if !queuedCandidate.Selected || queued.Content != queuedMessage.Content || + !containsExactlyOnce(queued.Content, "Queued task brief") { + t.Fatalf("queued content=%q message=%q candidate=%+v", queued.Content, queuedMessage.Content, queuedCandidate) + } +} + +func containsExactlyOnce(content, needle string) bool { + return len(content) > 0 && strings.Count(content, needle) == 1 +} + +func newInitialTaskBriefRepoAtPath(t *testing.T) (*Repository, *sqlx.DB, string) { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "initial-task-brief-restart.db") + dbConn, err := db.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + repo, err := NewWithDB(sqlxDB, sqlxDB, nil) + if err != nil { + _ = sqlxDB.Close() + t.Fatalf("new repo: %v", err) + } + return repo, sqlxDB, dbPath +} + +// @covers AC-TASKS-INITIAL-TASK-BRIEF-001.6 +func TestInitialTaskBriefAdmissionSurvivesRepositoryRestart(t *testing.T) { + repo, sqlxDB, dbPath := newInitialTaskBriefRepoAtPath(t) + ctx := context.Background() + const ( + taskID = "task-initial-brief-restart" + sessionID = "session-initial-brief-restart" + turnID = "turn-initial-brief-restart" + workspaceID = "workspace-initial-brief-restart" + ) + seedWorkspace(t, repo, workspaceID) + if err := repo.CreateTask(ctx, &models.Task{ID: taskID, WorkspaceID: workspaceID, Title: "Restart task"}); err != nil { + t.Fatalf("seed restart task: %v", err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ID: sessionID, TaskID: taskID}); err != nil { + t.Fatalf("seed restart session: %v", err) + } + if err := repo.CreateTurn(ctx, &models.Turn{ID: turnID, TaskSessionID: sessionID, TaskID: taskID}); err != nil { + t.Fatalf("seed restart turn: %v", err) + } + setInitialTaskBriefDescription(t, repo, taskID, "Restart task brief") + candidate := initialTaskBriefCandidate("Restart task brief", "Restart task brief\n\ninstruction") + message := initialTaskBriefMessage(taskID, sessionID, turnID, "initial-brief-restart-message", "instruction") + if err := requireInitialTaskBriefMessageWriter(t, repo).CreateMessageWithInitialTaskBrief(ctx, message, candidate); err != nil { + t.Fatalf("initial restart fixture: %v", err) + } + if err := sqlxDB.Close(); err != nil { + t.Fatalf("close first repository: %v", err) + } + + dbConn, err := db.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("reopen sqlite: %v", err) + } + reopenedDB := sqlx.NewDb(dbConn, "sqlite3") + reopenedRepo, err := NewWithDB(reopenedDB, reopenedDB, nil) + if err != nil { + _ = reopenedDB.Close() + t.Fatalf("reopen repository: %v", err) + } + t.Cleanup(func() { _ = reopenedDB.Close() }) + hasHistory, err := reopenedRepo.HasUserPromptHistory(ctx, sessionID) + if err != nil || !hasHistory { + t.Fatalf("history after restart = %v, err=%v", hasHistory, err) + } + second := initialTaskBriefMessage(taskID, sessionID, turnID, "initial-brief-restart-second", "later") + secondCandidate := initialTaskBriefCandidate("Restart task brief", "Restart task brief\n\nlater") + if err := requireInitialTaskBriefMessageWriter(t, reopenedRepo).CreateMessageWithInitialTaskBrief(ctx, second, secondCandidate); err != nil { + t.Fatalf("post-restart admission: %v", err) + } + if secondCandidate.Selected || second.Content != "later" || second.PromptIndex != 2 { + t.Fatalf("post-restart message=%+v candidate=%+v", second, secondCandidate) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/message_plan_comment.go b/apps/backend/internal/task/repository/sqlite/message_plan_comment.go index ab68c10e3d..467160f47d 100644 --- a/apps/backend/internal/task/repository/sqlite/message_plan_comment.go +++ b/apps/backend/internal/task/repository/sqlite/message_plan_comment.go @@ -10,6 +10,7 @@ import ( "github.com/kandev/kandev/internal/db/dialect" "github.com/kandev/kandev/internal/orchestrator/messagequeue" "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/admission" "github.com/kandev/kandev/internal/task/repository/plancommenttx" ) @@ -47,6 +48,36 @@ func (r *Repository) CreateMessageWithPlanComments( requirePrimary bool, expectedState models.TaskSessionState, claim *messagequeue.QueueAttachmentClaim, +) (*models.TaskPlanCommentSnapshot, error) { + return r.createMessageWithPlanComments( + ctx, message, nil, refs, requirePrimary, expectedState, claim, + ) +} + +// CreateMessageWithPlanCommentsWithInitialTaskBrief adds the atomic prepared +// session first-prompt admission to a comment-bearing user message. +func (r *Repository) CreateMessageWithPlanCommentsWithInitialTaskBrief( + ctx context.Context, + message *models.Message, + candidate *admission.InitialTaskBriefCandidate, + refs []models.TaskPlanCommentRef, + requirePrimary bool, + expectedState models.TaskSessionState, + claim *messagequeue.QueueAttachmentClaim, +) (*models.TaskPlanCommentSnapshot, error) { + return r.createMessageWithPlanComments( + ctx, message, candidate, refs, requirePrimary, expectedState, claim, + ) +} + +func (r *Repository) createMessageWithPlanComments( + ctx context.Context, + message *models.Message, + candidate *admission.InitialTaskBriefCandidate, + refs []models.TaskPlanCommentRef, + requirePrimary bool, + expectedState models.TaskSessionState, + claim *messagequeue.QueueAttachmentClaim, ) (*models.TaskPlanCommentSnapshot, error) { if message.ID == "" { message.ID = uuid.New().String() @@ -74,7 +105,7 @@ func (r *Repository) CreateMessageWithPlanComments( requestsInput = 1 } return r.createMessagePlanCommentBoundary( - ctx, message, refs, requirePrimary, expectedState, claim, requestsInput, messageType, string(metadataJSON), + ctx, message, candidate, refs, requirePrimary, expectedState, claim, requestsInput, messageType, string(metadataJSON), ) } @@ -89,13 +120,47 @@ func (r *Repository) CreateMessageWithPlanCommentsAndQueue( expectedState models.TaskSessionState, claim *messagequeue.QueueAttachmentClaim, maxPerSession int, +) (*models.TaskPlanCommentSnapshot, error) { + return r.createMessageWithPlanCommentsAndQueue( + ctx, message, queued, nil, refs, requirePrimary, expectedState, claim, maxPerSession, + ) +} + +// CreateMessageWithPlanCommentsAndQueueWithInitialTaskBrief adds the atomic +// prepared session first-prompt admission to a queued comment-bearing message. +func (r *Repository) CreateMessageWithPlanCommentsAndQueueWithInitialTaskBrief( + ctx context.Context, + message *models.Message, + queued *messagequeue.QueuedMessage, + candidate *admission.InitialTaskBriefCandidate, + refs []models.TaskPlanCommentRef, + requirePrimary bool, + expectedState models.TaskSessionState, + claim *messagequeue.QueueAttachmentClaim, + maxPerSession int, +) (*models.TaskPlanCommentSnapshot, error) { + return r.createMessageWithPlanCommentsAndQueue( + ctx, message, queued, candidate, refs, requirePrimary, expectedState, claim, maxPerSession, + ) +} + +func (r *Repository) createMessageWithPlanCommentsAndQueue( + ctx context.Context, + message *models.Message, + queued *messagequeue.QueuedMessage, + candidate *admission.InitialTaskBriefCandidate, + refs []models.TaskPlanCommentRef, + requirePrimary bool, + expectedState models.TaskSessionState, + claim *messagequeue.QueueAttachmentClaim, + maxPerSession int, ) (*models.TaskPlanCommentSnapshot, error) { fields, err := prepareQueuedPlanCommentMessage(message, queued) if err != nil { return nil, err } return r.createQueuedMessagePlanCommentBoundary( - ctx, message, queued, refs, requirePrimary, expectedState, claim, maxPerSession, fields, + ctx, message, queued, candidate, refs, requirePrimary, expectedState, claim, maxPerSession, fields, ) } @@ -151,25 +216,38 @@ func (r *Repository) createQueuedMessagePlanCommentBoundary( ctx context.Context, message *models.Message, queued *messagequeue.QueuedMessage, + candidate *admission.InitialTaskBriefCandidate, refs []models.TaskPlanCommentRef, requirePrimary bool, expectedState models.TaskSessionState, claim *messagequeue.QueueAttachmentClaim, maxPerSession int, fields queuedPlanCommentMessageFields, -) (*models.TaskPlanCommentSnapshot, error) { +) (snapshot *models.TaskPlanCommentSnapshot, err error) { originalMessage := *message originalQueued := *queued + originalCandidateSelected := false + if candidate != nil { + originalCandidateSelected = candidate.Selected + } restore := func() { *message = originalMessage *queued = originalQueued + if candidate != nil { + candidate.Selected = originalCandidateSelected + } } tx, release, err := r.beginPlanCommentTx(ctx, message.TaskID) if err != nil { return nil, fmt.Errorf("begin queued plan-comment message creation: %w", err) } defer release() - defer func() { _ = tx.Rollback() }() + defer func() { + _ = tx.Rollback() + if err != nil { + restore() + } + }() // Task lifecycle is the outer authority. Lock it before session/turn rows so // archive and admission share one task -> session lock order. if err := r.guardActivePlanCommentTaskTx(ctx, tx, message.TaskID); err != nil { @@ -178,8 +256,20 @@ func (r *Repository) createQueuedMessagePlanCommentBoundary( if err := lockSessionTurnWrites(ctx, tx, r.db.DriverName(), message.TaskSessionID); err != nil { return nil, err } + contentTemplate := message.Content + if candidate != nil { + if err := r.validateInitialTaskBriefCandidate(ctx, tx, message.TaskID, candidate); err != nil { + return nil, err + } + if err := r.selectInitialTaskBriefCandidate(ctx, tx, message.TaskSessionID, message, candidate); err != nil { + return nil, err + } + if candidate.Selected { + contentTemplate = message.Content + } + } resolved, err := plancommenttx.ResolveDirect( - ctx, tx, r.db, message.TaskID, message.TaskSessionID, message.Content, refs, + ctx, tx, r.db, message.TaskID, message.TaskSessionID, contentTemplate, refs, requirePrimary, expectedState, ) if err != nil { @@ -207,7 +297,7 @@ func (r *Repository) createQueuedMessagePlanCommentBoundary( restore() return nil, err } - snapshot, err := plancommenttx.Consume(ctx, tx, r.db, resolved) + snapshot, err = plancommenttx.Consume(ctx, tx, r.db, resolved) if err != nil { restore() return nil, err @@ -222,28 +312,56 @@ func (r *Repository) createQueuedMessagePlanCommentBoundary( func (r *Repository) createMessagePlanCommentBoundary( ctx context.Context, message *models.Message, + candidate *admission.InitialTaskBriefCandidate, refs []models.TaskPlanCommentRef, requirePrimary bool, expectedState models.TaskSessionState, claim *messagequeue.QueueAttachmentClaim, requestsInput int, messageType, metadataJSON string, -) (*models.TaskPlanCommentSnapshot, error) { +) (snapshot *models.TaskPlanCommentSnapshot, err error) { original := *message + originalCandidateSelected := false + if candidate != nil { + originalCandidateSelected = candidate.Selected + } + restore := func() { + *message = original + if candidate != nil { + candidate.Selected = originalCandidateSelected + } + } tx, release, err := r.beginPlanCommentTx(ctx, message.TaskID) if err != nil { return nil, fmt.Errorf("begin plan-comment message creation: %w", err) } defer release() - defer func() { _ = tx.Rollback() }() + defer func() { + _ = tx.Rollback() + if err != nil { + restore() + } + }() if err := r.guardActivePlanCommentTaskTx(ctx, tx, message.TaskID); err != nil { return nil, err } if err := lockSessionTurnWrites(ctx, tx, r.db.DriverName(), message.TaskSessionID); err != nil { return nil, err } + contentTemplate := message.Content + if candidate != nil { + if err := r.validateInitialTaskBriefCandidate(ctx, tx, message.TaskID, candidate); err != nil { + return nil, err + } + if err := r.selectInitialTaskBriefCandidate(ctx, tx, message.TaskSessionID, message, candidate); err != nil { + return nil, err + } + if candidate.Selected { + contentTemplate = message.Content + } + } resolved, err := plancommenttx.ResolveDirect( - ctx, tx, r.db, message.TaskID, message.TaskSessionID, message.Content, refs, + ctx, tx, r.db, message.TaskID, message.TaskSessionID, contentTemplate, refs, requirePrimary, expectedState, ) if err != nil { @@ -257,20 +375,16 @@ func (r *Repository) createMessagePlanCommentBoundary( } normalizedTime := dialect.NormalizedMicrosecond(r.db.DriverName(), "created_at") if err := r.assignUserMessageBoundary(ctx, tx, message, r.db.DriverName(), normalizedTime); err != nil { - *message = original return nil, err } if err := r.insertMessageRow(ctx, tx, message, requestsInput, messageType, metadataJSON); err != nil { - *message = original return nil, err } - snapshot, err := plancommenttx.Consume(ctx, tx, r.db, resolved) + snapshot, err = plancommenttx.Consume(ctx, tx, r.db, resolved) if err != nil { - *message = original return nil, err } if err := tx.Commit(); err != nil { - *message = original return nil, fmt.Errorf("commit plan-comment message creation: %w", err) } return snapshot, nil diff --git a/apps/backend/internal/task/repository/sqlite/message_prompt_index.go b/apps/backend/internal/task/repository/sqlite/message_prompt_index.go index dcda50c20e..a9b8871dfc 100644 --- a/apps/backend/internal/task/repository/sqlite/message_prompt_index.go +++ b/apps/backend/internal/task/repository/sqlite/message_prompt_index.go @@ -10,6 +10,9 @@ import ( "github.com/kandev/kandev/internal/db/dialect" "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/plancomments" + "github.com/kandev/kandev/internal/task/repository/admission" + "github.com/kandev/kandev/internal/task/repository/repoerrors" ) // promptKeyLayout is the exact byte form of the normalized-microsecond @@ -209,10 +212,22 @@ func (r *Repository) createUserMessageWithBoundary( message *models.Message, requestsInput int, messageType, metadataJSON string, +) error { + return r.createUserMessageWithBoundaryAndInitialTaskBrief( + ctx, message, requestsInput, messageType, metadataJSON, nil, + ) +} + +func (r *Repository) createUserMessageWithBoundaryAndInitialTaskBrief( + ctx context.Context, + message *models.Message, + requestsInput int, + messageType, metadataJSON string, + candidate *admission.InitialTaskBriefCandidate, ) error { driver := r.db.DriverName() nm := dialect.NormalizedMicrosecond(driver, "created_at") - return r.executeBoundaryTransaction(ctx, message, requestsInput, messageType, metadataJSON, driver, nm) + return r.executeBoundaryTransaction(ctx, message, requestsInput, messageType, metadataJSON, driver, nm, candidate) } // executeBoundaryTransaction runs one per-session write boundary: begin a @@ -225,38 +240,113 @@ func (r *Repository) executeBoundaryTransaction( message *models.Message, requestsInput int, messageType, metadataJSON, driver, nm string, -) error { + candidate *admission.InitialTaskBriefCandidate, +) (err error) { origCreatedAt := message.CreatedAt origUpdatedAt := message.UpdatedAt origPromptIndex := message.PromptIndex + origContent := message.Content + origCandidateSelected := false + if candidate != nil { + origCandidateSelected = candidate.Selected + } restore := func() { message.CreatedAt = origCreatedAt message.UpdatedAt = origUpdatedAt message.PromptIndex = origPromptIndex + message.Content = origContent + if candidate != nil { + candidate.Selected = origCandidateSelected + } } tx, err := r.db.BeginTxx(ctx, nil) if err != nil { return fmt.Errorf("begin user message creation: %w", err) } - defer func() { _ = tx.Rollback() }() + defer func() { + _ = tx.Rollback() + if err != nil { + restore() + } + }() + if candidate != nil { + if err := r.lockTaskRowInTx(ctx, tx, message.TaskID); err != nil { + return err + } + if err := r.validateInitialTaskBriefCandidate(ctx, tx, message.TaskID, candidate); err != nil { + return err + } + } if err := lockSessionTurnWrites(ctx, tx, driver, message.TaskSessionID); err != nil { return err } + if candidate != nil { + if err := r.selectInitialTaskBriefCandidate(ctx, tx, message.TaskSessionID, message, candidate); err != nil { + return err + } + } + if err := plancomments.ValidateRenderedPrompt(message.Content); err != nil { + return err + } if err := r.assignUserMessageBoundary(ctx, tx, message, driver, nm); err != nil { return err } if err := r.insertMessageRow(ctx, tx, message, requestsInput, messageType, metadataJSON); err != nil { - restore() return err } if err := tx.Commit(); err != nil { - restore() return fmt.Errorf("commit user message creation: %w", err) } return nil } +func (r *Repository) validateInitialTaskBriefCandidate( + ctx context.Context, + tx messageBoundaryExecer, + taskID string, + candidate *admission.InitialTaskBriefCandidate, +) error { + var description string + err := tx.QueryRowContext(ctx, tx.Rebind( + `SELECT description FROM tasks WHERE id = ?`, + ), taskID).Scan(&description) + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("%w: %s", repoerrors.ErrTaskNotFound, taskID) + } + if err != nil { + return fmt.Errorf("read task description for initial task brief: %w", err) + } + if description != candidate.DescriptionSnapshot { + return fmt.Errorf("%w: %s", repoerrors.ErrInitialTaskBriefStale, taskID) + } + return nil +} + +func (r *Repository) selectInitialTaskBriefCandidate( + ctx context.Context, + tx messageBoundaryExecer, + sessionID string, + message *models.Message, + candidate *admission.InitialTaskBriefCandidate, +) error { + candidate.Selected = false + var marker int + err := tx.QueryRowContext(ctx, tx.Rebind( + `SELECT 1 FROM task_session_prompt_seq WHERE task_session_id = ?`, + ), sessionID).Scan(&marker) + switch { + case errors.Is(err, sql.ErrNoRows): + message.Content = candidate.Content + candidate.Selected = true + return nil + case err != nil: + return fmt.Errorf("read initial task brief admission marker: %w", err) + default: + return nil + } +} + func (r *Repository) assignUserMessageBoundary( ctx context.Context, tx messageBoundaryExecer, diff --git a/apps/backend/internal/task/service/service_access.go b/apps/backend/internal/task/service/service_access.go index b63ebe0790..36badbd7d7 100644 --- a/apps/backend/internal/task/service/service_access.go +++ b/apps/backend/internal/task/service/service_access.go @@ -296,7 +296,21 @@ func (s *Service) AuthorizeSessionScope(ctx context.Context, sessionID string, s // caller and that the session belongs to the supplied task. Mismatches use the // task not-found sentinel so callers cannot enumerate another task's sessions. func (s *Service) AuthorizeTaskSessionAccess(ctx context.Context, taskID, sessionID string) error { - if err := s.AuthorizeTaskAccess(ctx, taskID); err != nil { + return s.authorizeTaskSessionScope(ctx, taskID, sessionID, authz.ScopeWorkspaceRead) +} + +// AuthorizeTaskSessionPromptAccess checks the task/session pair and requires +// the session.prompt capability used to create or dispatch user messages. +func (s *Service) AuthorizeTaskSessionPromptAccess(ctx context.Context, taskID, sessionID string) error { + return s.authorizeTaskSessionScope(ctx, taskID, sessionID, authz.ScopeSessionPrompt) +} + +func (s *Service) authorizeTaskSessionScope( + ctx context.Context, + taskID, sessionID string, + scope authz.Scope, +) error { + if err := s.authorizeTaskScope(ctx, taskID, scope); err != nil { return err } session, err := s.sessions.GetTaskSession(ctx, sessionID) diff --git a/apps/backend/internal/task/service/service_messages.go b/apps/backend/internal/task/service/service_messages.go index 1a28a3eac2..2ebf3e76b9 100644 --- a/apps/backend/internal/task/service/service_messages.go +++ b/apps/backend/internal/task/service/service_messages.go @@ -17,7 +17,9 @@ import ( "github.com/kandev/kandev/internal/orchestrator/messagequeue" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/plancomments" + "github.com/kandev/kandev/internal/task/repository/admission" "github.com/kandev/kandev/internal/task/repository/plancommenttx" + "github.com/kandev/kandev/internal/task/repository/repoerrors" ) const ( @@ -52,6 +54,36 @@ type queuedPlanCommentMessageWriter interface { ) (*models.TaskPlanCommentSnapshot, error) } +type initialTaskBriefMessageWriter interface { + CreateMessageWithInitialTaskBrief(context.Context, *models.Message, *admission.InitialTaskBriefCandidate) error +} + +type initialTaskBriefPlanCommentMessageWriter interface { + CreateMessageWithPlanCommentsWithInitialTaskBrief( + context.Context, + *models.Message, + *admission.InitialTaskBriefCandidate, + []models.TaskPlanCommentRef, + bool, + models.TaskSessionState, + *messagequeue.QueueAttachmentClaim, + ) (*models.TaskPlanCommentSnapshot, error) +} + +type initialTaskBriefQueuedPlanCommentMessageWriter interface { + CreateMessageWithPlanCommentsAndQueueWithInitialTaskBrief( + context.Context, + *models.Message, + *messagequeue.QueuedMessage, + *admission.InitialTaskBriefCandidate, + []models.TaskPlanCommentRef, + bool, + models.TaskSessionState, + *messagequeue.QueueAttachmentClaim, + int, + ) (*models.TaskPlanCommentSnapshot, error) +} + type planCommentAdmissionLocker interface { AcquirePlanCommentAdmission(context.Context, string) (context.Context, func(), error) } @@ -364,10 +396,22 @@ func (s *Service) CreateQueuedMessageIdempotent( if !ok { return nil, errors.New("queued plan comment message admission is unavailable") } - snapshot, err := writer.CreateMessageWithPlanCommentsAndQueue( - ctx, message, queued, req.PlanCommentRefs, req.RequirePrimarySession, - req.ExpectedSessionState, req.AttachmentClaim, maxPerSession, - ) + var snapshot *models.TaskPlanCommentSnapshot + if req.InitialTaskBrief != nil { + initialWriter, initialOK := s.messages.(initialTaskBriefQueuedPlanCommentMessageWriter) + if !initialOK { + return nil, errors.New("queued initial task brief admission is unavailable") + } + snapshot, err = initialWriter.CreateMessageWithPlanCommentsAndQueueWithInitialTaskBrief( + ctx, message, queued, req.InitialTaskBrief, req.PlanCommentRefs, + req.RequirePrimarySession, req.ExpectedSessionState, req.AttachmentClaim, maxPerSession, + ) + } else { + snapshot, err = writer.CreateMessageWithPlanCommentsAndQueue( + ctx, message, queued, req.PlanCommentRefs, req.RequirePrimarySession, + req.ExpectedSessionState, req.AttachmentClaim, maxPerSession, + ) + } if err != nil { existing, found, lookupErr := s.findPlanCommentMessageReplay(ctx, id, req) if lookupErr == nil && found { @@ -483,6 +527,9 @@ func (s *Service) authorizeMessageCreate(ctx context.Context, req *CreateMessage if req == nil || req.AuthorType == createdByAgent { return nil } + if req.TaskID != "" { + return s.AuthorizeTaskSessionPromptAccess(ctx, req.TaskID, req.TaskSessionID) + } return s.AuthorizeSessionScope(ctx, req.TaskSessionID, authz.ScopeSessionPrompt) } @@ -609,6 +656,9 @@ func (s *Service) createMessageWithRequestRetry( if ctx.Err() != nil { return nil, ctx.Err() } + if errors.Is(err, repoerrors.ErrInitialTaskBriefStale) { + return nil, err + } if attempt < maxRetries-1 { s.logger.Debug("failed to create message, retrying", zap.String("message_id", message.ID), @@ -631,8 +681,25 @@ func (s *Service) persistMessage( req *CreateMessageRequest, ) (*models.TaskPlanCommentSnapshot, error) { if len(req.PlanCommentRefs) == 0 { + if req.InitialTaskBrief != nil { + writer, ok := s.messages.(initialTaskBriefMessageWriter) + if !ok { + return nil, errors.New("initial task brief admission is unavailable") + } + return nil, writer.CreateMessageWithInitialTaskBrief(ctx, message, req.InitialTaskBrief) + } return nil, s.messages.CreateMessage(ctx, message) } + if req.InitialTaskBrief != nil { + writer, ok := s.messages.(initialTaskBriefPlanCommentMessageWriter) + if !ok { + return nil, errors.New("plan comment initial task brief admission is unavailable") + } + return writer.CreateMessageWithPlanCommentsWithInitialTaskBrief( + ctx, message, req.InitialTaskBrief, req.PlanCommentRefs, req.RequirePrimarySession, + req.ExpectedSessionState, req.AttachmentClaim, + ) + } writer, ok := s.messages.(planCommentMessageWriter) if !ok { return nil, errors.New("plan comment message admission is unavailable") diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index 2d0a8bbe75..2086d8f887 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -7,6 +7,7 @@ import ( v1 "github.com/kandev/kandev/pkg/api/v1" "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/admission" ) // Request types @@ -357,18 +358,19 @@ type UpdateRepositoryScriptRequest struct { // CreateMessageRequest contains the data for creating a new message type CreateMessageRequest struct { - TaskSessionID string `json:"session_id"` - TaskID string `json:"task_id,omitempty"` - TurnID string `json:"turn_id"` - CompletedTurn bool `json:"-"` - Content string `json:"content"` - AuthorType string `json:"author_type,omitempty"` // "user" or "agent", defaults to "user" - AuthorID string `json:"author_id,omitempty"` - RequestsInput bool `json:"requests_input,omitempty"` - Type string `json:"type,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - PlanCommentRefs []models.TaskPlanCommentRef `json:"plan_comment_refs,omitempty"` - RequirePrimarySession bool `json:"require_primary_session,omitempty"` - ExpectedSessionState models.TaskSessionState `json:"-"` - AttachmentClaim *messagequeue.QueueAttachmentClaim `json:"-"` + TaskSessionID string `json:"session_id"` + TaskID string `json:"task_id,omitempty"` + TurnID string `json:"turn_id"` + CompletedTurn bool `json:"-"` + Content string `json:"content"` + AuthorType string `json:"author_type,omitempty"` // "user" or "agent", defaults to "user" + AuthorID string `json:"author_id,omitempty"` + RequestsInput bool `json:"requests_input,omitempty"` + Type string `json:"type,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + PlanCommentRefs []models.TaskPlanCommentRef `json:"plan_comment_refs,omitempty"` + RequirePrimarySession bool `json:"require_primary_session,omitempty"` + ExpectedSessionState models.TaskSessionState `json:"-"` + AttachmentClaim *messagequeue.QueueAttachmentClaim `json:"-"` + InitialTaskBrief *admission.InitialTaskBriefCandidate `json:"-"` } diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 0879a1890d..d6053c0c85 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -240,6 +240,8 @@ type CreateTaskOpts = { workflow_id?: string; workflow_step_id?: string; agent_profile_id?: string; + /** Prepare a CREATED session without launching the agent. */ + prepare_session?: boolean; session_target?: WorkflowSessionTarget | null; executor_profile_id?: string; repository_ids?: string[]; @@ -309,6 +311,7 @@ function buildCreateTaskBody( setIf(body, "workflow_id", options.workflow_id); setIf(body, "workflow_step_id", options.workflow_step_id); setIf(body, "agent_profile_id", options.agent_profile_id); + if (options.prepare_session) body.prepare_session = true; if (options.session_target !== undefined) body.session_target = options.session_target; setIf(body, "executor_profile_id", options.executor_profile_id); setIf(body, "metadata", buildTaskMetadata(options)); @@ -530,39 +533,7 @@ export class ApiClient { async createTask( workspaceId: string, title: string, - opts?: { - description?: string; - workflow_id?: string; - workflow_step_id?: string; - /** Stored in task.Metadata so auto_start_agent can pick it up on on_enter. */ - agent_profile_id?: string; - session_target?: WorkflowSessionTarget | null; - /** Executor profile used when the task session is prepared. */ - executor_profile_id?: string; - /** Repository IDs to associate with the task (required for agent execution). */ - repository_ids?: string[]; - /** Full repository entries with optional checkout_branch / base_branch / pr_number. */ - repositories?: TaskRepositoryInput[]; - /** When true, task is placed at position 0 regardless of is_start_step. */ - plan_mode?: boolean; - /** Start the task with the immutable autopilot MCP/prompt contract. */ - autopilot?: boolean; - /** Extra metadata to store on the task. */ - metadata?: Record; - /** Parent task ID for subtasks. */ - parent_id?: string; - /** Workspace sharing policy used by parent/child task trees. */ - workspace_mode?: "inherit_parent" | "new_workspace" | "shared_group"; - /** Existing group required when workspace_mode is shared_group. */ - workspace_group_id?: string; - attachments?: MessageAttachmentInput[]; - /** Task IDs this task must wait on. Suppresses the immediate agent launch. */ - blocked_by?: string[]; - /** Force the start-when-unblocked intent on or off; defaults from start_agent. */ - start_when_unblocked?: boolean; - /** One of "critical" | "high" | "medium" | "low". Server defaults to "medium" when omitted. */ - priority?: TaskPriority; - }, + opts?: CreateTaskOpts, ): Promise { return this.request("POST", "/api/v1/tasks", buildCreateTaskBody(workspaceId, title, opts)); } diff --git a/apps/web/e2e/tests/chat/initial-task-brief-helpers.ts b/apps/web/e2e/tests/chat/initial-task-brief-helpers.ts new file mode 100644 index 0000000000..5028d528de --- /dev/null +++ b/apps/web/e2e/tests/chat/initial-task-brief-helpers.ts @@ -0,0 +1,84 @@ +import { expect, type Page, type SeedData } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import { SessionPage } from "../../pages/session-page"; + +export const INITIAL_TASK_BRIEF = + "Initial task brief: preserve this long user supplied context while the prepared session starts. " + + "Use the repository conventions, inspect the existing workflow, and keep the final change reviewable."; +export const INITIAL_TASK_INSTRUCTION = + "Begin with the user instruction and report the first concrete step."; +export const FOLLOWUP_INSTRUCTION = "Follow up without repeating the original brief."; + +type InitialTaskBriefFlowOptions = { + testPage: Page; + apiClient: ApiClient; + seedData: SeedData; + title: string; +}; + +export async function runInitialTaskBriefFlow({ + testPage, + apiClient, + seedData, + title, +}: InitialTaskBriefFlowOptions): Promise { + const task = await apiClient.createTask(seedData.workspaceId, title, { + description: INITIAL_TASK_BRIEF, + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + agent_profile_id: seedData.agentProfileId, + prepare_session: true, + repository_ids: [seedData.repositoryId], + }); + if (!task.session_id) throw new Error("prepared task did not return a session_id"); + + const session = new SessionPage(testPage); + await testPage.goto(`/t/${task.id}`); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 45_000 }); + await session.sendMessageViaButton(INITIAL_TASK_INSTRUCTION); + + const chat = session.activeChat(); + const userBubbles = chat.getByTestId("user-message-bubble"); + await expect(userBubbles).toHaveCount(1, { timeout: 15_000 }); + const firstBubble = userBubbles.first(); + await expect(firstBubble).toContainText(INITIAL_TASK_BRIEF); + await expect(firstBubble).toContainText(INITIAL_TASK_INSTRUCTION); + + await expect + .poll( + async () => { + const { messages } = await apiClient.listSessionMessages(task.session_id!); + return messages.filter((message) => message.author_type === "user"); + }, + { timeout: 30_000, message: "Waiting for the combined first prompt to persist" }, + ) + .toHaveLength(1); + + await testPage.reload(); + await session.waitForLoad(); + await expect(userBubbles).toHaveCount(1, { timeout: 15_000 }); + await expect(userBubbles.first()).toContainText(INITIAL_TASK_BRIEF); + await expect(userBubbles.first()).toContainText(INITIAL_TASK_INSTRUCTION); + + await session.waitForChatIdle({ timeout: 45_000 }); + await session.sendMessageViaButton(FOLLOWUP_INSTRUCTION); + await expect(userBubbles).toHaveCount(2, { timeout: 15_000 }); + await expect(userBubbles.nth(1)).toContainText(FOLLOWUP_INSTRUCTION); + await expect(userBubbles.nth(1)).not.toContainText(INITIAL_TASK_BRIEF); + + const userMessages = await apiClient.listSessionMessages(task.session_id); + const storedUserMessages = userMessages.messages.filter( + (message) => message.author_type === "user", + ); + expect(storedUserMessages).toHaveLength(2); + expect(storedUserMessages[0]?.content).toContain(INITIAL_TASK_BRIEF); + expect(storedUserMessages[0]?.content).toContain(INITIAL_TASK_INSTRUCTION); + expect(storedUserMessages[1]?.content).toContain(FOLLOWUP_INSTRUCTION); + expect(storedUserMessages[1]?.content).not.toContain(INITIAL_TASK_BRIEF); + + const hasDocumentOverflow = await testPage.evaluate( + () => document.documentElement.scrollWidth > document.documentElement.clientWidth, + ); + expect(hasDocumentOverflow).toBe(false); +} diff --git a/apps/web/e2e/tests/chat/initial-task-brief.spec.ts b/apps/web/e2e/tests/chat/initial-task-brief.spec.ts new file mode 100644 index 0000000000..de309a26b1 --- /dev/null +++ b/apps/web/e2e/tests/chat/initial-task-brief.spec.ts @@ -0,0 +1,19 @@ +import { test } from "../../fixtures/test-base"; +import { runInitialTaskBriefFlow } from "./initial-task-brief-helpers"; + +test.describe("Initial task brief", () => { + // @covers AC-TASKS-INITIAL-TASK-BRIEF-001.1, AC-TASKS-INITIAL-TASK-BRIEF-001.2, AC-TASKS-INITIAL-TASK-BRIEF-001.7 + test("keeps the brief in the first user message after reload", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(180_000); + await runInitialTaskBriefFlow({ + testPage, + apiClient, + seedData, + title: "Initial task brief desktop", + }); + }); +}); diff --git a/apps/web/e2e/tests/chat/mobile-initial-task-brief.spec.ts b/apps/web/e2e/tests/chat/mobile-initial-task-brief.spec.ts new file mode 100644 index 0000000000..c3af66801d --- /dev/null +++ b/apps/web/e2e/tests/chat/mobile-initial-task-brief.spec.ts @@ -0,0 +1,19 @@ +import { test } from "../../fixtures/test-base"; +import { runInitialTaskBriefFlow } from "./initial-task-brief-helpers"; + +test.describe("Initial task brief on mobile", () => { + // @covers AC-TASKS-INITIAL-TASK-BRIEF-001.1, AC-TASKS-INITIAL-TASK-BRIEF-001.2, AC-TASKS-INITIAL-TASK-BRIEF-001.7 + test("keeps the brief in the first user message after reload", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(180_000); + await runInitialTaskBriefFlow({ + testPage, + apiClient, + seedData, + title: "Initial task brief mobile", + }); + }); +}); diff --git a/apps/web/hooks/use-processed-messages-fallback.test.ts b/apps/web/hooks/use-processed-messages-fallback.test.ts index cbc3c63c9f..d4bf5fc1de 100644 --- a/apps/web/hooks/use-processed-messages-fallback.test.ts +++ b/apps/web/hooks/use-processed-messages-fallback.test.ts @@ -141,6 +141,27 @@ describe("useProcessedMessages task description fallback", () => { }); }); +describe("combined initial task brief transcript", () => { + it("does not synthesize a brief after the combined first prompt is stored", () => { + const userMessage = makeMessage( + "user-1", + "user", + `${TASK_DESCRIPTION}\n\nadditional instruction`, + ); + const { result } = renderHook(() => + useProcessedMessages([userMessage], "t1", "s1", TASK_DESCRIPTION, { + historyInitialized: true, + hasOlderMessages: false, + }), + ); + + expect(result.current.allMessages).toEqual([userMessage]); + expect(result.current.allMessages[0]?.content).toBe( + `${TASK_DESCRIPTION}\n\nadditional instruction`, + ); + }); +}); + afterEach(() => { vi.useRealTimers(); }); diff --git a/docs/plans/initial-task-brief/plan.md b/docs/plans/initial-task-brief/plan.md new file mode 100644 index 0000000000..17b423f6c6 --- /dev/null +++ b/docs/plans/initial-task-brief/plan.md @@ -0,0 +1,204 @@ +--- +created: 2026-09-12 +status: implemented +requirements: + - REQ-TASKS-INITIAL-TASK-BRIEF-001 +system_design: + - ../../specs/tasks/system-design/initial-task-brief.md +legacy_specs: [] +--- + +# Implementation plan: Initial task brief + +## Overview + +Preserve the task brief when a user starts a prepared session through Chat. +First extend atomic message admission. Then connect composition and dispatch. +Finally prove transcript visibility on desktop and phone. All work is sequential. + +Source: [GitHub issue #3615](https://github.com/kdlbs/kandev/issues/3615). +Investigated revision: `8892920514250dfc9873725f90d736a5cc7e388a`. +The issue had no comments or image attachments during investigation. +The authenticated user `carlosflorencio` was assigned on 2026-09-12. + +## Confirmed root cause + +1. `hooks/use-message-handler.ts` submits the additional text through `message.add`. +2. `internal/task/handlers/message_handlers.go:wsAddMessage` persists the submitted content without the task description. +3. `dispatchPromptAsync` forwards the committed content to `forwardMessageAsPrompt`. +4. `StartCreatedSession` uses the supplied nonempty prompt. Its description fallback applies only to an empty prompt. +5. `hooks/use-processed-messages.ts:shouldShowTaskDescriptionFallback` removes the synthetic brief after any stored user message appears. + +The transcript behavior conforms to its existing specification. The missing +contract is preservation during direct first-message admission. Do not weaken +the UI fallback predicate or change automatic workflow fallback semantics. + +## Reproduction evidence + +A temporary `internal/task/handlers/issue3615_repro_test.go` used the existing +`runCreatedMessageContextTest` harness. It created an ordinary task with a +nonempty description and a single CREATED session. It submitted +`rebase on latest main` through the real handler and task service. + +```bash +(cd apps/backend && go test -tags fts5 ./internal/task/handlers -run '^TestReproIssue3615$' -count=1 -v) +``` + +The assertion failed for the expected reason: + +```text +saved brief=false, saved instruction=true, description intact=true +original task brief omitted from saved first message +FAIL TestReproIssue3615 +``` + +This reproduction used a repository fake and no agent process. Agent omission +is supported by the dispatch source trace and issue evidence, not a live-agent run. +The temporary test was removed after recording the result. No runtime was launched. + +Permanent regression: `TestWSAddMessage_InitialTaskBrief` in +`apps/backend/internal/task/handlers/message_handlers_initial_task_brief_test.go`. +Use the existing `firstTurnCaptureOrchestrator` to assert saved and dispatched +visible content, then cover the real orchestrator composition separately. + +## Scope + +### In scope + +- Direct first-message brief composition for ordinary CREATED sessions. +- Atomic selection using the existing durable session prompt boundary. +- Idempotency, rollback, description snapshots, and concurrent admission. +- Existing structured, passthrough, saved-prompt, attachment, and queue integration. +- Persisted prompt #1 visibility on desktop and phone. + +### Out of scope + +- Production or permanent test changes during this design turn. +- Historical transcript backfill, new prompt ordinals, or synthetic user history. +- Changes to automatic workflow entry, Office, Quick Chat, or configuration policies. +- New public replacement controls, API fields, or database migrations. +- Provider-level delivery guarantees after crashes. + +## Technical approach + +Follow [the system design](../../specs/tasks/system-design/initial-task-brief.md). +A server-only initial candidate travels from the handler through the service. +The repository chooses it inside the existing prompt write transaction. +The handler dispatches the returned content with its matching trusted context. + +Do not call `HasUserPromptHistory` outside a transaction and then write later. +Do not select by `PromptIndex == 1` alone: automatic fallback reserves ordinal +zero before its user row exists. Preserve the existing fallback reservation. + +Compose and deduplicate the raw brief and instruction before one server-owned +preparer call. Carry the resulting content, trusted reference context, and an +explicit prepared-state bit as one candidate through admission and dispatch; +the bit preserves an accepted empty expansion snapshot. Compare the description +snapshot during admission and refresh only the candidate when it is stale. +Keep replay identity based on the original request. Preserve plan-comment +transaction and attachment ownership. Validate the final rendered content at +the database boundary before insertion. + +Authorize the task/session pair before reading mutable task state. Atomic +admission gives only the selected candidate created-session launch ownership. +Later contenders are persisted and queued with turn-start already processed and +an admission-order dispatch marker, so queue fast paths wait for the admitted +launch. Only the selected queued plan-comment candidate notifies the lifecycle +starter. This keeps visible content, trusted expansion, and launch ownership +paired across direct and queued delivery. + +The rendered structure stays unchanged. Stored prompt content corrects visibility. +No new locale keys are expected. Any added product copy must use existing localization rules. + +## ASCII UI preview + +```text +UI-01: Chat after first message (desktop and phone) + +Before Proposed ++-------------------------+ +-------------------------+ +| User #1 | | User #1 | +| rebase on latest main | | Original task brief | +| | | | +| Agent response | | rebase on latest main | ++-------------------------+ | Agent response | +| Composer [Send] | +-------------------------+ ++-------------------------+ | Composer [Send] | + +-------------------------+ +``` + +Entry: open the task, then send a preparatory message before its first start. +AC references: `AC-TASKS-INITIAL-TASK-BRIEF-001.1`, `.2`, `.7`. +The before view is supported by the source predicate and reproduction. + +Desktop uses the existing Chat pane. Phone uses full-height Chat through +`task-layout.tsx` and `SessionMobileLayout`. Both share this content order. +The transcript scrolls vertically. The composer retains its existing fixed +region and phone safe-area behavior. Long content uses existing expansion +controls and history navigation. No new drawer, button, or hover action is required. +Content order and durable visibility are required. Borders and spacing are illustrative. + +## Tests + +| Criteria | Test and boundary | +| --- | --- | +| `.1`, `.2`, `.4`, `.10` | `TestWSAddMessage_InitialTaskBrief`, `TestWSAddMessage_InitialTaskBriefExpandsCombinedPromptAtAdmission`, `TestWSAddMessage_InitialTaskBriefKeepsAcceptedExpansionWhenDefinitionsChange`, handler persistence and captured dispatch | +| `.3`, `.5`, `.6` | `TestInitialTaskBriefAdmission`, SQLite transaction, fallback reservation, rollback, and replay | +| `.5`, `.6` | `TestInitialTaskBriefAdmissionPostgres`, cross-connection transaction parity | +| `.8`, `.9` | `TestStartCreatedSession_InitialTaskBrief`, `TestApplyWorkflowAndPlanMode_PreservesEmptyAcceptedPromptSnapshot`, handler saved-reference table cases, and queued initial-brief delivery | +| `.2`, `.7` | `use-processed-messages-fallback.test.ts`, synthetic row replacement with combined prompt | + +All criterion suffixes refer to `AC-TASKS-INITIAL-TASK-BRIEF-001`. +Repository tests must include two distinct first submissions, a direct/fallback +race, zero reservation, message deletion, restart, stale description, and rollback. +Handler cases must include normal launch, queued promotion, session redirection, +attachments, plan comments, saved references, equality, empty brief, and excluded modes. + +## E2E tests + +Add `e2e/tests/chat/initial-task-brief.spec.ts` for `chromium` and +`e2e/tests/chat/mobile-initial-task-brief.spec.ts` for `mobile-chrome`. +Seed a task without starting it. Open Chat, observe the brief, and submit the +preparatory instruction through the composer. Assert both texts in prompt #1, +reload, and assert them again. Send a later message and assert no brief replay. +These flows cover `.1`, `.2`, `.3`, and `.7`. Use existing causal WebSocket waits. + +## Work orders + +- [x] [Task 01: Atomic initial content selection](task-01-atomic-admission.md) — done +- [x] [Task 02: First-message composition](task-02-first-message-composition.md) — done +- [x] [Task 03: Transcript visibility evidence](task-03-transcript-evidence.md) — done + +## Related packages and documentation + +The completed `workflow-step-agent-start-ownership` package owns reset/start +routing. Its scope and results remain unchanged. The transcript visibility +package owns pagination. This package consumes both contracts without rewriting +their completed work orders or test results. + +Task 03 added a short explanation to `docs/public/tasks-and-workflows.md` with +the implemented behavior. + +## Verification results + +- Permanent backend admission, handler, service, and orchestrator regressions passed, including combined saved-reference preparation, accepted-context dispatch, and queued delivery. +- Review-remediation regressions passed for task/session pair authorization, stale-description candidate refresh without repeated turn-start hooks, admission-order launch ownership, deferred queue fast-path delivery, accepted empty expansion snapshots, and rendered prompt size validation. +- SQLite admission tests passed, including rollback, stale snapshots, deletion, restart, fallback races, zero reservations, and plan-comment queues. +- PostgreSQL parity test was skipped because `KANDEV_TEST_POSTGRES_DSN` is not configured in this environment. +- `go vet` passed for all changed backend packages. +- `make -C apps/backend lint`, `make -C apps/backend build`, and `go run ./cmd/sqlguard ./internal` passed. +- Race-enabled initial-admission tests and `go test -race ./internal/persistence/storeconformance -count=1` passed. +- `pnpm exec vitest run hooks/use-processed-messages-fallback.test.ts` passed. +- `pnpm run typecheck` passed. +- `pnpm run i18n:check` passed. +- Targeted changed-file ESLint passed with zero warnings, and `pnpm run e2e:sleep-ratchet` passed. +- Managed `chromium` and `mobile-chrome` E2E tests passed, including reload and later-message behavior. +- Both public-doc validators passed; the specification test suite passed 36 tests, all specification files passed, and `git diff --check` passed. + +## Risks + +- Candidate selection must pair visible content with the correct trusted expansion. +- A nonempty workflow template can replace a direct prompt during launch composition. +- A fallback reservation has no visible row yet but still owns the first boundary. +- Plan-comment admission and queued content must select the same candidate atomically. +- Existing affected conversations require manual brief recovery. This package does not backfill them. diff --git a/docs/plans/initial-task-brief/task-01-atomic-admission.md b/docs/plans/initial-task-brief/task-01-atomic-admission.md new file mode 100644 index 0000000000..0e05496029 --- /dev/null +++ b/docs/plans/initial-task-brief/task-01-atomic-admission.md @@ -0,0 +1,93 @@ +--- +id: "01-atomic-admission" +title: "Atomic initial content selection" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-TASKS-INITIAL-TASK-BRIEF-001 +acceptance_criteria: + - AC-TASKS-INITIAL-TASK-BRIEF-001.3 + - AC-TASKS-INITIAL-TASK-BRIEF-001.5 + - AC-TASKS-INITIAL-TASK-BRIEF-001.6 +system_design: + - ../../specs/tasks/system-design/initial-task-brief.md +--- + +# Task 01: Atomic initial content selection + +## Summary + +Extend the existing message transaction with an optional server-owned initial candidate. +Keep ordinary callers unchanged until Task 02 opts in. + +## In scope + +- Add typed internal admission options through service and repository contracts. +- Select content before prompt ordinal allocation under the existing write lock. +- Share selection with plan-comment and queued plan-comment transactions. +- Preserve replay identity, description snapshot validation, and rollback restoration. + +## Out of scope + +Do not change unrelated launch policy, historical messages, or public API fields. + +## Acceptance + +- `TestInitialTaskBriefAdmission` proves one winner, rollback retry, stale snapshot rejection, zero reservation, deletion, and restart behavior. +- `TestInitialTaskBriefAdmissionPostgres` proves the same boundary with separate connections. +- Candidate content, trusted-context identity, message metadata, and queue content commit together. + +## Verification + +Start with failing behavior assertions before implementation. Then run this +block from the repository root: + +```bash +(cd apps/backend && go test -tags fts5 ./internal/task/repository/sqlite ./internal/task/service -run 'InitialTaskBrief|Prompt|MessageWithPlanComments' -count=1) +# For PostgreSQL parity, configure a disposable test database first. +(cd apps/backend && test -n "$KANDEV_TEST_POSTGRES_DSN" && go test -tags fts5 ./internal/task/repository/sqlite -run '^TestInitialTaskBriefAdmissionPostgres$' -count=1) +git diff --check +``` + +## Files likely touched + +- `apps/backend/internal/task/service/service_messages.go` +- `apps/backend/internal/task/repository/interface.go` +- `apps/backend/internal/task/repository/sqlite/message.go` +- `apps/backend/internal/task/repository/sqlite/message_prompt_index.go` +- `apps/backend/internal/task/repository/sqlite/message_plan_comment.go` +- `apps/backend/internal/task/repository/sqlite/message_initial_task_brief_test.go (new)` +- `apps/backend/internal/task/repository/sqlite/message_initial_task_brief_postgres_test.go (new)` + +## Dependencies + +None. + +## Risks + +Do not use a separate history read or committed reservation followed by a later message insert. +A skipped PostgreSQL test is not parity evidence. Use existing disposable database fixtures. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/tasks/requirements/initial-task-brief.md). +- [System design](../../specs/tasks/system-design/initial-task-brief.md). +- [Package evidence and test matrix](plan.md). + +## Results + +- Added the server-owned initial candidate through service and repository admission contracts. +- Selected the candidate inside the existing prompt boundary transaction, with task-row snapshot validation and rollback restoration. +- Preserved idempotency and covered normal, plan-comment, and queued plan-comment writes. +- Added SQLite coverage for two competing direct submissions, fallback races, zero-valued fallback reservations, stale descriptions, rollback, deletion, restart, and plan/queue boundaries. +- Added a cross-connection PostgreSQL parity test. It skips when `KANDEV_TEST_POSTGRES_DSN` is unset. +- Validated the selected rendered prompt against `plancomments.MaxRenderedPromptBytes` before insertion, with rollback coverage for an oversized candidate. +- `go test -tags fts5 ./internal/task/repository/sqlite -run '^TestInitialTaskBrief' -count=1` passed. +- `go test -race -tags fts5 ./internal/task/repository/sqlite -run '^TestInitialTaskBrief' -count=1` passed. +- `go vet ./internal/task/repository/sqlite ./internal/task/service ./internal/task/handlers ./internal/orchestrator ./internal/backendapp` passed. diff --git a/docs/plans/initial-task-brief/task-02-first-message-composition.md b/docs/plans/initial-task-brief/task-02-first-message-composition.md new file mode 100644 index 0000000000..8a021211f8 --- /dev/null +++ b/docs/plans/initial-task-brief/task-02-first-message-composition.md @@ -0,0 +1,95 @@ +--- +id: "02-first-message-composition" +title: "First-message composition" +status: done +wave: 2 +depends_on: ["01-atomic-admission"] +plan: "plan.md" +requirements: + - REQ-TASKS-INITIAL-TASK-BRIEF-001 +acceptance_criteria: + - AC-TASKS-INITIAL-TASK-BRIEF-001.1 + - AC-TASKS-INITIAL-TASK-BRIEF-001.2 + - AC-TASKS-INITIAL-TASK-BRIEF-001.4 + - AC-TASKS-INITIAL-TASK-BRIEF-001.5 + - AC-TASKS-INITIAL-TASK-BRIEF-001.8 + - AC-TASKS-INITIAL-TASK-BRIEF-001.9 + - AC-TASKS-INITIAL-TASK-BRIEF-001.10 +system_design: + - ../../specs/tasks/system-design/initial-task-brief.md +--- + +# Task 02: First-message composition + +## Summary + +Compose the original brief with the additional instruction before first-message persistence. +Dispatch the selected stored content and its matching trusted expansion. + +## In scope + +- Opt eligible ordinary CREATED direct messages into Task 01 admission. +- Capture and validate the task description after task/session authorization. +- Keep exact equality, empty descriptions, passthrough, saved prompts, references, and attachments correct. +- Preserve visible direct content through workflow composition and session redirection. +- Exclude Office, ephemeral Quick Chat, configuration, and already-prompted sessions. + +## Out of scope + +Do not change unrelated launch policy, historical messages, or public API fields. + +## Acceptance + +- `TestWSAddMessage_InitialTaskBrief` fails on the original branch and then proves saved and captured content contain both texts once. +- `TestStartCreatedSession_InitialTaskBrief` proves real launch composition preserves both texts with empty, placeholder, and replacing step templates. +- Retry, queued promotion, plan comments, and excluded modes pass their regression cases without a second dispatch. + +## Verification + +Start with failing behavior assertions before implementation. Then run this +block from the repository root: + +```bash +(cd apps/backend && go test -tags fts5 ./internal/task/handlers ./internal/task/service ./internal/orchestrator -run 'InitialTaskBrief|WSAddMessage|StartCreatedSession|InitialPromptFallback' -count=1) +git diff --check +``` + +## Files likely touched + +- `apps/backend/internal/task/handlers/message_handlers.go` +- `apps/backend/internal/task/handlers/message_handlers_initial_task_brief.go (new helper)` +- `apps/backend/internal/task/handlers/message_handlers_initial_task_brief_test.go (new)` +- `apps/backend/internal/task/service/service_messages.go` +- `apps/backend/internal/orchestrator/task_operations.go` +- `apps/backend/internal/orchestrator/task_operations_initial_task_brief_test.go (new)` + +## Dependencies + +Task 01 must be complete. + +## Risks + +Never trust a browser initial-content flag or client-supplied system block. +Do not let a second workflow transform replace already composed direct content. +Preserve the original request fingerprint across candidate preparation. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/tasks/requirements/initial-task-brief.md). +- [System design](../../specs/tasks/system-design/initial-task-brief.md). +- [Package evidence and test matrix](plan.md). + +## Results + +- Composed the raw trimmed task description and first direct instruction before saved-prompt expansion, so both inputs share one acceptance-time snapshot. +- Preserved the selected stored content and its matching trusted prompt context through created-session dispatch and workflow prompt composition, including an explicit accepted-empty expansion state. +- Kept equality, empty descriptions, attachments, saved references, plan comments, queued promotion, session redirection, and excluded modes on their existing paths. +- Added task/session pair authorization before mutable task reads and a stale-description retry that rebuilds only the candidate without repeating turn-start or title hooks. +- Added handler regressions for distinct saved references, brief-only references, identical brief and instruction deduplication, definitions changed after admission, queued delivery, mismatched task/session IDs, stale descriptions, and concurrent first sends. +- Restricted created-session launch to the atomically selected candidate; later contenders use an admission-order queue marker and the queue fast path defers until the selected launch completes. +- Added handler and orchestrator regressions for empty, placeholder, and replacing workflow step prompts. +- Focused handler, service, and orchestrator tests passed, including `TestWSAddMessage_InitialTaskBriefExpandsCombinedPromptAtAdmission`, `TestWSAddMessage_InitialTaskBriefKeepsAcceptedExpansionWhenDefinitionsChange`, `TestWSAddMessage_QueuedInitialTaskBriefPersistsAcceptedExpansion`, `TestWSAddMessage_RejectsMismatchedTaskSessionBeforeReadingTask`, `TestWSAddMessage_ConcurrentInitialBriefStartsOnlyAdmittedCandidate`, `TestWSAddMessage_RefreshesStaleBriefWithoutRepeatingTurnStart`, `TestStartCreatedSession_InitialTaskBrief`, and `TestApplyWorkflowAndPlanMode_PreservesEmptyAcceptedPromptSnapshot`. diff --git a/docs/plans/initial-task-brief/task-03-transcript-evidence.md b/docs/plans/initial-task-brief/task-03-transcript-evidence.md new file mode 100644 index 0000000000..d7dd871c7f --- /dev/null +++ b/docs/plans/initial-task-brief/task-03-transcript-evidence.md @@ -0,0 +1,115 @@ +--- +id: "03-transcript-evidence" +title: "Transcript visibility evidence" +status: done +wave: 3 +depends_on: ["02-first-message-composition"] +plan: "plan.md" +requirements: + - REQ-TASKS-INITIAL-TASK-BRIEF-001 +acceptance_criteria: + - AC-TASKS-INITIAL-TASK-BRIEF-001.1 + - AC-TASKS-INITIAL-TASK-BRIEF-001.2 + - AC-TASKS-INITIAL-TASK-BRIEF-001.3 + - AC-TASKS-INITIAL-TASK-BRIEF-001.7 +system_design: + - ../../specs/tasks/system-design/initial-task-brief.md +--- + +# Task 03: Transcript visibility evidence + +## Summary + +Prove that the stored first prompt replaces the synthetic brief without losing content. +Document the behavior only after the implementation passes its focused checks. + +## In scope + +- Extend the processed-message regression with a combined stored prompt #1. +- Add desktop and phone composer/reload flows using mock-agent fixtures. +- Add a concise explanation to the existing public task guide. +- Record actual command results in all work orders and the manifest. + +## Out of scope + +Do not change unrelated launch policy, historical messages, or public API fields. + +## Acceptance + +- Both desktop and phone preserve brief plus instruction after submission and reload. +- The synthetic row disappears, prompt #1 exists once, and later messages do not repeat the brief. +- Long content remains reachable with existing controls and no document-level horizontal overflow. + +## ASCII UI preview + +Same UI-01 as [the package preview](plan.md#ascii-ui-preview), covering `AC-TASKS-INITIAL-TASK-BRIEF-001.2` and `.7`. + +```text +UI-01: Chat after first message (desktop and phone) + +Before Proposed ++-------------------------+ +-------------------------+ +| User #1 | | User #1 | +| rebase on latest main | | Original task brief | +| | | | +| Agent response | | rebase on latest main | ++-------------------------+ | Agent response | +| Composer [Send] | +-------------------------+ ++-------------------------+ | Composer [Send] | + +-------------------------+ +``` + +Both viewports retain their existing Chat surface and vertical scroll owner. +Phone controls keep the existing safe-area and touch behavior. No new controls are planned. + +## Verification + +Start with failing behavior assertions before implementation. Then run this +block from the repository root: + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps/web && pnpm exec vitest run hooks/use-processed-messages-fallback.test.ts) +(cd apps/web && pnpm e2e:run --project chromium tests/chat/initial-task-brief.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/chat/mobile-initial-task-brief.spec.ts) +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +## Files likely touched + +- `apps/web/hooks/use-processed-messages-fallback.test.ts` +- `apps/web/e2e/tests/chat/initial-task-brief.spec.ts (new)` +- `apps/web/e2e/tests/chat/mobile-initial-task-brief.spec.ts (new)` +- `apps/web/e2e/tests/chat/initial-task-brief-helpers.ts (new, if shared)` +- `docs/public/tasks-and-workflows.md` +- `docs/plans/initial-task-brief/plan.md` + +## Dependencies + +Task 02 must be complete. + +## Risks + +Use fresh managed builds and causal waits. A seeded API assertion alone is not browser evidence. +Do not weaken existing transcript pagination or fallback eligibility to satisfy these tests. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/tasks/requirements/initial-task-brief.md). +- [System design](../../specs/tasks/system-design/initial-task-brief.md). +- [Package evidence and test matrix](plan.md). + +## Results + +- Extended the processed-message regression so a persisted combined prompt replaces the synthetic task-description row without duplicating the brief. +- Added managed desktop and phone Chat flows that submit the first message, reload, verify the stored prompt, send a later message, and check for document overflow. +- Added the same prepared-session API option to both E2E flows without changing production API behavior. +- Added public guidance to `docs/public/tasks-and-workflows.md`. +- `pnpm exec vitest run hooks/use-processed-messages-fallback.test.ts` passed. +- `pnpm e2e:run --project chromium tests/chat/initial-task-brief.spec.ts` passed. +- `pnpm e2e:run --project mobile-chrome tests/chat/mobile-initial-task-brief.spec.ts` passed. diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index 424a119655..8914a0ac88 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -322,6 +322,8 @@ A task created with **Create without starting agent** opens in a prepared workbe If the selected profile is unhealthy or incompatible with the executor, fix that configuration before launch. Starting an agent is separate from moving the task through its workflow; entry actions and turn-complete transitions can move or restart work afterward. +When you send a message from Chat before selecting **Start agent**, Kandev keeps the task description in the first user prompt and places your instruction after it. The combined prompt is stored and remains after reload. Later messages contain only their own text. + By default, a running session keeps the coarse **Generating** state and queues another message even if Kandev detects background work. Operators can opt into the high-risk **Claude background prompt handoff** feature toggle for controlled diff --git a/docs/specs/tasks/README.md b/docs/specs/tasks/README.md index 9302171a90..19ec88a739 100644 --- a/docs/specs/tasks/README.md +++ b/docs/specs/tasks/README.md @@ -32,12 +32,179 @@ signals, and task-scoped scheduling contracts. [Office system](../office). - Presentation-only behavior belongs to the [UI specifications](../ui). +## Specification map + +### Requirements + +- [Initial task brief](requirements/initial-task-brief.md) + +- [Task completion, conversation follow-ups, and workspace access](requirements/task-completion.md) + +- [Agent-Generated Task Titles](requirements/agent-generated-titles.md) +- [Additional Session Workspace Reuse](requirements/additional-session-workspace-reuse.md) +- [Task Archive Confirmation](requirements/archive-confirmation.md) +- [Task removal navigation](requirements/removal-navigation.md) +- [Attach Workspace Sources](requirements/attach-workspace-sources.md) +- [Task Autopilot Mode](requirements/autopilot-mode.md) +- [Blocked Task Escalation](requirements/blocked-task-escalation.md) +- [Command-panel archived task results](requirements/command-panel-archived-task-results.md) +- [Active clarification lifecycle scenarios](requirements/clarification-active-lifecycle-scenarios.md) +- [Active clarification lifecycle](requirements/clarification-active-lifecycle.md) +- [Clarification response reliability](requirements/clarification-response-reliability.md) +- [Task Documents](requirements/documents.md) +- [Detached Workspace Continuity](requirements/detached-workspace-continuity.md) +- [Task plan append-mode write](requirements/plan-write-append-mode.md) +- [Task Execution Stages](requirements/execution-stages.md) +- [External task ID idempotency boundaries](requirements/external-id-idempotency-boundaries.md) +- [External task ID idempotency scenarios](requirements/external-id-idempotency-scenarios.md) +- [External task ID idempotency](requirements/external-id-idempotency.md) +- [Interrupted Task Indicator](requirements/interrupted-task-indicator.md) +- [Kanban task cache preserves executor fields across merges](requirements/kanban-task-executor-cache-staleness.md) +- [Task Labels](requirements/labels.md) +- [Link Existing Task to External References](requirements/link-existing-task-github-issue.md) +- [MCP-Created Task Agent Profile Default](requirements/mcp-task-agent-profile-default.md) +- [MCP Tool Name Stability](requirements/mcp-tool-name-stability.md) +- [MCP workspace modes](requirements/mcp-workspace-mode.md) +- [Missing task route recovery](requirements/missing-task-route-recovery.md) +- [Task model unification](requirements/model-unification.md) +- [Multi-branch tasks](requirements/multi-branch.md) +- [Parent-Child Message Interrupt](requirements/parent-child-message-interrupt.md) +- [Parent-Child Task Stop](requirements/parent-child-task-stop.md) +- [Passthrough Queued Prompt Dispatch](requirements/passthrough-queued-prompt-dispatch.md) +- [Task plan content size limit](requirements/plan-content-size-limit.md) +- [Task Plan Comments](requirements/plan-comments.md) +- [Task plan write consistency](requirements/plan-write-consistency.md) +- [Passthrough Initial Prompt Turn Boundary](requirements/passthrough-initial-prompt-turn-boundary.md) +- [Prevent Agent Auto-Start On Open](requirements/prevent-agent-autostart-on-open.md) +- [Prompt attachments](requirements/prompt-attachments.md) +- [Saved Prompt Delivery](requirements/saved-prompt-delivery.md) +- [Quick Chat Sessions, Persistence, and Expiration](requirements/quick-chat-expiration.md) +- [Quick Chat Agent Titles](requirements/quick-chat-agent-titles.md) +- [Quick Chat Repository Context](requirements/quick-chat-repository-context.md) +- [Remote Contribution Tasks](requirements/remote-contribution-tasks.md) +- [Rich task title previews](requirements/rich-task-title-previews.md) +- [Queued run scheduling](requirements/run-scheduling.md) +- [Resume prompt queue](requirements/resume-prompt-queue.md) +- [Task Runtime Cleanup](requirements/runtime-cleanup.md) +- [Task Terminal Persistence](requirements/task-terminal-persistence.md) +- [Runtime Task-State Publication Order](requirements/runtime-state-publication-order.md) +- [Session Delete Preserves Task Workspaces](requirements/session-delete-resource-cleanup.md) +- [Sidebar Task Editing](requirements/sidebar-task-edit.md) +- [Task Actions Menu on Preview and Detail Surfaces](requirements/task-actions-menu.md) +- [Task menu grouping](requirements/task-menu-grouping.md) +- [Task Actions Menu In-Flight and Concurrency](requirements/task-actions-menu-concurrency.md) +- [Task Actions Menu Action Outcomes](requirements/task-actions-menu-outcomes.md) +- [Subtasks as Workflow Checklist](requirements/subtask-checklist.md) +- [Subtask Completion Trigger](requirements/subtask-completion-trigger.md) +- [Subtask detachment](requirements/subtask-detachment.md) +- [Subtask re-parenting by drag and drop](requirements/subtask-reparenting-drag-drop.md) +- [Task Subtree Controls](requirements/subtree-controls.md) +- [Create Task Escape Dismissal](requirements/task-create-escape-dismissal.md) +- [Task Create Agent Compatibility Recovery](requirements/task-create-agent-executor-compatibility.md) +- [Task Create Executor Default](requirements/task-create-executor-default.md) +- [Task Create Launch Preview](requirements/task-create-launch-preview.md) +- [Task Create Workflow Memory](requirements/task-create-workflow-memory.md) +- [Task-create advanced settings disclosure](requirements/task-dependencies-create-dialog-advanced-settings.md) +- [Task-create dependency selector refinement](requirements/task-dependencies-create-dialog-dependency-selector.md) +- [Edit task dependencies](requirements/task-dependency-detail-editing.md) +- [Task Dependencies and Auto-Start Chains](requirements/task-dependencies.md) +- [Session Launch Repository Resolution](requirements/launch-repository-resolution.md) +- [Task Launch Failure Recovery](requirements/task-launch-failure-recovery.md) +- [Worktree metadata recovery](requirements/worktree-metadata-recovery.md) +- [Task priority visibility](requirements/task-priority-visibility.md) +- [Threads task actions](requirements/threads-task-actions.md) +- [Task Title Length Limit](requirements/title-length-limit.md) +- [User Question Turn Boundary](requirements/user-question-turn-boundary.md) +- [WIP Limits and Visible Overflow Queues](requirements/wip-limit-pull-system.md) +- [Tasks Without Repositories](requirements/without-repositories.md) +- [Workflow completion-signal payload delivery](requirements/workflow-completion-signal-payload-delivery.md) +- [Cancelled Turn Completion](requirements/workflow-cancelled-turn-completion.md) +- [Workflow Cycle Guardrails](requirements/workflow-cycle-guardrails.md) +- [Workflow Duplication](requirements/workflow-duplication.md) +- [Explicit Workflow-Step Completion Signal](requirements/workflow-explicit-completion-signal.md) +- [Workflow Profile Session Lifecycle and Recipients](requirements/workflow-profile-session-lifecycle.md) +- [Agent decision recording](requirements/workflow-quorum-decision-recording-agent-surface.md) +- [Quorum ordering and concurrency](requirements/workflow-quorum-decision-recording-concurrency.md) +- [Quorum diagnostics](requirements/workflow-quorum-decision-recording-diagnostics.md) +- [Quorum participant slate](requirements/workflow-quorum-decision-recording-participant-slate.md) +- [Decision-triggered quorum re-evaluation](requirements/workflow-quorum-decision-recording-reevaluation.md) +- [Quorum regression behavior](requirements/workflow-quorum-decision-recording-regression.md) +- [Quorum step binding and thresholds](requirements/workflow-quorum-decision-recording-step-binding.md) +- [Quorum verdict vocabulary](requirements/workflow-quorum-decision-recording-verdict-vocabulary.md) +- [Workflow quorum decision recording](requirements/workflow-quorum-decision-recording.md) +- [Conditional Workflow Session Settings](requirements/workflow-session-settings.md) +- [Workflow Settings Autosave](requirements/workflow-settings-autosave.md) +- [Workflow Step Agent Start Ownership](requirements/workflow-step-agent-start-ownership.md) +- [Workflow Sync — Per-User Workspace Authorization](requirements/workflow-sync-workspace-authz.md) +- [Workflow task-step transition ledger scenarios](requirements/workflow-task-step-transition-ledger-scenarios.md) +- [Workflow task-step transition ledger](requirements/workflow-task-step-transition-ledger.md) +- [Human Assignee and Actor Attribution](requirements/human-assignee.md) + +### System design + +- [Initial task brief](system-design/initial-task-brief.md) + +- [Task completion, conversation follow-ups, and workspace access](system-design/task-completion.md) + +- [Additional Session Workspace Reuse](system-design/additional-session-workspace-reuse.md) +- [Environment-Owned Git Status](system-design/environment-owned-git-status.md) +- [Attach Workspace Sources](system-design/attach-workspace-sources.md) +- [Command-panel archived task results](system-design/command-panel-archived-task-results.md) +- [Detached Workspace Continuity](system-design/detached-workspace-continuity.md) +- [Quick Chat Agent Titles](system-design/quick-chat-agent-titles.md) +- [Quick Chat Session Resumption](system-design/quick-chat-session-resumption.md) +- [Active clarification lifecycle](system-design/clarification-active-lifecycle.md) +- [Clarification response reliability](system-design/clarification-response-reliability.md) +- [External task ID idempotency operations](system-design/external-id-idempotency-operations.md) +- [External task ID idempotency](system-design/external-id-idempotency.md) +- [Task model unification](system-design/model-unification.md) +- [MCP Tool Name Stability](system-design/mcp-tool-name-stability.md) +- [MCP workspace modes](system-design/mcp-workspace-mode.md) +- [Remote Contribution Tasks](system-design/remote-contribution-tasks.md) +- [Passthrough Queued Prompt Dispatch](system-design/passthrough-queued-prompt-dispatch.md) +- [Saved Prompt Delivery](system-design/saved-prompt-delivery.md) +- [Passthrough Initial Prompt Turn Boundary](system-design/passthrough-initial-prompt-turn-boundary.md) +- [Prompt attachments](system-design/prompt-attachments.md) +- [Task Actions Menu on Preview and Detail Surfaces](system-design/task-actions-menu.md) +- [Task menu grouping](system-design/task-menu-grouping.md) +- [Task Archive Confirmation](system-design/archive-confirmation.md) +- [Task removal navigation](system-design/removal-navigation.md) +- [Task plan content size limit](system-design/plan-content-size-limit.md) +- [Task Plan Comments](system-design/plan-comments.md) +- [Task plan write consistency](system-design/plan-write-consistency.md) +- [Task plan write lifecycle](system-design/plan-write-lifecycle.md) +- [Task plan append-mode write](system-design/plan-write-append-mode.md) +- [Task plan append-mode agent text](system-design/plan-write-append-mode-agent-text.md) +- [Task Runtime Cleanup](system-design/runtime-cleanup.md) +- [Task Cleanup Preparation](system-design/runtime-cleanup-preparation.md) +- [Task Terminal Persistence](system-design/task-terminal-persistence.md) +- [Runtime Task-State Publication Order](system-design/runtime-state-publication-order.md) +- [Queued Run Scheduling](system-design/run-scheduling.md) +- [Resume prompt queue](system-design/resume-prompt-queue.md) +- [Session Delete Preserves Task Workspaces](system-design/session-delete-resource-cleanup.md) +- [Task Dependencies and Auto-Start Chains](system-design/task-dependencies.md) +- [Edit task dependencies](system-design/task-dependency-detail-editing.md) +- [Session Launch Repository Resolution](system-design/launch-repository-resolution.md) +- [Task Launch Failure Recovery](system-design/task-launch-failure-recovery.md) +- [Worktree metadata recovery](system-design/worktree-metadata-recovery.md) +- [Task Create Launch Preview](system-design/task-create-launch-preview.md) +- [Task priority visibility](system-design/task-priority-visibility.md) +- [Threads task actions](system-design/threads-task-actions.md) +- [WIP Limits and Visible Overflow Queues](system-design/wip-limit-pull-system.md) +- [Workflow completion-signal payload delivery](system-design/workflow-completion-signal-payload-delivery.md) +- [Signal-Gated Manual Move Visibility](system-design/workflow-signal-gated-manual-move-visibility.md) +- [Workflow quorum decision recording](system-design/workflow-quorum-decision-recording.md) +- [Workflow Step Agent Start Ownership](system-design/workflow-step-agent-start-ownership.md) +- [Workflow Step Fixed-Profile Routing](system-design/workflow-step-fixed-profile-routing.md) +- [Workflow Profile Session Lifecycle and Recipients](system-design/workflow-profile-session-lifecycle.md) +- [Workflow task-step transition ledger](system-design/workflow-task-step-transition-ledger.md) +- [Human Assignee](system-design/human-assignee.md) +- [Task Create Agent Compatibility Recovery](system-design/task-create-agent-executor-compatibility.md) + ## Migration record -Migration remains in progress. Use the catalog command to find the current -requirement and system-design documents for this system. Other migrated files -still need the same extraction before this system can return to a complete -migration state. +Migration remains in progress. Files that retain migration wrappers still need +extraction before this system can return to a complete migration state. ## Related systems diff --git a/docs/specs/tasks/requirements/initial-task-brief.md b/docs/specs/tasks/requirements/initial-task-brief.md new file mode 100644 index 0000000000..54960cf7b3 --- /dev/null +++ b/docs/specs/tasks/requirements/initial-task-brief.md @@ -0,0 +1,62 @@ +--- +status: active +system: tasks +created: 2026-09-12 +updated: 2026-09-12 +owners: + - kandev +--- + +# Initial task brief requirements + +## Overview + +A message sent before an ordinary task session starts must preserve the task +brief. The message supplements that brief unless its instructions explicitly +replace the objective. Both texts remain available to the user and agent. + +The task system owns first-message admission and delivery. The existing UI +transcript contract owns history windows and the synthetic description row. + +## Terms + +- **Brief:** The task description at first-message acceptance. +- **Eligible session:** A never-prompted, CREATED ordinary task session. + Office, ephemeral Quick Chat, and configuration sessions are excluded. +- **Additional instruction:** A direct user message that starts an eligible session. + +## Requirements + +### REQ-TASKS-INITIAL-TASK-BRIEF-001: Initial task brief preservation + +**Intent:** Preparatory instructions retain the objective, scope, and constraints +that the task already contains. + +#### Acceptance criteria + +- **AC-TASKS-INITIAL-TASK-BRIEF-001.1:** When an additional instruction starts an eligible session, its first agent prompt shall include the nonempty brief and instruction. +- **AC-TASKS-INITIAL-TASK-BRIEF-001.2:** The first stored user prompt shall retain both texts in order: brief, then additional instruction. Reloading shall preserve both texts. +- **AC-TASKS-INITIAL-TASK-BRIEF-001.3:** When a later message arrives, the system shall not automatically repeat the brief. Message deletion or backend restart shall not restore eligibility. +- **AC-TASKS-INITIAL-TASK-BRIEF-001.4:** When the instruction equals the brief after outer whitespace removal, the system shall include that text once. An empty brief adds nothing. +- **AC-TASKS-INITIAL-TASK-BRIEF-001.5:** Concurrent first-message submissions shall not both add the brief. Retrying an accepted message identity shall return its original saved content without another dispatch. +- **AC-TASKS-INITIAL-TASK-BRIEF-001.6:** When first-message persistence fails, the system shall not dispatch that message. A later retry shall remain eligible if no competing prompt won admission. +- **AC-TASKS-INITIAL-TASK-BRIEF-001.7:** Desktop and phone Chat shall show the preserved first prompt once, through existing history navigation, including after reload. +- **AC-TASKS-INITIAL-TASK-BRIEF-001.8:** Structured and passthrough sessions shall preserve the same visible brief and instruction. Existing attachment and saved-prompt delivery rules shall remain applicable. +- **AC-TASKS-INITIAL-TASK-BRIEF-001.9:** Office, Quick Chat, configuration, and previously prompted sessions shall retain their existing context rules. Automatic workflow entry shall retain its fallback rules. +- **AC-TASKS-INITIAL-TASK-BRIEF-001.10:** An explicit replacement instruction shall remain after the brief, preserving its precedence. The system shall not infer replacement from preparatory wording. + +## Exclusions + +- Rewriting old conversations or recovering previously omitted prompts automatically. +- Changing task descriptions when users send messages. +- New replacement controls or natural-language intent classification. +- Changes to workflow move instructions, context reset, or automatic entry policy. +- Treating display-only launch previews as authorized agent input. + +## System design + +[Initial task brief](../system-design/initial-task-brief.md) + +## Implementation plans + +[Initial task brief fix package](../../../plans/initial-task-brief/plan.md) diff --git a/docs/specs/tasks/system-design/initial-task-brief.md b/docs/specs/tasks/system-design/initial-task-brief.md new file mode 100644 index 0000000000..2836a6415b --- /dev/null +++ b/docs/specs/tasks/system-design/initial-task-brief.md @@ -0,0 +1,126 @@ +--- +status: current +system: tasks +requirements: + - REQ-TASKS-INITIAL-TASK-BRIEF-001 +created: 2026-09-12 +updated: 2026-09-12 +owners: + - kandev +--- + +# Initial task brief system design + +## Boundary and mapping + +The task service owns accepted prompt content. The repository selects initial +content inside its existing session write transaction. The handler dispatches +the committed result. This extends the existing first-prompt boundary. + +| Requirement | Design sections | +| --- | --- | +| `REQ-TASKS-INITIAL-TASK-BRIEF-001` | Admission, Composition and delivery, Transcript, Failure and compatibility | + +## Admission + +`MessageHandlers.wsAddMessage` already resolves session changes after +`ProcessOnTurnStart`. Eligibility belongs to that resolved session, including +a CREATED source redirected to a fresh workflow session. The service verifies +task/session ownership and excludes Office, ephemeral, and configuration contexts. + +Use a server-only admission option on `CreateMessageRequest`. It carries a +prepared initial-content candidate and its corresponding trusted prompt context. +This option is not a public request field and is never accepted from client metadata. +Existing callers without the option keep their current behavior. + +Select between ordinary and initial candidates inside the existing repository +user-message transaction, before `assignUserMessageBoundary` allocates an ordinal. +Read `task_session_prompt_seq` with the same transaction and session write lock. +An existing row, including `last_seq = 0`, means that another prompt owns the +first boundary. Do not use CREATED state, transcript length, title ownership, +or the eventual ordinal alone as proof of eligibility. + +Extend the shared boundary used by ordinary, idempotent, plan-comment, and +queued plan-comment message writes. Keep candidate selection, message insertion, +ordinal allocation, comment consumption, and queue insertion atomic where those +operations are already atomic. Preserve SQLite writer serialization and the +PostgreSQL session advisory lock. A new counter table or migration is unnecessary. + +The candidate uses a task-description snapshot read from authorized task data. +Validate that snapshot against the task row during final admission. A concurrent +description edit rejects stale admission for recomposition, rather than saving +an unintended stale brief. Do not run saved-prompt lookups inside the write transaction. + +Return the selected content and selected trusted-context identity to the service. +The same selection determines persistence, queue content, and agent dispatch. +On rollback, restore caller-visible content and metadata as well as timestamps +and prompt ordinal. Otherwise retries can retain a failed selection. + +## Composition and delivery + +For an eligible candidate, put the trimmed brief before the additional +instruction, separated by two newlines. Exact equality after outer trimming +produces one copy. Empty descriptions use the ordinary candidate. + +Prepare both candidates through the existing server-owned saved-prompt and +system-context pipeline before admission. Resolve title ownership, canvas +capabilities, and session transitions once, then reuse their canonical snapshots. Keep each candidate paired with its +trusted expansion. Fingerprint the original request before either transform. +An idempotent replay returns the saved winner even after the description changes. + +`wsAddMessage` already assigns `req.Content = message.Content` before dispatch. +Extend that handoff to use the selected trusted context. Do not independently +prepend the brief inside `StartCreatedSession`, `startTask`, or the browser. + +The real created-session launch must preserve both visible sections through +workflow composition. Compose the applicable workflow instructions once and +retain the accepted direct content as user input. If a replacing step template +would discard this content, append the direct content after that template and +use the existing composed-prompt launch option to prevent a second replacement. +Keep automatic workflow-entry composition unchanged. Test empty step prompts, +`{{task_prompt}}`, and nonempty templates without that placeholder. + +The saved user row and final dispatch must contain the same visible brief and +instruction. Existing canonical system-context additions retain their authority. +Passthrough delivery retains visible text without hidden saved-prompt expansion. +Keep submitted attachments, entity references, and plan-comment claims intact. +`initial_prompt_preview` remains display-only and cannot supply prompt authority. + +## Transcript + +Keep `useProcessedMessages` and `shouldShowTaskDescriptionFallback` eligibility +unchanged. Before admission, the synthetic description row represents the brief. +After admission, prompt #1 replaces it and contains the brief plus instruction. +No synthetic historical prompt, extra ordinal, or permanently pinned banner is needed. + +Reuse the existing transcript and Prompt History renderers. The nearest mobile +exemplar is `components/task/task-layout.tsx` and its `SessionMobileLayout`. +Phone Chat remains a full-height destination with one transcript scroll owner. +Composer position, safe-area handling, touch targets, and navigation remain unchanged. +Long first prompts remain reachable through existing prompt expansion/history controls. + +## Failure and compatibility + +A history, snapshot, or persistence error stops admission before dispatch. +Existing launch-error behavior handles failures after message acceptance. This +package does not promise provider-level exactly-once delivery after a process crash. + +Race tests must include a direct request competing with +`ClaimInitialPromptFallback`, two distinct direct messages, and a same-ID retry. +A zero-valued fallback reservation suppresses direct brief insertion. +Deleted user rows cannot make an existing prompt-counter row eligible again. + +Existing launch, workflow-entry, and Office callers do not opt into this direct +admission option. No public API field, database migration, or new UI control is needed. +This design records a local extension of existing ownership, so no separate ADR is required. + +## Related contracts + +- [Workflow first-prompt ownership](workflow-step-agent-start-ownership.md#prompt-history-contract) +- [Saved-prompt delivery](saved-prompt-delivery.md) +- [Transcript history visibility](../../ui/system-design/task-prompt-transcript-visibility.md) +- [Server-owned saved-prompt expansion](../../../decisions/2026-09-01-server-owned-saved-prompt-expansion.md) + +## Implementation plans + +[Initial task brief fix package](../../../plans/initial-task-brief/plan.md)