fix(chat): preserve saved prompt context in Quick Chat (#3251)
This commit is contained in:
@@ -29,6 +29,13 @@ const overloaded529Message = "Internal error: API Error: 529 Overloaded. This is
|
||||
var (
|
||||
overloadedCmdRe = regexp.MustCompile(`(?i)^/(?:e2e:)?overloaded(?::(\d+))?$`)
|
||||
changesWalkthroughPromptRefRe = regexp.MustCompile(`^@changes-walkthrough(?:\s|$)`)
|
||||
savedPromptDeliveryBlockRe = regexp.MustCompile(
|
||||
regexp.QuoteMeta("<kandev-system>EXPANDED PROMPT REFERENCES:") +
|
||||
`[\s\S]*?</kandev-system>`,
|
||||
)
|
||||
savedPromptDeliveryDirectiveRe = regexp.MustCompile(
|
||||
`(?m)^` + regexp.QuoteMeta(savedPromptDeliveryDirective) + `(?:\r?\n|</kandev-system>)`,
|
||||
)
|
||||
)
|
||||
|
||||
const changesWalkthroughPromptMarker = "Please create an agent-authored walkthrough of the current changes"
|
||||
@@ -42,6 +49,19 @@ func isChangesWalkthroughRequest(prompt string) bool {
|
||||
return legacyPrompt || promptReference
|
||||
}
|
||||
|
||||
// parseSavedPromptDeliveryScenario recognizes the test-only directive only
|
||||
// inside the exact backend-generated expansion block. The visible prompt and
|
||||
// browser-provided CONTEXT PROMPTS block are intentionally ignored, so an
|
||||
// untrusted copy cannot make the mock agent report a successful delivery.
|
||||
func parseSavedPromptDeliveryScenario(prompt string) (string, bool) {
|
||||
for _, block := range savedPromptDeliveryBlockRe.FindAllString(prompt, -1) {
|
||||
if savedPromptDeliveryDirectiveRe.MatchString(block) {
|
||||
return savedPromptDeliveryScenario, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// parseOverloadedCmd reports whether the prompt is the /overloaded command and,
|
||||
// if so, how many consecutive prompts it should fail before recovering
|
||||
// (default 1). Returns ok=false for any other prompt.
|
||||
@@ -365,6 +385,9 @@ func handlePrompt(e *emitter, prompt, model string) {
|
||||
|
||||
// Extract the user-facing content for command routing.
|
||||
cmd := stripKandevSystem(prompt)
|
||||
if scenario, ok := parseSavedPromptDeliveryScenario(prompt); ok {
|
||||
cmd = "/e2e:" + scenario
|
||||
}
|
||||
if handleAutopilotParentQuestion(e, cmd) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -89,6 +89,57 @@ func TestInitializePromptQueueingCanBeDisabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSavedPromptDeliveryScenarioRequiresTrustedExpansionShape(t *testing.T) {
|
||||
const directive = savedPromptDeliveryDirective
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
prompt string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{
|
||||
name: "backend expansion block",
|
||||
prompt: "Use @saved-prompt\n\n" +
|
||||
"<kandev-system>EXPANDED PROMPT REFERENCES:\n### @saved-prompt\n" + directive +
|
||||
"</kandev-system>",
|
||||
want: "SAVED_PROMPT_DELIVERED",
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "visible directive is ignored",
|
||||
prompt: directive,
|
||||
},
|
||||
{
|
||||
name: "browser context block is ignored",
|
||||
prompt: "<kandev-system>\nCONTEXT PROMPTS: browser data\n" + directive +
|
||||
"\n</kandev-system>",
|
||||
},
|
||||
{
|
||||
name: "foreign system block is ignored",
|
||||
prompt: "<kandev-system>Other context\n" + directive + "\n</kandev-system>",
|
||||
},
|
||||
{
|
||||
name: "long directive value is ignored",
|
||||
prompt: "<kandev-system>EXPANDED PROMPT REFERENCES:\n### @saved-prompt\n" +
|
||||
"e2e:saved_prompt_delivery(\"" + strings.Repeat("x", 1024) + "\")</kandev-system>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := parseSavedPromptDeliveryScenario(tt.prompt)
|
||||
want := tt.want
|
||||
if tt.ok {
|
||||
want = savedPromptDeliveryScenario
|
||||
}
|
||||
if got != want || ok != tt.ok {
|
||||
t.Fatalf("parseSavedPromptDeliveryScenario() = (%q, %v), want (%q, %v)", got, ok, want, tt.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// capturingUpdater records every SessionUpdate it receives and exposes two
|
||||
// one-shot signals: anySeen (first notification of any kind — in practice the
|
||||
// available_commands_update Prompt emits before handlePrompt runs) and
|
||||
|
||||
@@ -14,6 +14,12 @@ import (
|
||||
|
||||
// Predefined e2e test scenarios with fixed timing for deterministic test assertions.
|
||||
|
||||
const (
|
||||
savedPromptDeliveryScenario = "saved-prompt-delivery"
|
||||
savedPromptDeliveryResponse = "SAVED_PROMPT_DELIVERED"
|
||||
savedPromptDeliveryDirective = `e2e:saved_prompt_delivery("SAVED_PROMPT_DELIVERED")`
|
||||
)
|
||||
|
||||
// scenarioRegistry maps scenario names to their handler functions.
|
||||
var scenarioRegistry = map[string]func(e *emitter){
|
||||
"simple-message": scenarioSimpleMessage,
|
||||
@@ -47,6 +53,7 @@ var scenarioRegistry = map[string]func(e *emitter){
|
||||
"push-current-branch": scenarioPushCurrentBranch,
|
||||
"steer-fold-setup": scenarioSteerFoldSetup,
|
||||
"steer-defer-setup": scenarioSteerDeferSetup,
|
||||
"saved-prompt-delivery": scenarioSavedPromptDelivery,
|
||||
}
|
||||
|
||||
// steerSetupHoldMillis is how long steer-fold-setup and steer-defer-setup
|
||||
@@ -98,6 +105,13 @@ func scenarioEmptyTurn(e *emitter) {
|
||||
fixedDelay(3000)
|
||||
}
|
||||
|
||||
// scenarioSavedPromptDelivery emits the fixed response used by the Quick Chat
|
||||
// saved-prompt E2E test. The handler routes here only after it finds the exact
|
||||
// directive inside a backend-generated expansion block.
|
||||
func scenarioSavedPromptDelivery(e *emitter) {
|
||||
e.text(savedPromptDeliveryResponse)
|
||||
}
|
||||
|
||||
// emitPredefinedScenario dispatches to a named e2e scenario.
|
||||
func emitPredefinedScenario(e *emitter, name string) {
|
||||
if fn, ok := scenarioRegistry[name]; ok {
|
||||
|
||||
@@ -822,6 +822,28 @@ func (w *orchestratorWrapper) StartCreatedSession(ctx context.Context, taskID, s
|
||||
return err
|
||||
}
|
||||
|
||||
// PrepareDirectPrompt forwards the backend-owned direct-message preparation
|
||||
// seam used by the WebSocket message handler.
|
||||
func (w *orchestratorWrapper) PrepareDirectPrompt(ctx context.Context, prompt string, isPassthrough bool) (string, string) {
|
||||
return w.svc.PrepareDirectPrompt(ctx, prompt, isPassthrough)
|
||||
}
|
||||
|
||||
// StartCreatedSessionWithPromptContext forwards direct-message startup while
|
||||
// preserving the trusted saved-prompt context through the second canonicalizer.
|
||||
func (w *orchestratorWrapper) StartCreatedSessionWithPromptContext(
|
||||
ctx context.Context,
|
||||
taskID, sessionID, agentProfileID, prompt string,
|
||||
skipMessageRecord, planMode, autoStart bool,
|
||||
attachments []v1.MessageAttachment,
|
||||
references []v1.EntityReference,
|
||||
promptReferenceContext string,
|
||||
) (*executor.TaskExecution, error) {
|
||||
return w.svc.StartCreatedSessionWithPromptContext(
|
||||
ctx, taskID, sessionID, agentProfileID, prompt,
|
||||
skipMessageRecord, planMode, autoStart, attachments, references, promptReferenceContext,
|
||||
)
|
||||
}
|
||||
|
||||
type githubTaskIssueStoreAdapter struct {
|
||||
svc *taskservice.Service
|
||||
}
|
||||
|
||||
@@ -239,6 +239,27 @@ type PromptReferenceExpander interface {
|
||||
) (expandedPrompt, trustedContext string)
|
||||
}
|
||||
|
||||
// DirectPromptPreparer canonicalizes a user-submitted structured prompt before
|
||||
// the task service persists it. The returned trusted context is the exact
|
||||
// server-generated content that may be preserved by system-context
|
||||
// canonicalization.
|
||||
type DirectPromptPreparer interface {
|
||||
PrepareDirectPrompt(ctx context.Context, prompt string, isPassthrough bool) (string, string)
|
||||
}
|
||||
|
||||
// DirectPromptStarter starts a prepared direct-message session while retaining
|
||||
// the trusted saved-prompt context prepared before message persistence.
|
||||
type DirectPromptStarter interface {
|
||||
StartCreatedSessionWithPromptContext(
|
||||
ctx context.Context,
|
||||
taskID, sessionID, agentProfileID, prompt string,
|
||||
skipMessageRecord, planMode, autoStart bool,
|
||||
attachments []v1.MessageAttachment,
|
||||
references []v1.EntityReference,
|
||||
promptReferenceContext string,
|
||||
) (*executor.TaskExecution, error)
|
||||
}
|
||||
|
||||
// repoStore is the repository interface accepted by NewService.
|
||||
// It covers both the orchestrator's own needs (sessionExecutorStore) and
|
||||
// the executor package's needs (executor.executorStore).
|
||||
|
||||
@@ -428,6 +428,39 @@ func (s *Service) StartCreatedSession(
|
||||
skipMessageRecord, planMode, autoStart bool,
|
||||
attachments []v1.MessageAttachment,
|
||||
references []v1.EntityReference,
|
||||
) (*executor.TaskExecution, error) {
|
||||
return s.startCreatedSession(
|
||||
ctx, taskID, sessionID, agentProfileID, prompt,
|
||||
skipMessageRecord, planMode, autoStart, attachments, references, "",
|
||||
)
|
||||
}
|
||||
|
||||
// StartCreatedSessionWithPromptContext starts a prepared session while
|
||||
// preserving the exact server-generated saved-prompt context prepared by the
|
||||
// message handler. Direct first turns need this narrow seam because the
|
||||
// session-start path canonicalizes the prompt again before dispatch.
|
||||
func (s *Service) StartCreatedSessionWithPromptContext(
|
||||
ctx context.Context,
|
||||
taskID, sessionID, agentProfileID, prompt string,
|
||||
skipMessageRecord, planMode, autoStart bool,
|
||||
attachments []v1.MessageAttachment,
|
||||
references []v1.EntityReference,
|
||||
promptReferenceContext string,
|
||||
) (*executor.TaskExecution, error) {
|
||||
return s.startCreatedSession(
|
||||
ctx, taskID, sessionID, agentProfileID, prompt,
|
||||
skipMessageRecord, planMode, autoStart, attachments, references, promptReferenceContext,
|
||||
)
|
||||
}
|
||||
|
||||
//nolint:cyclop,funlen,gocognit // Existing complexity inherited from session-lifecycle handling.
|
||||
func (s *Service) startCreatedSession(
|
||||
ctx context.Context,
|
||||
taskID, sessionID, agentProfileID, prompt string,
|
||||
skipMessageRecord, planMode, autoStart bool,
|
||||
attachments []v1.MessageAttachment,
|
||||
references []v1.EntityReference,
|
||||
promptReferenceContext string,
|
||||
) (*executor.TaskExecution, error) {
|
||||
releaseLifecycleLock := s.acquireSessionLifecycleLock(sessionID)
|
||||
defer releaseLifecycleLock()
|
||||
@@ -600,10 +633,16 @@ func (s *Service) StartCreatedSession(
|
||||
return nil, fmt.Errorf("failed to claim first-turn task title: %w", err)
|
||||
}
|
||||
}
|
||||
effectivePrompt, planModeActive, promptReferenceContext := s.applyWorkflowAndPlanMode(
|
||||
effectivePrompt, planModeActive, generatedPromptReferenceContext := s.applyWorkflowAndPlanModeWithPromptContext(
|
||||
ctx, effectivePrompt, taskID, sessionID, dbTask.WorkflowStepID,
|
||||
planMode, task.IsEphemeral, session.IsPassthrough,
|
||||
planMode, task.IsEphemeral, session.IsPassthrough, promptReferenceContext,
|
||||
)
|
||||
if generatedPromptReferenceContext != "" {
|
||||
// The workflow helper preserves a direct message's acceptance-time
|
||||
// context. Auto-started workflow prompts have no prior context, so their
|
||||
// launch-time expansion becomes the trusted value here.
|
||||
promptReferenceContext = generatedPromptReferenceContext
|
||||
}
|
||||
|
||||
// Inject config context for config-mode sessions (dedicated settings chat)
|
||||
if configMode {
|
||||
@@ -1763,7 +1802,38 @@ func (s *Service) postLaunchStart(ctx context.Context, taskID string, execution
|
||||
// applyWorkflowAndPlanMode applies workflow step configuration and plan mode injection to a prompt.
|
||||
// Returns the effective prompt and whether plan mode is active (from either the step or the caller).
|
||||
// For ephemeral tasks (quick chat), workflow step processing is skipped since they have no workflow.
|
||||
func (s *Service) applyWorkflowAndPlanMode(ctx context.Context, prompt string, taskID string, sessionID string, workflowStepID string, planMode bool, isEphemeral bool, isPassthrough bool) (string, bool, string) {
|
||||
func (s *Service) applyWorkflowAndPlanMode(
|
||||
ctx context.Context,
|
||||
prompt string,
|
||||
taskID string,
|
||||
sessionID string,
|
||||
workflowStepID string,
|
||||
planMode bool,
|
||||
isEphemeral bool,
|
||||
isPassthrough bool,
|
||||
) (string, bool, string) {
|
||||
return s.applyWorkflowAndPlanModeWithPromptContext(
|
||||
ctx, prompt, taskID, sessionID, workflowStepID, planMode,
|
||||
isEphemeral, isPassthrough, "",
|
||||
)
|
||||
}
|
||||
|
||||
// applyWorkflowAndPlanModeWithPromptContext composes a workflow prompt while
|
||||
// preserving a direct message's acceptance-time saved-prompt expansion. A
|
||||
// direct message already has canonical reference content, so resolving it
|
||||
// again would make persistence and ACP dispatch depend on mutable prompt
|
||||
// records. Workflow-only prompts still use the normal expansion path.
|
||||
func (s *Service) applyWorkflowAndPlanModeWithPromptContext(
|
||||
ctx context.Context,
|
||||
prompt string,
|
||||
taskID string,
|
||||
sessionID string,
|
||||
workflowStepID string,
|
||||
planMode bool,
|
||||
isEphemeral bool,
|
||||
isPassthrough bool,
|
||||
trustedPromptContext string,
|
||||
) (string, bool, string) {
|
||||
effectivePrompt := prompt
|
||||
promptReferenceContext := ""
|
||||
|
||||
@@ -1777,9 +1847,15 @@ func (s *Service) applyWorkflowAndPlanMode(ctx context.Context, prompt string, t
|
||||
zap.Error(err))
|
||||
} else {
|
||||
stepHasPlanMode = step.HasOnEnterAction(wfmodels.OnEnterEnablePlanMode)
|
||||
effectivePrompt, promptReferenceContext = s.buildWorkflowPromptWithContext(
|
||||
ctx, effectivePrompt, step, taskID, sessionID, isPassthrough,
|
||||
)
|
||||
if trustedPromptContext != "" {
|
||||
effectivePrompt, promptReferenceContext = s.buildWorkflowPromptWithTrustedContext(
|
||||
ctx, effectivePrompt, step, taskID, sessionID, isPassthrough, trustedPromptContext,
|
||||
)
|
||||
} else {
|
||||
effectivePrompt, promptReferenceContext = s.buildWorkflowPromptWithContext(
|
||||
ctx, effectivePrompt, step, taskID, sessionID, isPassthrough,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1863,6 +1939,24 @@ func (s *Service) buildWorkflowPromptWithContext(
|
||||
taskID string,
|
||||
sessionID string,
|
||||
isPassthrough bool,
|
||||
) (string, string) {
|
||||
return s.buildWorkflowPromptWithTrustedContext(
|
||||
ctx, basePrompt, step, taskID, sessionID, isPassthrough, "",
|
||||
)
|
||||
}
|
||||
|
||||
// buildWorkflowPromptWithTrustedContext composes a workflow prompt without
|
||||
// re-expanding an already canonical direct message. If the workflow step
|
||||
// replaces the base prompt, it restores the exact trusted block so the
|
||||
// accepted saved-prompt context remains available to the agent.
|
||||
func (s *Service) buildWorkflowPromptWithTrustedContext(
|
||||
ctx context.Context,
|
||||
basePrompt string,
|
||||
step *wfmodels.WorkflowStep,
|
||||
taskID string,
|
||||
sessionID string,
|
||||
isPassthrough bool,
|
||||
trustedPromptContext string,
|
||||
) (string, string) {
|
||||
_ = sessionID
|
||||
var parts []string
|
||||
@@ -1888,6 +1982,13 @@ func (s *Service) buildWorkflowPromptWithContext(
|
||||
}
|
||||
|
||||
joined := strings.Join(parts, "\n\n")
|
||||
if trustedPromptContext != "" {
|
||||
trustedBlock := sysprompt.Wrap(trustedPromptContext)
|
||||
if !strings.Contains(joined, trustedBlock) {
|
||||
joined += "\n\n" + trustedBlock
|
||||
}
|
||||
return joined, trustedPromptContext
|
||||
}
|
||||
return s.expandPromptReferencesWithContext(ctx, joined, isPassthrough)
|
||||
}
|
||||
|
||||
@@ -1951,6 +2052,18 @@ func (s *Service) expandPromptReferencesWithContext(
|
||||
return s.promptExpander.AppendReferenceExpansionsWithContext(ctx, prompt, zapLogger)
|
||||
}
|
||||
|
||||
// PrepareDirectPrompt applies the same backend-owned saved-prompt expansion
|
||||
// used by workflow prompts to a direct user message. Message handlers call it
|
||||
// before persistence so the stored content and the first dispatched prompt
|
||||
// share one canonical representation.
|
||||
func (s *Service) PrepareDirectPrompt(
|
||||
ctx context.Context,
|
||||
prompt string,
|
||||
isPassthrough bool,
|
||||
) (string, string) {
|
||||
return s.expandPromptReferencesWithContext(ctx, prompt, isPassthrough)
|
||||
}
|
||||
|
||||
// ResumeTaskSession restarts a specific task session using its stored worktree.
|
||||
func (s *Service) ResumeTaskSession(ctx context.Context, taskID, sessionID string) (*executor.TaskExecution, error) {
|
||||
return s.ResumeTaskSessionWithOptions(ctx, taskID, sessionID, executor.ResumeOptions{})
|
||||
|
||||
@@ -411,6 +411,41 @@ func TestApplyWorkflowAndPlanMode_PassthroughSkipsReferenceExpansion(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyWorkflowAndPlanMode_PreservesAcceptanceTimePromptContext(t *testing.T) {
|
||||
repo := setupTestRepo(t)
|
||||
stepGetter := newMockStepGetter()
|
||||
stepGetter.steps["step-1"] = &wfmodels.WorkflowStep{
|
||||
ID: "step-1",
|
||||
Prompt: "Implement this exactly:\n\n{{task_prompt}}",
|
||||
}
|
||||
svc := createTestService(repo, stepGetter, newMockTaskRepo())
|
||||
expander := &fakePromptReferenceExpander{}
|
||||
svc.promptExpander = expander
|
||||
|
||||
basePrompt := "Use @my-prompt.\n\n" + sysprompt.Wrap(fakeResolvedPromptReferenceContext)
|
||||
got, _, trustedContext := svc.applyWorkflowAndPlanModeWithPromptContext(
|
||||
context.Background(),
|
||||
basePrompt,
|
||||
"task-1",
|
||||
"session-1",
|
||||
"step-1",
|
||||
false,
|
||||
false, // isEphemeral
|
||||
false, // isPassthrough
|
||||
fakeResolvedPromptReferenceContext,
|
||||
)
|
||||
|
||||
if trustedContext != fakeResolvedPromptReferenceContext {
|
||||
t.Fatalf("trusted context = %q, want acceptance-time context %q", trustedContext, fakeResolvedPromptReferenceContext)
|
||||
}
|
||||
if strings.Count(got, sysprompt.Wrap(fakeResolvedPromptReferenceContext)) != 1 {
|
||||
t.Fatalf("expected one acceptance-time expansion block, got %q", got)
|
||||
}
|
||||
if len(expander.calls) != 0 {
|
||||
t.Fatalf("expected canonical direct prompt not to be re-expanded, got %d calls", len(expander.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWorkflowMeta_CachesPerRequest(t *testing.T) {
|
||||
stepGetter := newMockStepGetter()
|
||||
stepGetter.workflowAgentProfileID = "profile-wf"
|
||||
|
||||
@@ -18,6 +18,8 @@ import (
|
||||
// re-scan the block and append a duplicate one below it.
|
||||
const expansionMarker = "EXPANDED PROMPT REFERENCES:"
|
||||
|
||||
const browserPromptContextMarker = "CONTEXT PROMPTS:"
|
||||
|
||||
// expansionBlockPrefix is the exact, literal prefix that
|
||||
// FormatPromptReferenceExpansions' output has once wrapped by sysprompt.Wrap:
|
||||
// the opening <kandev-system> tag immediately followed by expansionMarker,
|
||||
@@ -32,14 +34,32 @@ var expansionBlockRegex = regexp.MustCompile(
|
||||
regexp.QuoteMeta(expansionBlockPrefix) + `[\s\S]*?` + regexp.QuoteMeta(sysprompt.TagEnd) + `\s*`,
|
||||
)
|
||||
|
||||
// browserPromptContextBlockRegex matches the context block emitted by the
|
||||
// browser when it attaches saved prompts. Browser-provided definitions are
|
||||
// compatibility metadata only; the backend replaces them with the current
|
||||
// saved prompt record before the message is persisted or dispatched.
|
||||
var browserPromptContextBlockRegex = regexp.MustCompile(
|
||||
regexp.QuoteMeta(sysprompt.TagStart) + `\s*` + regexp.QuoteMeta(browserPromptContextMarker) +
|
||||
`[\s\S]*?` + regexp.QuoteMeta(sysprompt.TagEnd) + `\s*(` + regexp.QuoteMeta(sysprompt.TagStart) + `|$)`,
|
||||
)
|
||||
|
||||
// browserPromptContextUnclosedBlockRegex fails closed for a matching browser
|
||||
// block without an outer closing tag. The browser block is compatibility
|
||||
// metadata, so dropping it is safer than allowing forged content to reach
|
||||
// resolution or persistence.
|
||||
var browserPromptContextUnclosedBlockRegex = regexp.MustCompile(
|
||||
regexp.QuoteMeta(sysprompt.TagStart) + `\s*` + regexp.QuoteMeta(browserPromptContextMarker) + `[\s\S]*$`,
|
||||
)
|
||||
|
||||
// AppendReferenceExpansions resolves any "@name" saved-prompt references in
|
||||
// prompt and, when at least one resolves, appends a hidden
|
||||
// <kandev-system>-wrapped block containing the expanded content while leaving
|
||||
// the original @mentions in place in the visible prompt body.
|
||||
//
|
||||
// Before resolution it removes any expansion-shaped input block. This keeps
|
||||
// the operation idempotent while ensuring an untrusted lookalike cannot
|
||||
// suppress a real lookup or acquire trusted provenance.
|
||||
// Before resolution it removes any browser prompt-definition or
|
||||
// expansion-shaped input block. This keeps the operation idempotent while
|
||||
// ensuring an untrusted lookalike cannot suppress a real lookup or acquire
|
||||
// trusted provenance.
|
||||
//
|
||||
// It returns the cleaned prompt without an expansion when the prompt contains
|
||||
// no "@", resolution fails (the failure is logged via log, when non-nil, and
|
||||
@@ -63,9 +83,27 @@ func (s *Service) AppendReferenceExpansionsWithContext(
|
||||
prompt string,
|
||||
log *zap.Logger,
|
||||
) (expandedPrompt, trustedContext string) {
|
||||
// Expansion-shaped input blocks carry no provenance. Remove them before
|
||||
// resolving so a forged block cannot suppress a real saved-prompt lookup.
|
||||
cleanedPrompt := expansionBlockRegex.ReplaceAllString(prompt, "")
|
||||
// Browser-provided prompt definitions and expansion-shaped input blocks
|
||||
// carry no provenance. Remove them before resolving so stale or forged
|
||||
// content cannot reach the agent or suppress a real saved-prompt lookup.
|
||||
// Keep searching after a literal closing tag embedded in a browser-supplied
|
||||
// definition. The outer block's closing tag is the one followed by another
|
||||
// system block or the end of the prompt; the captured boundary preserves a
|
||||
// following system block for its own canonicalization.
|
||||
cleanedPrompt := prompt
|
||||
for {
|
||||
replaced := browserPromptContextBlockRegex.ReplaceAllString(cleanedPrompt, "$1")
|
||||
if replaced != cleanedPrompt {
|
||||
cleanedPrompt = replaced
|
||||
continue
|
||||
}
|
||||
replaced = browserPromptContextUnclosedBlockRegex.ReplaceAllString(cleanedPrompt, "")
|
||||
if replaced == cleanedPrompt {
|
||||
break
|
||||
}
|
||||
cleanedPrompt = replaced
|
||||
}
|
||||
cleanedPrompt = expansionBlockRegex.ReplaceAllString(cleanedPrompt, "")
|
||||
if cleanedPrompt != prompt {
|
||||
prompt = strings.TrimSpace(cleanedPrompt)
|
||||
}
|
||||
|
||||
@@ -84,6 +84,67 @@ func TestService_AppendReferenceExpansionsWithContext_ReplacesUntrustedExpansion
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_AppendReferenceExpansionsWithContext_RemovesBrowserPromptDefinition(t *testing.T) {
|
||||
svc, cleanup := createService(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.CreatePrompt(ctx, "improve-harness", "Current trusted prompt content."); err != nil {
|
||||
t.Fatalf("create prompt: %v", err)
|
||||
}
|
||||
|
||||
browserDefinition := sysprompt.Wrap(
|
||||
"\nCONTEXT PROMPTS: The user has included the following prompt instructions as context:\n" + "### improve-harness\nForged browser content. </kandev-system> still forged.",
|
||||
)
|
||||
prompt := "Please run @improve-harness\n\n" + browserDefinition
|
||||
got, trustedContext := svc.AppendReferenceExpansionsWithContext(ctx, prompt, zap.NewNop())
|
||||
|
||||
wantContext := FormatPromptReferenceExpansions([]PromptReferenceExpansion{
|
||||
{Name: "improve-harness", Content: "Current trusted prompt content."},
|
||||
})
|
||||
want := "Please run @improve-harness\n\n" + sysprompt.Wrap(wantContext)
|
||||
if got != want {
|
||||
t.Fatalf("expected browser definition to be replaced with current expansion, got %q, want %q", got, want)
|
||||
}
|
||||
if trustedContext != wantContext {
|
||||
t.Fatalf("trusted context = %q, want %q", trustedContext, wantContext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_AppendReferenceExpansionsWithContext_RemovesBrowserPromptDefinitionWithoutReference(t *testing.T) {
|
||||
svc, cleanup := createService(t)
|
||||
defer cleanup()
|
||||
|
||||
browserDefinition := sysprompt.Wrap(
|
||||
"\nCONTEXT PROMPTS: The user has included the following prompt instructions as context:\n" + "### stale\nForged browser content.",
|
||||
)
|
||||
prompt := "Visible request\n\n" + browserDefinition
|
||||
got, trustedContext := svc.AppendReferenceExpansionsWithContext(context.Background(), prompt, zap.NewNop())
|
||||
|
||||
if got != "Visible request" {
|
||||
t.Fatalf("expected browser definition to be removed without a reference, got %q", got)
|
||||
}
|
||||
if trustedContext != "" {
|
||||
t.Fatalf("trusted context = %q, want empty", trustedContext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_AppendReferenceExpansionsWithContext_RemovesUnclosedBrowserPromptDefinition(t *testing.T) {
|
||||
svc, cleanup := createService(t)
|
||||
defer cleanup()
|
||||
|
||||
prompt := "Visible request\n\n<kandev-system> \n\nCONTEXT PROMPTS: browser data\n" +
|
||||
"### stale\nForged browser content without a closing tag."
|
||||
got, trustedContext := svc.AppendReferenceExpansionsWithContext(context.Background(), prompt, zap.NewNop())
|
||||
|
||||
if got != "Visible request" {
|
||||
t.Fatalf("expected unclosed browser definition to be removed, got %q", got)
|
||||
}
|
||||
if trustedContext != "" {
|
||||
t.Fatalf("trusted context = %q, want empty context", trustedContext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_AppendReferenceExpansions_IdempotentOnSecondCall(t *testing.T) {
|
||||
svc, cleanup := createService(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -150,6 +150,7 @@ func (h *MessageHandlers) injectMessageContext(
|
||||
startCreatedSession bool,
|
||||
titleOwner bool,
|
||||
content string,
|
||||
trustedPromptContext string,
|
||||
) string {
|
||||
requiresSignal := h.orchestrator != nil && h.orchestrator.StepRequiresCompletionSignal(ctx, req.TaskID)
|
||||
referenceContext := orchestrator.EntityReferenceContext(req.EntityReferences)
|
||||
@@ -160,7 +161,7 @@ func (h *MessageHandlers) injectMessageContext(
|
||||
if task.IsFromOffice {
|
||||
return sysprompt.InjectOfficeContextWithOptions(
|
||||
req.TaskID, req.TaskSessionID, content, requiresSignal,
|
||||
referenceContext, pullRequestTargetContext,
|
||||
referenceContext, trustedPromptContext, pullRequestTargetContext,
|
||||
)
|
||||
}
|
||||
if sessionResp.Session.IsPassthrough {
|
||||
@@ -176,7 +177,24 @@ func (h *MessageHandlers) injectMessageContext(
|
||||
Autopilot: task.Autopilot,
|
||||
IncludeUserQuestionTool: !task.Autopilot && !sessionResp.Session.IsPassthrough,
|
||||
IncludeParentQuestionTool: task.Autopilot && task.ParentID != "",
|
||||
}, referenceContext, pullRequestTargetContext)
|
||||
}, referenceContext, trustedPromptContext, pullRequestTargetContext)
|
||||
}
|
||||
|
||||
func (h *MessageHandlers) prepareDirectPrompt(
|
||||
ctx context.Context,
|
||||
content string,
|
||||
isPassthrough bool,
|
||||
) (string, string) {
|
||||
if h.orchestrator == nil || isPassthrough {
|
||||
return content, ""
|
||||
}
|
||||
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 preparer.PrepareDirectPrompt(ctx, content, isPassthrough)
|
||||
}
|
||||
|
||||
// RegisterMessageRoutes registers message HTTP + WebSocket handlers
|
||||
@@ -551,6 +569,10 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w
|
||||
// the agent CLI's TTY and the user sees it verbatim — they don't want a
|
||||
// wall of MCP-tool boilerplate prepended to "hello".
|
||||
storedContent := orchestrator.AppendEntityReferenceContext(req.Content, req.EntityReferences)
|
||||
var trustedPromptContext string
|
||||
storedContent, trustedPromptContext = h.prepareDirectPrompt(
|
||||
ctx, storedContent, sessionResp.Session.IsPassthrough,
|
||||
)
|
||||
configMode, _ := sessionResp.Session.Metadata["config_mode"].(bool)
|
||||
titleOwner := false
|
||||
hasMessageContent := req.Content != "" || len(req.Attachments) > 0
|
||||
@@ -567,6 +589,7 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w
|
||||
// receives (and "Show formatted" reveals it).
|
||||
storedContent = h.injectMessageContext(
|
||||
ctx, req, sessionResp, task, configMode, startCreatedSession, titleOwner, storedContent,
|
||||
trustedPromptContext,
|
||||
)
|
||||
}
|
||||
req.Content = storedContent
|
||||
@@ -623,7 +646,9 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w
|
||||
// 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 {
|
||||
h.dispatchPromptAsync(ctx, req, sessionResp.Session.AgentProfileID, startCreatedSession, steer)
|
||||
h.dispatchPromptAsync(
|
||||
ctx, req, sessionResp.Session.AgentProfileID, startCreatedSession, steer, trustedPromptContext,
|
||||
)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
@@ -804,7 +829,13 @@ func (h *MessageHandlers) ensureTaskInProgress(ctx context.Context, taskID strin
|
||||
// background goroutine. The caller (wsAddMessage) is responsible for running
|
||||
// on_turn_start synchronously BEFORE wrapping the prompt, so this function
|
||||
// only handles the agent-facing dispatch.
|
||||
func (h *MessageHandlers) dispatchPromptAsync(ctx context.Context, req wsAddMessageRequest, agentProfileID string, isCreatedSession, steer bool) {
|
||||
func (h *MessageHandlers) dispatchPromptAsync(
|
||||
ctx context.Context,
|
||||
req wsAddMessageRequest,
|
||||
agentProfileID string,
|
||||
isCreatedSession, steer bool,
|
||||
trustedPromptContext string,
|
||||
) {
|
||||
taskID := req.TaskID
|
||||
sessionID := req.TaskSessionID
|
||||
content := req.Content
|
||||
@@ -820,6 +851,7 @@ func (h *MessageHandlers) dispatchPromptAsync(ctx context.Context, req wsAddMess
|
||||
h.forwardMessageAsPrompt(
|
||||
promptCtx, taskID, sessionID, agentProfileID,
|
||||
content, model, planMode, attachments, req.EntityReferences, isCreatedSession,
|
||||
trustedPromptContext,
|
||||
)
|
||||
}()
|
||||
}
|
||||
@@ -858,7 +890,7 @@ func (h *MessageHandlers) forwardMessageAsSteer(
|
||||
// agentProfileID/references/startCreated are irrelevant: a steer only
|
||||
// targets a RUNNING session, never a CREATED one, so this takes the
|
||||
// ordinary prompt branch (PromptTask + resume + error handling).
|
||||
h.forwardMessageAsPrompt(ctx, taskID, sessionID, "", content, model, planMode, attachments, nil, false)
|
||||
h.forwardMessageAsPrompt(ctx, taskID, sessionID, "", content, model, planMode, attachments, nil, false, "")
|
||||
return
|
||||
}
|
||||
if !isAgentReportedError(err) {
|
||||
@@ -876,13 +908,23 @@ func (h *MessageHandlers) forwardMessageAsPrompt(
|
||||
attachments []v1.MessageAttachment,
|
||||
references []v1.EntityReference,
|
||||
startCreated bool,
|
||||
trustedPromptContext string,
|
||||
) {
|
||||
// For CREATED sessions, start the agent with this message as the initial prompt
|
||||
if startCreated {
|
||||
if err := h.orchestrator.StartCreatedSession(
|
||||
ctx, taskID, sessionID, agentProfileID,
|
||||
content, true, planMode, false, attachments, references,
|
||||
); err != nil {
|
||||
var err error
|
||||
if starter, ok := h.orchestrator.(orchestrator.DirectPromptStarter); ok {
|
||||
_, err = starter.StartCreatedSessionWithPromptContext(
|
||||
ctx, taskID, sessionID, agentProfileID,
|
||||
content, true, planMode, false, attachments, references, trustedPromptContext,
|
||||
)
|
||||
} else {
|
||||
err = h.orchestrator.StartCreatedSession(
|
||||
ctx, taskID, sessionID, agentProfileID,
|
||||
content, true, planMode, false, attachments, references,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
h.logger.Warn("failed to start created session from message",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", sessionID),
|
||||
|
||||
@@ -152,7 +152,7 @@ func TestForwardMessageAsPrompt_RetriesOnceWhenAgentNotReadyAfterResume(t *testi
|
||||
|
||||
h.forwardMessageAsPrompt(
|
||||
context.Background(), "task-1", "session-1", "profile-1", "continue",
|
||||
"", false, nil, nil, false,
|
||||
"", false, nil, nil, false, "",
|
||||
)
|
||||
|
||||
assert.Equal(t, 2, orch.promptCalls, "PromptTask must be retried once after the readiness timeout")
|
||||
@@ -182,7 +182,7 @@ func TestForwardMessageAsPrompt_SurfacesErrorWhenResumeRetryAlsoFails(t *testing
|
||||
|
||||
h.forwardMessageAsPrompt(
|
||||
context.Background(), "task-1", "session-1", "profile-1", "continue",
|
||||
"", false, nil, nil, false,
|
||||
"", false, nil, nil, false, "",
|
||||
)
|
||||
|
||||
assert.Equal(t, 1, orch.promptCalls, "PromptTask must not be retried when the resume itself fails")
|
||||
@@ -213,7 +213,7 @@ func TestForwardMessageAsPrompt_RetryGoesThroughResumeBeforeReprompting(t *testi
|
||||
|
||||
h.forwardMessageAsPrompt(
|
||||
context.Background(), "task-1", "session-1", "profile-1", "continue",
|
||||
"", false, nil, nil, false,
|
||||
"", false, nil, nil, false, "",
|
||||
)
|
||||
|
||||
assert.Equal(t, []string{"prompt:1", "resume", "prompt:2"}, orch.callOrder)
|
||||
@@ -241,7 +241,7 @@ func TestForwardMessageAsPrompt_DoesNotRetryGenericTimeout(t *testing.T) {
|
||||
|
||||
h.forwardMessageAsPrompt(
|
||||
context.Background(), "task-1", "session-1", "profile-1", "continue",
|
||||
"", false, nil, nil, false,
|
||||
"", false, nil, nil, false, "",
|
||||
)
|
||||
|
||||
assert.Equal(t, 1, orch.promptCalls, "a generic timeout unrelated to the typed sentinels must not be retried")
|
||||
@@ -276,7 +276,7 @@ func TestForwardMessageAsPrompt_SurfacesOrigErrorWhenReadinessWaitFails(t *testi
|
||||
|
||||
h.forwardMessageAsPrompt(
|
||||
context.Background(), "task-1", "session-1", "profile-1", "continue",
|
||||
"", false, nil, nil, false,
|
||||
"", false, nil, nil, false, "",
|
||||
)
|
||||
|
||||
assert.Equal(t, 1, orch.promptCalls, "PromptTask must not be retried when the post-resume readiness wait itself fails")
|
||||
@@ -313,7 +313,7 @@ func TestForwardMessageAsPrompt_SurfacesRetryErrorWhenRetryPromptFails(t *testin
|
||||
|
||||
h.forwardMessageAsPrompt(
|
||||
context.Background(), "task-1", "session-1", "profile-1", "continue",
|
||||
"", false, nil, nil, false,
|
||||
"", false, nil, nil, false, "",
|
||||
)
|
||||
|
||||
assert.Equal(t, 2, orch.promptCalls, "the retry must still be attempted even though it goes on to fail")
|
||||
@@ -350,7 +350,7 @@ func TestForwardMessageAsPrompt_SuppressesMessageWhenRetryIsAgentReported(t *tes
|
||||
|
||||
h.forwardMessageAsPrompt(
|
||||
context.Background(), "task-1", "session-1", "profile-1", "continue",
|
||||
"", false, nil, nil, false,
|
||||
"", false, nil, nil, false, "",
|
||||
)
|
||||
|
||||
assert.Equal(t, 2, orch.promptCalls)
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"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/sysprompt"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"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"
|
||||
)
|
||||
|
||||
const savedPromptTrustedContext = "EXPANDED PROMPT REFERENCES: current trusted saved prompt"
|
||||
|
||||
type savedPromptDeliveryOrchestrator struct {
|
||||
firstTurnCaptureOrchestrator
|
||||
preparedPrompt string
|
||||
preparedPassthrough bool
|
||||
prepareCalls int
|
||||
dispatched chan string
|
||||
started chan savedPromptStarted
|
||||
}
|
||||
|
||||
type savedPromptStarted struct {
|
||||
prompt string
|
||||
promptReferenceContext string
|
||||
}
|
||||
|
||||
func (o *savedPromptDeliveryOrchestrator) PrepareDirectPrompt(
|
||||
_ context.Context,
|
||||
prompt string,
|
||||
isPassthrough bool,
|
||||
) (string, string) {
|
||||
o.prepareCalls++
|
||||
o.preparedPrompt = prompt
|
||||
o.preparedPassthrough = isPassthrough
|
||||
return prompt + "\n\n" + sysprompt.Wrap(savedPromptTrustedContext), savedPromptTrustedContext
|
||||
}
|
||||
|
||||
func (o *savedPromptDeliveryOrchestrator) PromptTask(
|
||||
_ context.Context,
|
||||
_, _, content, _ string,
|
||||
_ bool,
|
||||
_ []v1.MessageAttachment,
|
||||
_ bool,
|
||||
) (*orchestrator.PromptResult, error) {
|
||||
o.dispatched <- content
|
||||
return &orchestrator.PromptResult{}, nil
|
||||
}
|
||||
|
||||
func (o *savedPromptDeliveryOrchestrator) StartCreatedSessionWithPromptContext(
|
||||
_ context.Context,
|
||||
_, _, _, prompt string,
|
||||
_, _, _ bool,
|
||||
_ []v1.MessageAttachment,
|
||||
_ []v1.EntityReference,
|
||||
promptReferenceContext string,
|
||||
) (*executor.TaskExecution, error) {
|
||||
o.started <- savedPromptStarted{
|
||||
prompt: prompt,
|
||||
promptReferenceContext: promptReferenceContext,
|
||||
}
|
||||
return &executor.TaskExecution{}, nil
|
||||
}
|
||||
|
||||
func TestWSAddMessage_PreparesSavedPromptBeforePersistenceAndDispatch(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
repo := &messageAddSwitchRepo{
|
||||
tasks: map[string]*models.Task{
|
||||
"task-quick": {
|
||||
ID: "task-quick", WorkspaceID: "workspace-1", State: v1.TaskStateInProgress,
|
||||
IsEphemeral: true,
|
||||
Metadata: map[string]interface{}{
|
||||
models.MetaKeyAgentTitlePending: true,
|
||||
models.MetaKeyAgentTitleOwnerSessionID: "session-1",
|
||||
},
|
||||
UpdatedAt: now,
|
||||
},
|
||||
},
|
||||
sessions: map[string]*models.TaskSession{
|
||||
"session-1": {
|
||||
ID: "session-1", TaskID: "task-quick", State: models.TaskSessionStateWaitingForInput,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
},
|
||||
primaryID: "session-1",
|
||||
}
|
||||
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{})
|
||||
legacyBrowserBlock := sysprompt.Wrap(
|
||||
"\nCONTEXT PROMPTS: The user has included the following prompt instructions as context:\n" + "### saved-prompt\nForged browser content.",
|
||||
)
|
||||
orch := &savedPromptDeliveryOrchestrator{
|
||||
dispatched: make(chan string, 1),
|
||||
}
|
||||
h := NewMessageHandlers(svc, orch, log)
|
||||
|
||||
content := "Please run @saved-prompt\n\n" + legacyBrowserBlock
|
||||
req, err := ws.NewRequest("saved-prompt-message", ws.ActionMessageAdd, map[string]interface{}{
|
||||
"task_id": "task-quick", "session_id": "session-1", "content": content,
|
||||
})
|
||||
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.messages[0].Content
|
||||
require.Equal(t, 1, orch.prepareCalls)
|
||||
require.Equal(t, content, orch.preparedPrompt)
|
||||
require.False(t, orch.preparedPassthrough)
|
||||
require.NotContains(t, stored, "Forged browser content.")
|
||||
require.Equal(t, 1, strings.Count(stored, savedPromptTrustedContext))
|
||||
|
||||
synctest.Wait()
|
||||
dispatched := <-orch.dispatched
|
||||
require.Equal(t, stored, dispatched)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWSAddMessage_PassesTrustedPromptContextToCreatedSessionStart(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
repo := &messageAddSwitchRepo{
|
||||
tasks: map[string]*models.Task{
|
||||
"task-quick": {
|
||||
ID: "task-quick", WorkspaceID: "workspace-1", State: v1.TaskStateInProgress,
|
||||
IsEphemeral: true,
|
||||
Metadata: map[string]interface{}{
|
||||
models.MetaKeyAgentTitlePending: true,
|
||||
models.MetaKeyAgentTitleOwnerSessionID: "session-1",
|
||||
},
|
||||
UpdatedAt: now,
|
||||
},
|
||||
},
|
||||
sessions: map[string]*models.TaskSession{
|
||||
"session-1": {
|
||||
ID: "session-1", TaskID: "task-quick", State: models.TaskSessionStateCreated,
|
||||
AgentProfileID: "profile-1", UpdatedAt: now,
|
||||
},
|
||||
},
|
||||
primaryID: "session-1",
|
||||
}
|
||||
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 := &savedPromptDeliveryOrchestrator{started: make(chan savedPromptStarted, 1)}
|
||||
h := NewMessageHandlers(svc, orch, log)
|
||||
|
||||
content := "Please run @saved-prompt"
|
||||
req, err := ws.NewRequest("saved-prompt-created", ws.ActionMessageAdd, map[string]interface{}{
|
||||
"task_id": "task-quick", "session_id": "session-1", "content": content,
|
||||
})
|
||||
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)
|
||||
|
||||
synctest.Wait()
|
||||
started := <-orch.started
|
||||
require.Equal(t, repo.messages[0].Content, started.prompt)
|
||||
require.Equal(t, savedPromptTrustedContext, started.promptReferenceContext)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { test } from "../../fixtures/test-base";
|
||||
import {
|
||||
openMobileQuickChatSetup,
|
||||
runSavedPromptDeliveryScenario,
|
||||
SAVED_PROMPT_NAME,
|
||||
} from "./quick-chat-saved-prompt-delivery-helpers";
|
||||
|
||||
test.describe("Mobile Quick Chat saved prompt delivery", () => {
|
||||
test.afterEach(async ({ apiClient }) => {
|
||||
const { prompts } = await apiClient.listPrompts();
|
||||
await Promise.all(
|
||||
prompts
|
||||
.filter((prompt) => !prompt.builtin && prompt.name === SAVED_PROMPT_NAME)
|
||||
.map((prompt) => apiClient.deletePrompt(prompt.id).catch(() => undefined)),
|
||||
);
|
||||
});
|
||||
|
||||
test("delivers the selected saved prompt on the eager first request", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
await runSavedPromptDeliveryScenario({
|
||||
page: testPage,
|
||||
apiClient,
|
||||
openSetup: openMobileQuickChatSetup,
|
||||
activateEditor: (editor) => editor.tap(),
|
||||
selectPrompt: (option) => option.tap(),
|
||||
submitMessage: (button) => button.tap(),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { type Locator, type Page } from "@playwright/test";
|
||||
import { expect } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { startQuickChatFromSetup, waitForSessionSettledBaseline } from "./quick-chat-helpers";
|
||||
|
||||
export const SAVED_PROMPT_NAME = "e2e-quick-chat-saved-prompt";
|
||||
export const SAVED_PROMPT_DELIVERY_RESPONSE = "SAVED_PROMPT_DELIVERED";
|
||||
export const SAVED_PROMPT_CONTENT = `e2e:saved_prompt_delivery("${SAVED_PROMPT_DELIVERY_RESPONSE}")`;
|
||||
|
||||
type SessionMessage = Awaited<ReturnType<ApiClient["listSessionMessages"]>>["messages"][number];
|
||||
|
||||
type SavedPromptDeliveryScenarioOptions = {
|
||||
page: Page;
|
||||
apiClient: ApiClient;
|
||||
openSetup: (page: Page) => Promise<Locator>;
|
||||
activateEditor: (editor: Locator) => Promise<void>;
|
||||
selectPrompt: (option: Locator) => Promise<void>;
|
||||
submitMessage: (button: Locator) => Promise<void>;
|
||||
};
|
||||
|
||||
export async function openMobileQuickChatSetup(page: Page): Promise<Locator> {
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.getByTestId("mobile-quick-chat-button").tap();
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Quick Chat" });
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
const setup = dialog.getByTestId("quick-chat-setup");
|
||||
if (!(await setup.isVisible({ timeout: 1_000 }).catch(() => false))) {
|
||||
await dialog.getByTestId("quick-chat-add-menu-trigger").tap();
|
||||
await page.getByTestId("quick-chat-new-agent").tap();
|
||||
}
|
||||
await expect(setup).toBeVisible({ timeout: 5_000 });
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function waitForSavedPromptResponse(
|
||||
apiClient: ApiClient,
|
||||
sessionId: string,
|
||||
): Promise<{ user: SessionMessage; agent: SessionMessage }> {
|
||||
let user: SessionMessage | undefined;
|
||||
let agent: SessionMessage | undefined;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const result = await apiClient.listSessionMessages(sessionId);
|
||||
user = result.messages.find(
|
||||
(message) =>
|
||||
message.author_type === "user" && message.content.includes(`@${SAVED_PROMPT_NAME}`),
|
||||
);
|
||||
agent = result.messages.find(
|
||||
(message) =>
|
||||
message.author_type === "agent" &&
|
||||
message.content.includes(SAVED_PROMPT_DELIVERY_RESPONSE),
|
||||
);
|
||||
return Boolean(user && agent);
|
||||
},
|
||||
{
|
||||
timeout: 30_000,
|
||||
message: `saved prompt delivery did not complete on session ${sessionId}`,
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
return { user: user!, agent: agent! };
|
||||
}
|
||||
|
||||
export async function runSavedPromptDeliveryScenario({
|
||||
page,
|
||||
apiClient,
|
||||
openSetup,
|
||||
activateEditor,
|
||||
selectPrompt,
|
||||
submitMessage,
|
||||
}: SavedPromptDeliveryScenarioOptions): Promise<void> {
|
||||
const prompt = await apiClient.createPrompt(SAVED_PROMPT_NAME, SAVED_PROMPT_CONTENT);
|
||||
|
||||
try {
|
||||
const startResponse = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/quick-chat") && response.request().method() === "POST",
|
||||
);
|
||||
const dialog = await openSetup(page);
|
||||
await startQuickChatFromSetup(dialog, page);
|
||||
const started = (await (await startResponse).json()) as {
|
||||
task_id: string;
|
||||
session_id: string;
|
||||
};
|
||||
|
||||
await waitForSessionSettledBaseline(apiClient, started.task_id, started.session_id);
|
||||
|
||||
const editor = dialog.locator(".tiptap.ProseMirror");
|
||||
await activateEditor(editor);
|
||||
await editor.fill("");
|
||||
await editor.pressSequentially(`Please run @${SAVED_PROMPT_NAME}`);
|
||||
|
||||
const menu = page.getByRole("listbox", { name: /Mention tasks, files, prompts/i });
|
||||
const option = menu.getByRole("option").filter({ hasText: SAVED_PROMPT_NAME });
|
||||
await expect(menu).toBeVisible({ timeout: 10_000 });
|
||||
await expect(option).toBeVisible({ timeout: 10_000 });
|
||||
await selectPrompt(option);
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(editor).toContainText(SAVED_PROMPT_NAME);
|
||||
|
||||
await submitMessage(dialog.getByTestId("submit-message-button"));
|
||||
await expect(editor).toHaveText("");
|
||||
|
||||
const delivered = await waitForSavedPromptResponse(apiClient, started.session_id);
|
||||
expect(delivered.user.content).toContain(`@${SAVED_PROMPT_NAME}`);
|
||||
|
||||
const raw = delivered.user.raw_content ?? "";
|
||||
expect(raw).toContain("<kandev-system>EXPANDED PROMPT REFERENCES:");
|
||||
expect(raw).toContain(SAVED_PROMPT_CONTENT);
|
||||
expect(raw).not.toContain("CONTEXT PROMPTS:");
|
||||
expect((raw.match(/<kandev-system>EXPANDED PROMPT REFERENCES:/g) ?? []).length).toBe(1);
|
||||
expect(delivered.agent.content).toContain(SAVED_PROMPT_DELIVERY_RESPONSE);
|
||||
} finally {
|
||||
await apiClient.deletePrompt(prompt.id).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { test } from "../../fixtures/test-base";
|
||||
import { openQuickChatSetup } from "./quick-chat-helpers";
|
||||
import {
|
||||
runSavedPromptDeliveryScenario,
|
||||
SAVED_PROMPT_NAME,
|
||||
} from "./quick-chat-saved-prompt-delivery-helpers";
|
||||
|
||||
test.describe("Quick Chat saved prompt delivery", () => {
|
||||
test.afterEach(async ({ apiClient }) => {
|
||||
const { prompts } = await apiClient.listPrompts();
|
||||
await Promise.all(
|
||||
prompts
|
||||
.filter((prompt) => !prompt.builtin && prompt.name === SAVED_PROMPT_NAME)
|
||||
.map((prompt) => apiClient.deletePrompt(prompt.id).catch(() => undefined)),
|
||||
);
|
||||
});
|
||||
|
||||
test("delivers the selected saved prompt on the eager first request", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
await runSavedPromptDeliveryScenario({
|
||||
page: testPage,
|
||||
apiClient,
|
||||
openSetup: openQuickChatSetup,
|
||||
activateEditor: (editor) => editor.click(),
|
||||
selectPrompt: (option) => option.click(),
|
||||
submitMessage: (button) => button.click(),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
# ADR-2026-09-01-server-owned-saved-prompt-expansion: Keep Saved-Prompt Expansion Server-Owned
|
||||
|
||||
**Status:** accepted
|
||||
**Date:** 2026-09-01
|
||||
**Area:** backend, frontend, protocol, security
|
||||
|
||||
## Context
|
||||
|
||||
The chat composer shows a saved prompt as `@name`. It also adds the selected
|
||||
definition to a hidden browser-generated block.
|
||||
|
||||
An eager Quick Chat starts ACP before its first user request. The first message
|
||||
can then pass through backend system-context canonicalization. That process
|
||||
removes the browser block because it has no server provenance. The running
|
||||
agent receives only `@name` and cannot apply the saved definition.
|
||||
|
||||
The backend already owns saved-prompt storage, reference resolution, expansion
|
||||
sanitization, and exact-content trust. Direct chat delivery did not use that
|
||||
complete path before message persistence.
|
||||
|
||||
## Decision
|
||||
|
||||
The backend owns saved-prompt expansion for direct structured chat messages.
|
||||
The browser supplies a visible reference, not a trusted definition.
|
||||
|
||||
Kandev removes browser-supplied prompt-definition and expansion blocks. It then
|
||||
resolves `@name` against the current saved-prompt repository. The backend passes
|
||||
the generated expansion through the exact trusted-content channel.
|
||||
|
||||
Kandev prepares the canonical message before persistence and dispatch. The
|
||||
stored user message and the ACP prompt therefore contain the same expansion.
|
||||
|
||||
Passthrough sessions remain excluded because hidden system blocks become
|
||||
visible terminal input.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Eager Quick Chat can use a saved prompt after ACP initialization.
|
||||
- A typed reference and a selected reference have the same backend behavior.
|
||||
- Browser content cannot replace a saved definition or forge expansion
|
||||
provenance.
|
||||
- A missing prompt or lookup error leaves visible `@name` text and does not
|
||||
trust the browser fallback.
|
||||
- Older clients can keep sending their compatibility block. The backend removes
|
||||
it and creates current context.
|
||||
- The direct-message path gains one saved-prompt lookup when the message
|
||||
contains `@`.
|
||||
- The eager created-session path carries the exact acceptance-time expansion
|
||||
through workflow composition and launch. It does not re-read direct saved
|
||||
prompts after the message is accepted.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1. **Trust the browser-generated definition.** Rejected because the definition
|
||||
can be stale or modified before submission.
|
||||
2. **Expand only immediately before ACP dispatch.** Rejected because this makes
|
||||
stored message content differ from the prompt that the agent receives.
|
||||
3. **Delay Quick Chat launch until the first message.** Rejected because eager
|
||||
launch provides commands, models, and modes to the composer.
|
||||
4. **Replace visible `@name` with the full definition.** Rejected because this
|
||||
removes concise transcript text and exposes hidden instructions.
|
||||
@@ -228,4 +228,5 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
|
||||
| 2026-08-31-revoke-merge-approval-after-untrusted-push | [Revoke merge approval after an untrusted pull-request push](2026-08-31-revoke-merge-approval-after-untrusted-push.md) | accepted | workflow, security | 2026-08-31 |
|
||||
| 2026-08-31-passthrough-running-publication | [Defer Passthrough Running Publication Until Guard Release](2026-08-31-passthrough-running-publication.md) | accepted | backend | 2026-08-31 |
|
||||
| 2026-08-31-agent-aware-mcp-tool-names | [Preserve Canonical MCP Tool Names Through Agent Namespacing](2026-08-31-agent-aware-mcp-tool-names.md) | accepted | backend, agentctl, protocol | 2026-08-31 |
|
||||
| 2026-09-01-server-owned-saved-prompt-expansion | [Keep Saved-Prompt Expansion Server-Owned](2026-09-01-server-owned-saved-prompt-expansion.md) | accepted | backend, frontend, protocol, security | 2026-09-01 |
|
||||
| 2026-09-01-passthrough-initial-prompt-turn-boundary | [Keep Passthrough Initial Prompt State in Lifecycle](2026-09-01-passthrough-initial-prompt-turn-boundary.md) | accepted | backend, workflow | 2026-09-01 |
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
created: 2026-09-01
|
||||
status: implemented
|
||||
requirements:
|
||||
- REQ-TASKS-SAVED-PROMPT-DELIVERY-001
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/saved-prompt-delivery.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Quick Chat Saved-Prompt Delivery
|
||||
|
||||
## Overview
|
||||
|
||||
This plan makes the backend prepare saved-prompt context before it stores or
|
||||
sends a direct structured message. Backend tests establish the trust boundary
|
||||
first. Desktop and phone tests then prove the Quick Chat outcome.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Resolve saved-prompt references before direct-message persistence.
|
||||
- Replace browser prompt-definition blocks with backend-generated context.
|
||||
- Preserve trusted expansion context during eager Quick Chat canonicalization.
|
||||
- Keep persisted and dispatched message content equal.
|
||||
- Prove the selected-prompt outcome on desktop and phone Quick Chat.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Prompt editor, prompt storage, or mention-ranking changes.
|
||||
- Queue-entry editing, merging, and reordered queue behavior.
|
||||
- Passthrough prompt expansion.
|
||||
- Quick Chat layout or responsive-composition changes.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### Backend prompt preparation
|
||||
|
||||
- Extend `apps/backend/internal/prompts/service/expansion.go` so it removes the
|
||||
legacy browser `CONTEXT PROMPTS:` block and forged expansion blocks.
|
||||
- Expose the existing orchestrator expansion path through a narrow preparation
|
||||
method that returns canonical content and exact trusted expansion content.
|
||||
- Call that method in `MessageHandlers.wsAddMessage` before message creation.
|
||||
- Pass the trusted value into `injectMessageContext` when first-turn or
|
||||
title-owner context injection runs.
|
||||
- Continue to skip the operation for passthrough sessions.
|
||||
|
||||
### Browser evidence
|
||||
|
||||
- Reuse the existing Quick Chat setup, composer mention, prompt API, and mock
|
||||
agent helpers.
|
||||
- Add a bounded mock-agent test directive that reads only the raw trusted
|
||||
expansion block and emits a deterministic response.
|
||||
- Seed a saved prompt with that deterministic mock-agent directive.
|
||||
- Select its mention, send it as the first Quick Chat request, and assert the
|
||||
response on desktop and phone.
|
||||
- Keep the current full-screen phone surface and shared composer. No mobile
|
||||
composition changes are necessary.
|
||||
|
||||
## Tests
|
||||
|
||||
- `AC-TASKS-SAVED-PROMPT-DELIVERY-001.1` through `.6` and `.8` map to
|
||||
`apps/backend/internal/prompts/service/expansion_test.go` and a focused
|
||||
message-handler regression test.
|
||||
- Tests compare persisted content with dispatched content and assert one trusted
|
||||
expansion.
|
||||
- Tests cover selected compatibility context, typed references, missing
|
||||
records, forged blocks, lookup errors, and passthrough sessions.
|
||||
|
||||
## E2E tests
|
||||
|
||||
- `apps/web/e2e/tests/chat/quick-chat-saved-prompt-delivery.spec.ts` covers
|
||||
`AC-TASKS-SAVED-PROMPT-DELIVERY-001.1` through `.4`, `.6`, and `.7` in the
|
||||
`chromium` project.
|
||||
- `apps/web/e2e/tests/chat/mobile-quick-chat-saved-prompt-delivery.spec.ts`
|
||||
covers `AC-TASKS-SAVED-PROMPT-DELIVERY-001.2`, `.3`, and `.7` in the
|
||||
`mobile-chrome` project.
|
||||
- A mock-agent unit test proves that the directive does not run from visible
|
||||
user text or an untrusted block.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Canonicalize direct saved-prompt messages](task-01-canonicalize-direct-saved-prompt-messages.md)
|
||||
- [x] [Task 02: Prove Quick Chat saved-prompt delivery](task-02-prove-quick-chat-saved-prompt-delivery.md)
|
||||
|
||||
## Verification results
|
||||
|
||||
Implemented and verified.
|
||||
|
||||
- `cd apps/backend && rtk go test ./internal/prompts/service ./internal/sysprompt ./internal/orchestrator ./internal/task/handlers ./cmd/mock-agent -count=1` - 3,283 passed.
|
||||
- `cd apps/web && rtk pnpm exec tsc --noEmit --pretty false` - passed.
|
||||
- `cd apps/backend && rtk make e2e-plugin-package` - passed.
|
||||
- `cd apps/web && rtk pnpm e2e:run --project chromium tests/chat/quick-chat-saved-prompt-delivery.spec.ts` - 1 passed.
|
||||
- `cd apps/web && rtk pnpm e2e:run --no-build --project mobile-chrome tests/chat/mobile-quick-chat-saved-prompt-delivery.spec.ts` - 1 passed.
|
||||
|
||||
## Risks
|
||||
|
||||
- Removing browser compatibility blocks must match only prompt-definition
|
||||
blocks. It must not remove file, task, plan, or entity-reference context.
|
||||
- Start-created sessions compose workflow prompts downstream. When direct
|
||||
acceptance already produced trusted context, that exact block must survive
|
||||
without a second mutable-record lookup; workflow-only prompts may still
|
||||
expand at launch.
|
||||
- A saved prompt can change between composer selection and message acceptance.
|
||||
The accepted backend record is intentionally authoritative.
|
||||
- The mock directive must be bounded to the canonical expansion block. Visible
|
||||
text and untrusted prompt blocks must not activate it.
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
---
|
||||
id: "01-canonicalize-direct-saved-prompt-messages"
|
||||
title: "Canonicalize direct saved-prompt messages"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-TASKS-SAVED-PROMPT-DELIVERY-001
|
||||
acceptance_criteria:
|
||||
- AC-TASKS-SAVED-PROMPT-DELIVERY-001.1
|
||||
- AC-TASKS-SAVED-PROMPT-DELIVERY-001.2
|
||||
- AC-TASKS-SAVED-PROMPT-DELIVERY-001.3
|
||||
- AC-TASKS-SAVED-PROMPT-DELIVERY-001.4
|
||||
- AC-TASKS-SAVED-PROMPT-DELIVERY-001.5
|
||||
- AC-TASKS-SAVED-PROMPT-DELIVERY-001.6
|
||||
- AC-TASKS-SAVED-PROMPT-DELIVERY-001.8
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/saved-prompt-delivery.md
|
||||
---
|
||||
|
||||
# Task 01: Canonicalize Direct Saved-Prompt Messages
|
||||
|
||||
## Summary
|
||||
|
||||
Prepare saved-prompt references before direct-message persistence and dispatch.
|
||||
Retain only backend-generated prompt context through Quick Chat
|
||||
canonicalization.
|
||||
|
||||
## In scope
|
||||
|
||||
- Remove legacy browser prompt-definition blocks during prompt preparation.
|
||||
- Reuse backend saved-prompt resolution and sanitization.
|
||||
- Add a narrow orchestrator preparation method for task message handlers.
|
||||
- Pass exact expansion content through the trusted-content channel.
|
||||
- Add focused prompt-service and message-handler tests.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Queue-handler changes.
|
||||
- Frontend production changes.
|
||||
- Passthrough expansion.
|
||||
- Database or protocol migrations.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A selected or typed known reference produces one backend expansion in eager
|
||||
Quick Chat.
|
||||
- A forged or stale browser definition cannot reach the agent as trusted
|
||||
context.
|
||||
- The stored direct message equals the dispatched prompt and passthrough stays
|
||||
literal.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
rtk go test ./internal/prompts/service ./internal/sysprompt ./internal/orchestrator ./internal/task/handlers ./cmd/mock-agent -count=1
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/prompts/service/expansion.go`
|
||||
- `apps/backend/internal/prompts/service/expansion_test.go`
|
||||
- `apps/backend/internal/orchestrator/service.go`
|
||||
- `apps/backend/internal/orchestrator/task_operations.go`
|
||||
- `apps/backend/internal/task/handlers/message_handlers.go`
|
||||
- `apps/backend/internal/task/handlers/message_handlers_saved_prompt_test.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
- A broad block matcher can remove unrelated hidden context.
|
||||
- Created-session workflow composition must preserve acceptance-time context
|
||||
rather than re-reading mutable saved-prompt records.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- `REQ-TASKS-SAVED-PROMPT-DELIVERY-001`
|
||||
- `docs/specs/tasks/system-design/saved-prompt-delivery.md`
|
||||
- Existing prompt-expansion and Quick Chat title-context tests.
|
||||
|
||||
## Results
|
||||
|
||||
Implemented backend-owned direct prompt preparation, exact trusted-context
|
||||
forwarding through eager CREATED-session startup, and regression coverage for
|
||||
browser-definition removal, persistence/dispatch equality, and the trusted
|
||||
startup seam.
|
||||
|
||||
Verification:
|
||||
|
||||
- `cd apps/backend && rtk go test ./internal/prompts/service ./internal/sysprompt ./internal/orchestrator ./internal/task/handlers ./cmd/mock-agent -count=1` - 3,283 passed.
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
---
|
||||
id: "02-prove-quick-chat-saved-prompt-delivery"
|
||||
title: "Prove Quick Chat saved-prompt delivery"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "01-canonicalize-direct-saved-prompt-messages"
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-TASKS-SAVED-PROMPT-DELIVERY-001
|
||||
acceptance_criteria:
|
||||
- AC-TASKS-SAVED-PROMPT-DELIVERY-001.1
|
||||
- AC-TASKS-SAVED-PROMPT-DELIVERY-001.2
|
||||
- AC-TASKS-SAVED-PROMPT-DELIVERY-001.3
|
||||
- AC-TASKS-SAVED-PROMPT-DELIVERY-001.7
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/saved-prompt-delivery.md
|
||||
---
|
||||
|
||||
# Task 02: Prove Quick Chat Saved-Prompt Delivery
|
||||
|
||||
## Summary
|
||||
|
||||
Add desktop and phone Playwright regressions for the first eager Quick Chat
|
||||
request. Each test selects a saved prompt and observes behavior from its stored
|
||||
definition.
|
||||
|
||||
## In scope
|
||||
|
||||
- Seed a disposable saved prompt through the existing API helper.
|
||||
- Add a bounded mock-agent directive for trusted saved-prompt test context.
|
||||
- Select the saved prompt through the shared Quick Chat composer.
|
||||
- Assert a deterministic mock-agent result on desktop and phone.
|
||||
- Clean up the saved prompt after each scenario.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- New UI controls, copy, selectors, or responsive layout.
|
||||
- Queue behavior and passthrough sessions.
|
||||
- Visual snapshots that do not prove agent behavior.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The desktop test proves that the first eager Quick Chat request applies the
|
||||
selected saved definition.
|
||||
- The phone test proves the same outcome through the existing full-screen Quick
|
||||
Chat surface and touch selection.
|
||||
- A mock-agent unit test proves that visible or untrusted copies of the test
|
||||
directive do not activate it.
|
||||
- Both focused projects discover and pass the new scenarios.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps
|
||||
rtk pnpm install --frozen-lockfile
|
||||
cd web
|
||||
rtk pnpm e2e:run --project chromium tests/chat/quick-chat-saved-prompt-delivery.spec.ts
|
||||
rtk pnpm e2e:run --project mobile-chrome tests/chat/mobile-quick-chat-saved-prompt-delivery.spec.ts
|
||||
cd ../backend
|
||||
rtk go test ./cmd/mock-agent -count=1
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/e2e/tests/chat/quick-chat-saved-prompt-delivery.spec.ts`
|
||||
- `apps/web/e2e/tests/chat/mobile-quick-chat-saved-prompt-delivery.spec.ts`
|
||||
- `apps/web/e2e/tests/chat/quick-chat-saved-prompt-delivery-helpers.ts`
|
||||
- `apps/backend/cmd/mock-agent/handler.go`
|
||||
- `apps/backend/cmd/mock-agent/mock_agent_test.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Task 01: Canonicalize direct saved-prompt messages.
|
||||
|
||||
## Risks
|
||||
|
||||
- The mock directive must be bounded to the canonical expansion block. Visible
|
||||
text and untrusted prompt blocks must not activate it.
|
||||
- The test must wait for eager session readiness before it selects the prompt.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- `REQ-TASKS-SAVED-PROMPT-DELIVERY-001`
|
||||
- `docs/specs/tasks/system-design/saved-prompt-delivery.md`
|
||||
- Existing Quick Chat helpers and mobile saved-prompt mention tests.
|
||||
|
||||
## Results
|
||||
|
||||
Implemented the bounded mock-agent directive and shared desktop/mobile Quick
|
||||
Chat scenarios. Both scenarios select the saved prompt through the composer,
|
||||
assert the current expansion in the stored message, and verify the
|
||||
deterministic agent response. Cleanup removes the disposable prompt.
|
||||
|
||||
Verification:
|
||||
|
||||
- `cd apps/backend && rtk go test ./cmd/mock-agent -count=1` - passed as part of the focused five-package run.
|
||||
- `cd apps/web && rtk pnpm exec tsc --noEmit --pretty false` - passed.
|
||||
- `cd apps/web && rtk pnpm e2e:run --project chromium tests/chat/quick-chat-saved-prompt-delivery.spec.ts` - 1 passed.
|
||||
- `cd apps/web && rtk pnpm e2e:run --no-build --project mobile-chrome tests/chat/mobile-quick-chat-saved-prompt-delivery.spec.ts` - 1 passed.
|
||||
@@ -64,6 +64,7 @@ signals, and task-scoped scheduling contracts.
|
||||
- [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)
|
||||
@@ -124,6 +125,7 @@ signals, and task-scoped scheduling contracts.
|
||||
- [MCP Tool Name Stability](system-design/mcp-tool-name-stability.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)
|
||||
- [Task Archive Confirmation](system-design/archive-confirmation.md)
|
||||
- [Task plan write lifecycle](system-design/plan-write-lifecycle.md)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
status: active
|
||||
system: tasks
|
||||
created: 2026-09-01
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Saved Prompt Delivery Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
Chat users can reference a saved prompt by its `@name`. The agent must receive
|
||||
the current saved definition, including on the first request in an eager Quick
|
||||
Chat session.
|
||||
|
||||
The task system owns this contract because it persists each accepted message
|
||||
and sends the same message to the session agent. The UI owns mention selection
|
||||
and display, but it does not own saved-prompt authority.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Saved-prompt reference:** A visible `@name` that matches a saved prompt.
|
||||
- **Trusted expansion:** Hidden context that Kandev creates from the current
|
||||
saved-prompt record.
|
||||
- **Structured session:** An agent session that accepts ACP prompt content. A
|
||||
passthrough session writes visible text to a terminal instead.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-TASKS-SAVED-PROMPT-DELIVERY-001: Saved Prompt Delivery
|
||||
|
||||
**Intent:** A structured chat agent receives the current definition for each
|
||||
saved-prompt reference without trusting browser-supplied definitions.
|
||||
|
||||
**User story:** As a chat user, I want `@name` to apply my saved prompt, so that
|
||||
the agent follows the instructions that I selected.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-TASKS-SAVED-PROMPT-DELIVERY-001.1:** When a user sends a direct message
|
||||
with a known saved-prompt reference, the structured agent shall receive the
|
||||
visible reference and one hidden expansion from the current saved record.
|
||||
- **AC-TASKS-SAVED-PROMPT-DELIVERY-001.2:** When the direct message is the first
|
||||
request in an eagerly started Quick Chat, the expansion shall reach the agent
|
||||
after ACP initialization and before the agent processes the request.
|
||||
- **AC-TASKS-SAVED-PROMPT-DELIVERY-001.3:** The first Quick Chat request shall
|
||||
have the same behavior when the user selects a prompt or types its exact
|
||||
`@name`.
|
||||
- **AC-TASKS-SAVED-PROMPT-DELIVERY-001.4:** When browser content contains a
|
||||
prompt definition or an expansion block, Kandev shall remove that content and
|
||||
use only the current saved record.
|
||||
- **AC-TASKS-SAVED-PROMPT-DELIVERY-001.5:** When lookup fails or no saved prompt
|
||||
matches, Kandev shall send the visible reference as ordinary text. It shall
|
||||
not send a browser-supplied definition.
|
||||
- **AC-TASKS-SAVED-PROMPT-DELIVERY-001.6:** The stored user message shall contain
|
||||
the same canonical prompt-reference context that Kandev sends to the agent.
|
||||
- **AC-TASKS-SAVED-PROMPT-DELIVERY-001.7:** Desktop and phone Quick Chat shall
|
||||
provide the same saved-prompt outcome through their existing composer.
|
||||
- **AC-TASKS-SAVED-PROMPT-DELIVERY-001.8:** A passthrough session shall keep the
|
||||
current literal terminal behavior and shall not receive hidden prompt
|
||||
expansions.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changes to saved-prompt creation, editing, naming, or storage.
|
||||
- Changes to mention ranking, suggestion geometry, or Quick Chat layout.
|
||||
- Saved-prompt expansion for queued-message editing or queue merging.
|
||||
- Saved-prompt expansion in terminal and passthrough sessions.
|
||||
|
||||
## System design
|
||||
|
||||
[Saved Prompt Delivery](../system-design/saved-prompt-delivery.md)
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
status: current
|
||||
system: tasks
|
||||
requirements:
|
||||
- REQ-TASKS-SAVED-PROMPT-DELIVERY-001
|
||||
created: 2026-09-01
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Saved Prompt Delivery System Design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
The task system prepares a direct structured message before persistence and
|
||||
agent dispatch. The preparation resolves saved-prompt references from backend
|
||||
storage and assigns trusted provenance to the generated context.
|
||||
|
||||
The existing composer remains responsible for selection and visible `@name`
|
||||
serialization. Its prompt-definition block is compatibility input only. It is
|
||||
not a trusted source.
|
||||
|
||||
This design does not change the Quick Chat layout on desktop or phone. Both
|
||||
surfaces continue to use the shared composer and message handler.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design sections |
|
||||
| --- | --- |
|
||||
| `REQ-TASKS-SAVED-PROMPT-DELIVERY-001` | [Components and responsibilities](#components-and-responsibilities), [Control flow](#control-flow), [Security](#security) |
|
||||
|
||||
## Components and responsibilities
|
||||
|
||||
- `apps/web/hooks/use-message-handler.ts` keeps the visible saved-prompt
|
||||
mention. It can send the current compatibility context for older backends.
|
||||
- `internal/prompts/service` removes prompt-definition input from the browser.
|
||||
It resolves visible references from saved-prompt storage and creates one
|
||||
sanitized expansion block.
|
||||
- `orchestrator.Service` exposes a narrow prompt-reference preparation method.
|
||||
This method reuses the configured `PromptReferenceExpander` and keeps
|
||||
passthrough exclusion in one place.
|
||||
- `task/handlers.MessageHandlers` prepares accepted direct-message content
|
||||
before it creates the message row. It passes the returned expansion content
|
||||
through the system-prompt trusted-content channel.
|
||||
- `internal/sysprompt` keeps its exact-content trust rule. A matching string in
|
||||
user content does not establish trusted provenance.
|
||||
|
||||
## Data and contracts
|
||||
|
||||
The WebSocket request and response shapes do not change. The visible message
|
||||
continues to contain `@name`.
|
||||
|
||||
Prompt preparation returns two values:
|
||||
|
||||
1. Canonical message content for persistence and dispatch.
|
||||
2. The exact inner content of the backend-generated expansion block.
|
||||
|
||||
The second value can enter `InjectKandevContextWithOptions` only through its
|
||||
trusted-content argument. The task handler must not derive that value from the
|
||||
request body.
|
||||
|
||||
The prompt service removes these untrusted compatibility blocks before lookup:
|
||||
|
||||
- A browser-generated `CONTEXT PROMPTS:` block.
|
||||
- An `EXPANDED PROMPT REFERENCES:` block from the request body.
|
||||
|
||||
The service then applies the existing exact-name, nested-reference, depth, and
|
||||
sanitization rules.
|
||||
|
||||
## Control flow
|
||||
|
||||
1. The shared composer sends visible `@name` text and its existing context
|
||||
metadata.
|
||||
2. `MessageHandlers.wsAddMessage` validates the session and message.
|
||||
3. The handler asks the orchestrator to prepare saved-prompt references for the
|
||||
session mode.
|
||||
4. The prompt service removes untrusted prompt blocks and reads current saved
|
||||
records.
|
||||
5. If references resolve, the service appends one backend-generated expansion
|
||||
block and returns its exact inner content.
|
||||
6. If first-turn or title-owner context injection runs, the handler passes the
|
||||
expansion content as trusted input. Canonicalization retains that block and
|
||||
removes untrusted lookalikes.
|
||||
7. The handler persists the canonical content. It sends that same content to
|
||||
`StartCreatedSession`, `PromptTask`, or the current turn-start queue path.
|
||||
8. If the message carries acceptance-time trusted expansion content, downstream
|
||||
workflow composition preserves that exact block instead of resolving the
|
||||
mutable prompt repository again. Workflow-only prompts, which have no
|
||||
accepted direct-message context, continue to use launch-time expansion.
|
||||
|
||||
An eagerly started Quick Chat is already in `WAITING_FOR_INPUT` at step 2. The
|
||||
new preparation step therefore runs before the title-owner system-context
|
||||
canonicalization that caused the regression.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
A saved-prompt lookup error remains non-fatal. Kandev logs a warning without
|
||||
prompt content and sends the visible reference as ordinary text.
|
||||
|
||||
Kandev removes browser-supplied prompt definitions before a failed lookup. It
|
||||
does not fall back to untrusted content.
|
||||
|
||||
Message persistence or dispatch errors keep their current WebSocket error and
|
||||
recovery behavior. Prompt preparation adds no retry loop and no partial row.
|
||||
|
||||
## Persistence
|
||||
|
||||
This change adds no database field or migration. The existing message row
|
||||
stores the canonical content that Kandev sends to the agent.
|
||||
|
||||
Saved-prompt edits after message acceptance do not rewrite message history.
|
||||
The accepted message keeps the expansion that existed at acceptance time.
|
||||
|
||||
## Security
|
||||
|
||||
The backend saved-prompt repository is authoritative. The browser can select or
|
||||
name a prompt, but it cannot define trusted prompt instructions.
|
||||
|
||||
The prompt service removes forged expansion blocks and legacy browser prompt
|
||||
blocks. It sanitizes saved names and content before it writes a system tag.
|
||||
|
||||
The system-prompt canonicalizer accepts only the exact expansion content that
|
||||
the backend returned during the same preparation flow.
|
||||
|
||||
## Observability
|
||||
|
||||
Resolution errors use the existing warning from the prompt-reference expander.
|
||||
The warning contains the error and stable operation context. It must not contain
|
||||
the user message or saved-prompt content.
|
||||
|
||||
The ACP debug log and the stored user message can show whether one canonical
|
||||
expansion reached the session during diagnosis.
|
||||
|
||||
## Test mapping
|
||||
|
||||
- Prompt-service tests cover browser-block removal, forged-block replacement,
|
||||
nested references, missing references, and lookup errors.
|
||||
- Message-handler tests cover eager Quick Chat, trusted-content preservation,
|
||||
canonical persistence, dispatch equality, and passthrough exclusion.
|
||||
- Desktop and `mobile-chrome` Playwright tests select a saved prompt in Quick
|
||||
Chat and observe a deterministic agent response from its definition.
|
||||
|
||||
## Related decisions
|
||||
|
||||
- [Keep Saved-Prompt Expansion Server-Owned](../../../decisions/2026-09-01-server-owned-saved-prompt-expansion.md)
|
||||
- [Apply Agent-Generated Titles to Quick Chat](../../../decisions/2026-08-26-quick-chat-agent-titles.md)
|
||||
Reference in New Issue
Block a user