feat: resolve @prompt references in workflow prompts (#1865)

* feat: resolve @prompt_name saved-prompt references in workflow step prompts

Workflow step prompts, the GitHub review-watch task prompt, and the CI
auto-fix prompt now resolve "@name" references to saved custom prompts
(Settings > Prompts) at invocation time, appending the referenced
content as hidden context while keeping the mention visible - matching
how chat messages and message_task already resolve @mentions. Editing
a saved prompt now propagates to every workflow step that references
it without needing to update each step individually.

Adds a Monaco "@" completion provider to the step-prompt and
review-watch editors, alongside the existing "{{placeholder}}"
provider.

* fix: address PR review findings on prompt-reference expansion

- Make AppendReferenceExpansions idempotent: a prompt that already
  contains a previously-appended expansion block is returned unchanged,
  since the block's own "### @name" headers would otherwise be
  re-scanned as fresh mentions on a second call.
- Skip "@name" expansion entirely for CLI-passthrough sessions: their
  prompts are written raw to a PTY with no <kandev-system> stripping,
  so the hidden block would otherwise leak into the terminal verbatim.

* fix: fail safe on passthrough lookup error in startTask

A session-lookup error while resolving isPassthrough for the Kandev
MCP wrap and prompt-reference expansion previously defaulted to
false (non-passthrough), risking a hidden <kandev-system> block
leaking into a real passthrough session's PTY on a transient error.
Extracted resolveIsPassthroughForLaunch, which now fails safe by
defaulting to true (skip injection) and logs a warning.

* fix: scope expansion idempotency marker to system blocks

The idempotency guard added in a prior commit checked for the
expansion marker anywhere in the prompt, which could falsely
suppress a legitimate first-time expansion if caller-controlled
text happened to contain that literal phrase. Add
sysprompt.HasSystemMarker, mirroring the existing HasKandevContext
pattern, so the marker is only recognized inside a formatter-
produced <kandev-system> block. Also strengthens a passthrough test
assertion that didn't actually distinguish the snapshot path from
the fail-safe default.

* fix: match exact expansion-block prefix in idempotency guard

The prior scoping fix accepted the marker anywhere inside any
<kandev-system> block, which could still false-positive against an
unrelated system block (e.g. a plan-mode prefix or MCP context
wrap) that happens to mention the marker phrase without it being
the block's own content. Check for the exact literal prefix this
package's formatter produces instead: the opening tag immediately
followed by the marker, with nothing in between.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-07-23 13:14:54 +01:00
committed by GitHub
parent 350dc0cd21
commit ac6d8eb3be
23 changed files with 1028 additions and 56 deletions
+1 -1
View File
@@ -470,7 +470,7 @@ func startAgentInfrastructure(
log.Info("Initializing Orchestrator...")
orchestratorSvc, msgCreator, err := provideOrchestrator(cfg, log, dbPool, eventBus, repos.Task, services.Task, services.User,
lifecycleMgr, agentRegistry, services.Workflow, repos.Secrets, repoCloner)
lifecycleMgr, agentRegistry, services.Workflow, repos.Secrets, repoCloner, services.Prompts)
if err != nil {
log.Error("Failed to initialize orchestrator", zap.Error(err))
return false
@@ -28,6 +28,7 @@ import (
officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite"
"github.com/kandev/kandev/internal/orchestrator"
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
promptservice "github.com/kandev/kandev/internal/prompts/service"
"github.com/kandev/kandev/internal/repoclone"
"github.com/kandev/kandev/internal/secrets"
sentrypkg "github.com/kandev/kandev/internal/sentry"
@@ -59,6 +60,7 @@ func provideOrchestrator(
workflowSvc *workflowservice.Service,
secretStore secrets.SecretStore,
repoCloner *repoclone.Cloner,
promptSvc *promptservice.Service,
) (*orchestrator.Service, *messageCreatorAdapter, error) {
if lifecycleMgr == nil {
return nil, nil, errors.New("lifecycle manager is required: configure agent runtime (docker or standalone)")
@@ -122,6 +124,12 @@ func provideOrchestrator(
orchestratorSvc.SetWorkflowStepGetter(&orchestratorWorkflowStepGetterAdapter{svc: workflowSvc})
}
// Wire "@name" saved-prompt reference expansion into workflow-step prompt
// building.
if promptSvc != nil {
orchestratorSvc.SetPromptReferenceExpander(promptSvc)
}
// Wire review task creator for auto-creating tasks from review watch PRs
orchestratorSvc.SetReviewTaskCreator(&reviewTaskCreatorAdapter{svc: taskSvc})
+9 -14
View File
@@ -1802,6 +1802,12 @@ func (h *Handlers) resolveExplicitTargetSession(ctx context.Context, msg *ws.Mes
return session, nil
}
// appendPromptReferenceExpansionContext expands "@name" saved-prompt
// references found in prompt via h.promptResolver. The formatting logic
// lives in promptservice.FormatPromptReferenceExpansions (shared with
// promptservice.Service.AppendReferenceExpansions) so this stays a thin
// wiring layer over the resolver interface, which in tests may be a fake
// that only implements ResolvePromptReferences.
func (h *Handlers) appendPromptReferenceExpansionContext(ctx context.Context, prompt string) string {
if h.promptResolver == nil {
return prompt
@@ -1820,21 +1826,10 @@ func (h *Handlers) appendPromptReferenceExpansionContext(ctx context.Context, pr
return prompt + "\n\n" + sysprompt.Wrap(formatPromptReferenceExpansions(expansions))
}
// formatPromptReferenceExpansions delegates to the shared formatter in
// prompts/service so the rendered block stays byte-identical across callers.
func formatPromptReferenceExpansions(expansions []promptservice.PromptReferenceExpansion) string {
var b strings.Builder
b.WriteString("EXPANDED PROMPT REFERENCES: The message above references saved prompts by @name. ")
b.WriteString("Use these expansions as hidden context while preserving the original @mentions.")
for _, expansion := range expansions {
b.WriteString("\n\n### @")
b.WriteString(sanitizePromptExpansionSystemText(expansion.Name))
b.WriteString("\n")
b.WriteString(sanitizePromptExpansionSystemText(expansion.Content))
}
return b.String()
}
func sanitizePromptExpansionSystemText(value string) string {
return strings.ReplaceAll(value, sysprompt.TagEnd, "")
return promptservice.FormatPromptReferenceExpansions(expansions)
}
// handleGetTaskConversation returns paginated conversation history for a task.
@@ -199,6 +199,10 @@ func (s *Service) handleTaskPRCIAutoFix(ctx context.Context, pr *github.TaskPR,
s.recordCIAutomationError(ctx, pr, "no promptable task session for CI auto-fix")
return true
}
// Passthrough CI-fix sessions skip "@name" expansion: the prompt is typed
// straight into the agent CLI's TTY with no <kandev-system> stripping, so a
// hidden expansion block would leak into the terminal verbatim.
prompt = s.expandPromptReferences(ctx, prompt, session.IsPassthrough)
result, err := s.dispatchCIAutomationPromptForPR(ctx, session, pr, prompt, signature, allowNewRound)
if errors.Is(err, errCIAutoFixRoundCapReached) {
s.markCIAutoFixExhausted(ctx, pr)
@@ -326,6 +326,168 @@ func TestHandleTaskPRCIAutomationQueuesFixDedupesAndMerges(t *testing.T) {
}
}
// TestHandleTaskPRCIAutomationAutoFixExpandsPromptReferences verifies that a
// "@name" saved-prompt reference embedded in the (potentially per-task
// override) ci-auto-fix prompt template gets expanded via the configured
// PromptReferenceExpander before being queued for the agent, mirroring the
// expansion contract already covered for workflow-step prompts.
func TestHandleTaskPRCIAutomationAutoFixExpandsPromptReferences(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedTaskAndSession(t, repo, "task-1", "session-1", models.TaskSessionStateRunning)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
expander := &fakePromptReferenceExpander{}
svc.promptExpander = expander
pr := &github.TaskPR{
TaskID: "task-1",
RepositoryID: "repo-1",
Owner: "acme",
Repo: "widget",
PRNumber: 42,
State: "open",
ChecksState: "failure",
}
ghSvc := &mockGitHubService{
ciOptionsResp: &github.TaskCIOptionsResponse{
TaskID: "task-1",
AutoFixEnabled: true,
EffectiveAutoFixPrompt: "Fix the PR using @my-prompt\n\n{{pr.feedback}}",
},
prFeedback: &github.PRFeedback{
Checks: []github.CheckRun{{Name: "unit", Status: "completed", Conclusion: "failure", HTMLURL: "https://ci/unit"}},
},
}
svc.SetGitHubService(ghSvc)
svc.eventBus = bus.NewMemoryEventBus(testLogger())
if err := svc.handleTaskPRCIAutomation(ctx, pr); err != nil {
t.Fatalf("handle auto-fix: %v", err)
}
status := svc.messageQueue.GetStatus(ctx, "session-1")
if status.Count != 1 {
t.Fatalf("expected one queued CI fix prompt, got %+v", status)
}
queued := status.Entries[0].Content
if !strings.Contains(queued, "Fix the PR using @my-prompt") {
t.Fatalf("expected original prompt text preserved, got %q", queued)
}
if !strings.Contains(queued, "<kandev-system>EXPANDED:") {
t.Fatalf("expected saved-prompt reference to be expanded, got %q", queued)
}
}
// TestHandleTaskPRCIAutomationAutoFixNoExpanderLeavesReferenceUnexpanded is
// the regression-safety counterpart: with no PromptReferenceExpander set
// (the default fixture used by other tests in this file), a "@name" token in
// the ci-auto-fix prompt is queued verbatim.
func TestHandleTaskPRCIAutomationAutoFixNoExpanderLeavesReferenceUnexpanded(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedTaskAndSession(t, repo, "task-1", "session-1", models.TaskSessionStateRunning)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
pr := &github.TaskPR{
TaskID: "task-1",
RepositoryID: "repo-1",
Owner: "acme",
Repo: "widget",
PRNumber: 42,
State: "open",
ChecksState: "failure",
}
ghSvc := &mockGitHubService{
ciOptionsResp: &github.TaskCIOptionsResponse{
TaskID: "task-1",
AutoFixEnabled: true,
EffectiveAutoFixPrompt: "Fix the PR using @my-prompt\n\n{{pr.feedback}}",
},
prFeedback: &github.PRFeedback{
Checks: []github.CheckRun{{Name: "unit", Status: "completed", Conclusion: "failure", HTMLURL: "https://ci/unit"}},
},
}
svc.SetGitHubService(ghSvc)
svc.eventBus = bus.NewMemoryEventBus(testLogger())
if err := svc.handleTaskPRCIAutomation(ctx, pr); err != nil {
t.Fatalf("handle auto-fix: %v", err)
}
status := svc.messageQueue.GetStatus(ctx, "session-1")
if status.Count != 1 {
t.Fatalf("expected one queued CI fix prompt, got %+v", status)
}
queued := status.Entries[0].Content
if !strings.Contains(queued, "Fix the PR using @my-prompt") {
t.Fatalf("expected original prompt text preserved, got %q", queued)
}
if strings.Contains(queued, "<kandev-system>EXPANDED:") {
t.Fatalf("expected no expansion when no expander is set, got %q", queued)
}
}
// TestHandleTaskPRCIAutomationAutoFixPassthroughSkipsReferenceExpansion
// verifies that a passthrough CI-fix session (prompt written raw to a PTY,
// with no <kandev-system> stripping step) skips "@name" expansion entirely,
// mirroring the guard already covered for workflow-step prompts. The queued
// prompt must contain the literal "@my-prompt" text with no appended hidden
// expansion block.
func TestHandleTaskPRCIAutomationAutoFixPassthroughSkipsReferenceExpansion(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedTaskAndSession(t, repo, "task-1", "session-1", models.TaskSessionStateRunning)
session, err := repo.GetTaskSession(ctx, "session-1")
if err != nil {
t.Fatalf("failed to load seeded session: %v", err)
}
session.IsPassthrough = true
if err := repo.UpdateTaskSession(ctx, session); err != nil {
t.Fatalf("failed to mark session passthrough: %v", err)
}
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
expander := &fakePromptReferenceExpander{}
svc.promptExpander = expander
pr := &github.TaskPR{
TaskID: "task-1",
RepositoryID: "repo-1",
Owner: "acme",
Repo: "widget",
PRNumber: 42,
State: "open",
ChecksState: "failure",
}
ghSvc := &mockGitHubService{
ciOptionsResp: &github.TaskCIOptionsResponse{
TaskID: "task-1",
AutoFixEnabled: true,
EffectiveAutoFixPrompt: "Fix the PR using @my-prompt\n\n{{pr.feedback}}",
},
prFeedback: &github.PRFeedback{
Checks: []github.CheckRun{{Name: "unit", Status: "completed", Conclusion: "failure", HTMLURL: "https://ci/unit"}},
},
}
svc.SetGitHubService(ghSvc)
svc.eventBus = bus.NewMemoryEventBus(testLogger())
if err := svc.handleTaskPRCIAutomation(ctx, pr); err != nil {
t.Fatalf("handle auto-fix: %v", err)
}
status := svc.messageQueue.GetStatus(ctx, "session-1")
if status.Count != 1 {
t.Fatalf("expected one queued CI fix prompt, got %+v", status)
}
queued := status.Entries[0].Content
if !strings.Contains(queued, "Fix the PR using @my-prompt") {
t.Fatalf("expected original prompt text preserved verbatim, got %q", queued)
}
if strings.Contains(queued, "<kandev-system>EXPANDED:") {
t.Fatalf("expected no expansion for a passthrough session, got %q", queued)
}
if len(expander.calls) != 0 {
t.Fatalf("expected expander not to be invoked for a passthrough session, got %d calls", len(expander.calls))
}
}
func TestHandleTaskPRCIAutomationAutoFixUsesFreshSyncAndFullFeedback(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
@@ -935,7 +935,7 @@ func (s *Service) processOnEnter(ctx context.Context, taskID string, session *mo
// Passthrough path: write prompt directly to PTY stdin.
// By the time processOnEnter runs (from an on_turn_complete transition),
// the agent has finished its previous turn and the PTY is waiting for input.
effectivePrompt := s.buildWorkflowPrompt(taskDescription, step, taskID, sessionID)
effectivePrompt := s.buildWorkflowPrompt(ctx, taskDescription, step, taskID, sessionID, isPassthrough)
if err := s.autoStartPassthroughPrompt(ctx, taskID, session, step.Name, effectivePrompt); err != nil {
s.logger.Error("failed to auto-start passthrough agent for step",
zap.String("task_id", taskID),
@@ -951,7 +951,7 @@ func (s *Service) processOnEnter(ctx context.Context, taskID string, session *mo
// When called from applyEngineTransition (on_turn_complete), processOnEnter
// runs in a goroutine and the session is already WAITING_FOR_INPUT, so
// autoStartStepPrompt sends the prompt directly via PromptTask.
effectivePrompt := s.buildWorkflowPrompt(taskDescription, step, taskID, sessionID)
effectivePrompt := s.buildWorkflowPrompt(ctx, taskDescription, step, taskID, sessionID, isPassthrough)
if err := s.autoStartStepPrompt(ctx, taskID, session, step, effectivePrompt, hasPlanMode, true); err != nil {
s.logger.Error("failed to auto-start agent for step",
zap.String("task_id", taskID),
@@ -967,7 +967,7 @@ func (s *Service) processOnEnter(ctx context.Context, taskID string, session *mo
// has no auto_start_agent, launch the agent anyway — the profile override
// implies the user wants this agent to run on this step.
if sessionSwitched && step.Prompt != "" {
effectivePrompt := s.buildWorkflowPrompt(taskDescription, step, taskID, sessionID)
effectivePrompt := s.buildWorkflowPrompt(ctx, taskDescription, step, taskID, sessionID, isPassthrough)
planMode := hasPlanMode
stepID := step.ID
s.logger.Info("auto-launching agent after profile switch (no explicit auto_start)",
@@ -120,6 +120,13 @@ type WorkflowStepGetter interface {
GetWorkflowAgentProfileID(ctx context.Context, workflowID string) (string, error)
}
// PromptReferenceExpander resolves "@name" saved-prompt references embedded in
// an effective prompt and appends a hidden expansion block. Implemented by
// promptservice.Service.
type PromptReferenceExpander interface {
AppendReferenceExpansions(ctx context.Context, prompt string, log *zap.Logger) string
}
// 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).
@@ -250,6 +257,11 @@ type Service struct {
// Workflow step getter for prompt building
workflowStepGetter WorkflowStepGetter
// Prompt reference expander for resolving "@name" saved-prompt
// references in the effective workflow-step prompt. Nil-safe: when
// unset, buildWorkflowPrompt leaves the prompt unchanged.
promptExpander PromptReferenceExpander
// Workflow engine for typed state-machine evaluation of step transitions
workflowEngine *engine.Engine
workflowStore *workflowStore
@@ -778,6 +790,15 @@ func (s *Service) SetWorkflowStepGetter(getter WorkflowStepGetter) {
s.initWorkflowEngine()
}
// SetPromptReferenceExpander sets the collaborator used to resolve "@name"
// saved-prompt references embedded in workflow-step prompts.
//
// If not set: buildWorkflowPrompt leaves any "@name" references in the
// effective prompt unexpanded.
func (s *Service) SetPromptReferenceExpander(e PromptReferenceExpander) {
s.promptExpander = e
}
// ClarificationCanceller detaches in-memory clarification waiters when an agent's
// turn completes while questions are still pending in the DB.
type ClarificationCanceller interface {
@@ -422,7 +422,7 @@ func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, ag
if err != nil {
return nil, fmt.Errorf("failed to reload task after on_turn_start: %w", err)
}
effectivePrompt, planModeActive := s.applyWorkflowAndPlanMode(ctx, effectivePrompt, taskID, sessionID, dbTask.WorkflowStepID, planMode, task.IsEphemeral)
effectivePrompt, planModeActive := s.applyWorkflowAndPlanMode(ctx, effectivePrompt, taskID, sessionID, dbTask.WorkflowStepID, planMode, task.IsEphemeral, session.IsPassthrough)
// Inject config context for config-mode sessions (dedicated settings chat)
configMode := false
@@ -683,7 +683,20 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s
s.tagSessionAsWorkflowSwitched(ctx, sessionID)
}
effectivePrompt, planModeActive := s.applyWorkflowAndPlanMode(ctx, effectivePrompt, task.ID, sessionID, workflowStepID, planMode, task.IsEphemeral)
// Passthrough sessions skip both the workflow-prompt "@name" expansion below
// and the Kandev MCP wrap further down: 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 or a hidden expansion block prepended to
// "hello". Use the session snapshot, not a live profile lookup, so a
// mid-run profile edit cannot change wrap behavior. Looked up once here and
// reused below so we don't hit GetTaskSession twice for the same fact.
// If the lookup fails, resolveIsPassthroughForLaunch fails safe by
// treating the session as passthrough: skipping the wrap/expansion is
// strictly less harmful than leaking hidden <kandev-system> content into
// a real passthrough session's PTY.
isPassthrough := s.resolveIsPassthroughForLaunch(ctx, sessionID)
effectivePrompt, planModeActive := s.applyWorkflowAndPlanMode(ctx, effectivePrompt, task.ID, sessionID, workflowStepID, planMode, task.IsEphemeral, isPassthrough)
// Inject config context for config-mode sessions (dedicated settings chat)
configMode := false
@@ -702,14 +715,7 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s
// recordAutoStartMessage) wrap before recording the user message so the DB
// row carries the block; the HasKandevContext guard makes this orchestrator
// pass a no-op in those cases instead of double-wrapping.
// 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". Use the session snapshot, not a
// live profile lookup, so a mid-run profile edit cannot change wrap behavior.
skipKandevMCPWrap := false
if launchSession, sessErr := s.repo.GetTaskSession(ctx, sessionID); sessErr == nil {
skipKandevMCPWrap = launchSession.IsPassthrough
}
skipKandevMCPWrap := isPassthrough
// `task` here is *v1.Task from the scheduler, which does NOT carry the
// orchestrator's WorkflowStepID — go through the task-ID variant so the
// repo lookup pulls the canonical step. Using the workflowStepID parameter
@@ -794,6 +800,23 @@ func (s *Service) prepareSessionForStart(
return sessionID, nil
}
// resolveIsPassthroughForLaunch reloads the session snapshot to decide
// whether this launch is a passthrough session (skip the Kandev MCP wrap and
// the "@name" prompt-reference expansion). If the reload fails, this fails
// safe by treating the session as passthrough: skipping the wrap/expansion is
// strictly less harmful than leaking hidden <kandev-system> content into a
// real passthrough session's PTY.
func (s *Service) resolveIsPassthroughForLaunch(ctx context.Context, sessionID string) bool {
launchSession, sessErr := s.repo.GetTaskSession(ctx, sessionID)
if sessErr != nil {
s.logger.Warn("failed to reload session for passthrough check; defaulting to passthrough (skip hidden-content injection) as the safer failure mode",
zap.String("session_id", sessionID),
zap.Error(sessErr))
return true
}
return launchSession.IsPassthrough
}
// createStartSession picks the right session-creation path for the task:
// office tasks with an assignee use the per-(task, agent) EnsureSessionForAgent
// (so runs reuse one row across turns); kanban / quick-chat fall through to
@@ -934,7 +957,7 @@ 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) (string, bool) {
func (s *Service) applyWorkflowAndPlanMode(ctx context.Context, prompt string, taskID string, sessionID string, workflowStepID string, planMode bool, isEphemeral bool, isPassthrough bool) (string, bool) {
effectivePrompt := prompt
stepHasPlanMode := false
@@ -947,7 +970,7 @@ func (s *Service) applyWorkflowAndPlanMode(ctx context.Context, prompt string, t
zap.Error(err))
} else {
stepHasPlanMode = step.HasOnEnterAction(wfmodels.OnEnterEnablePlanMode)
effectivePrompt = s.buildWorkflowPrompt(effectivePrompt, step, taskID, sessionID)
effectivePrompt = s.buildWorkflowPrompt(ctx, effectivePrompt, step, taskID, sessionID, isPassthrough)
}
}
@@ -1009,7 +1032,7 @@ func (s *Service) recordInitialMessage(ctx context.Context, taskID, sessionID, p
// Otherwise, step.Prompt fully replaces the base prompt.
// If the step has enable_plan_mode in on_enter events, plan mode prefix is also prepended.
// Only true internal instructions are wrapped in <kandev-system> tags so they can be stripped from the visible chat.
func (s *Service) buildWorkflowPrompt(basePrompt string, step *wfmodels.WorkflowStep, taskID string, sessionID string) string {
func (s *Service) buildWorkflowPrompt(ctx context.Context, basePrompt string, step *wfmodels.WorkflowStep, taskID string, sessionID string, isPassthrough bool) string {
_ = sessionID
var parts []string
@@ -1029,7 +1052,25 @@ func (s *Service) buildWorkflowPrompt(basePrompt string, step *wfmodels.Workflow
parts = append(parts, basePrompt)
}
return strings.Join(parts, "\n\n")
joined := strings.Join(parts, "\n\n")
return s.expandPromptReferences(ctx, joined, isPassthrough)
}
// expandPromptReferences resolves "@name" saved-prompt references in prompt
// via the configured PromptReferenceExpander. When no expander is set, prompt
// is returned unchanged. Passthrough sessions skip expansion entirely: the
// prompt is written raw to a PTY with no <kandev-system> stripping step, so
// the expansion's hidden wrapper block would be typed into the terminal
// verbatim instead of staying hidden.
func (s *Service) expandPromptReferences(ctx context.Context, prompt string, isPassthrough bool) string {
if s.promptExpander == nil || isPassthrough {
return prompt
}
var zapLogger *zap.Logger
if s.logger != nil {
zapLogger = s.logger.Zap()
}
return s.promptExpander.AppendReferenceExpansions(ctx, prompt, zapLogger)
}
// ResumeTaskSession restarts a specific task session using its stored worktree.
@@ -1278,7 +1319,7 @@ func (s *Service) StartSessionForWorkflowStep(ctx context.Context, taskID, sessi
s.advanceTaskWorkflowStep(ctx, dbTask, workflowStepID, session)
effectivePrompt := s.buildWorkflowPrompt(dbTask.Description, step, taskID, sessionID)
effectivePrompt := s.buildWorkflowPrompt(ctx, dbTask.Description, step, taskID, sessionID, session.IsPassthrough)
if err := s.ensureSessionRunning(ctx, sessionID, session); err != nil {
return err
@@ -11,6 +11,61 @@ import (
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// getTaskSessionErrorRepo wraps a sessionExecutorStore and forces
// GetTaskSession to fail for every call, regardless of session ID. Used to
// exercise resolveIsPassthroughForLaunch's fail-safe fallback.
type getTaskSessionErrorRepo struct {
sessionExecutorStore
err error
}
func (r getTaskSessionErrorRepo) GetTaskSession(context.Context, string) (*models.TaskSession, error) {
return nil, r.err
}
// TestResolveIsPassthroughForLaunch_FailsSafeOnLookupError proves the fix for
// the coderabbitai review finding on PR #1865: a transient GetTaskSession
// error during the passthrough check must default to isPassthrough=true (skip
// the Kandev MCP wrap and "@name" prompt-reference expansion), not false.
// Failing open on a lookup error for a session that IS actually passthrough
// would inject the hidden <kandev-system> block into a real terminal PTY —
// exactly the leak this check exists to prevent.
func TestResolveIsPassthroughForLaunch_FailsSafeOnLookupError(t *testing.T) {
repo := setupTestRepo(t)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
svc.repo = getTaskSessionErrorRepo{sessionExecutorStore: svc.repo, err: errors.New("transient lookup failure")}
got := svc.resolveIsPassthroughForLaunch(context.Background(), "session1")
if !got {
t.Fatalf("resolveIsPassthroughForLaunch() on GetTaskSession error = %v, want true (fail-safe)", got)
}
}
// TestResolveIsPassthroughForLaunch_UsesSessionSnapshotOnSuccess proves the
// success path is unchanged: it returns the session's stored IsPassthrough
// value rather than always defaulting true.
func TestResolveIsPassthroughForLaunch_UsesSessionSnapshotOnSuccess(t *testing.T) {
repo := setupTestRepo(t)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCreated)
session, err := repo.GetTaskSession(context.Background(), "session1")
if err != nil {
t.Fatalf("failed to load seeded session: %v", err)
}
session.IsPassthrough = false
if err := repo.UpdateTaskSession(context.Background(), session); err != nil {
t.Fatalf("failed to persist passthrough flag: %v", err)
}
got := svc.resolveIsPassthroughForLaunch(context.Background(), "session1")
if got {
t.Fatalf("resolveIsPassthroughForLaunch() = %v, want false (session snapshot IsPassthrough)", got)
}
}
// TestPromptTask_PassthroughRoutesToPTYStdin walks the full Service.PromptTask
// → Executor.Prompt → promptPassthrough path for a passthrough session and
// asserts the prompt reaches PTY stdin (not the ACP PromptAgent) with the
@@ -5,9 +5,31 @@ import (
"strings"
"testing"
"go.uber.org/zap"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
)
// fakePromptReferenceExpander is a test double for PromptReferenceExpander.
// It appends a deterministic marker for any "@name" reference found in the
// prompt, mirroring the "resolves then appends a hidden block" contract of
// promptservice.Service.AppendReferenceExpansions without depending on the
// real prompt-resolution machinery.
type fakePromptReferenceExpander struct {
// calls records every prompt passed to AppendReferenceExpansions, so
// tests can assert what the expander actually saw (e.g. that template
// interpolation already ran).
calls []string
}
func (f *fakePromptReferenceExpander) AppendReferenceExpansions(_ context.Context, prompt string, _ *zap.Logger) string {
f.calls = append(f.calls, prompt)
if !strings.Contains(prompt, "@") {
return prompt
}
return prompt + "\n\n<kandev-system>EXPANDED:" + prompt + "</kandev-system>"
}
func TestBuildWorkflowPrompt_ReplacesTaskPromptPlaceholder(t *testing.T) {
svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo())
step := &wfmodels.WorkflowStep{
@@ -15,7 +37,7 @@ func TestBuildWorkflowPrompt_ReplacesTaskPromptPlaceholder(t *testing.T) {
Prompt: "Implement this exactly:\n\n{{task_prompt}}",
}
got := svc.buildWorkflowPrompt("Migrate Atlantis datasource.", step, "task-1", "session-1")
got := svc.buildWorkflowPrompt(context.Background(), "Migrate Atlantis datasource.", step, "task-1", "session-1", false)
want := "Implement this exactly:\n\nMigrate Atlantis datasource."
if got != want {
@@ -30,7 +52,7 @@ func TestBuildWorkflowPrompt_UsesStepPromptOnlyWithoutTaskPromptPlaceholder(t *t
Prompt: "Commit the changes, push and create a draft PR.",
}
got := svc.buildWorkflowPrompt("Migrate Atlantis datasource.", step, "task-1", "session-1")
got := svc.buildWorkflowPrompt(context.Background(), "Migrate Atlantis datasource.", step, "task-1", "session-1", false)
want := "Commit the changes, push and create a draft PR."
if got != want {
@@ -38,6 +60,103 @@ func TestBuildWorkflowPrompt_UsesStepPromptOnlyWithoutTaskPromptPlaceholder(t *t
}
}
func TestBuildWorkflowPrompt_NoExpanderLeavesReferenceUnexpanded(t *testing.T) {
svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo())
step := &wfmodels.WorkflowStep{
ID: "step-1",
Prompt: "Use @my-prompt for context.",
}
got := svc.buildWorkflowPrompt(context.Background(), "Migrate Atlantis datasource.", step, "task-1", "session-1", false)
want := "Use @my-prompt for context."
if got != want {
t.Fatalf("buildWorkflowPrompt() with no expander set = %q, want unchanged %q", got, want)
}
}
func TestBuildWorkflowPrompt_ExpandsStepPromptReference(t *testing.T) {
svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo())
expander := &fakePromptReferenceExpander{}
svc.promptExpander = expander
step := &wfmodels.WorkflowStep{
ID: "step-1",
Prompt: "Use @my-prompt for context.",
}
got := svc.buildWorkflowPrompt(context.Background(), "Migrate Atlantis datasource.", step, "task-1", "session-1", false)
if !strings.Contains(got, "Use @my-prompt for context.") {
t.Fatalf("expected original prompt text preserved, got %q", got)
}
if !strings.Contains(got, "<kandev-system>EXPANDED:Use @my-prompt for context.</kandev-system>") {
t.Fatalf("expected hidden expansion block appended, got %q", got)
}
}
func TestBuildWorkflowPrompt_PassthroughSkipsReferenceExpansion(t *testing.T) {
svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo())
expander := &fakePromptReferenceExpander{}
svc.promptExpander = expander
step := &wfmodels.WorkflowStep{
ID: "step-1",
Prompt: "Use @my-prompt for context.",
}
got := svc.buildWorkflowPrompt(context.Background(), "Migrate Atlantis datasource.", step, "task-1", "session-1", true)
want := "Use @my-prompt for context."
if got != want {
t.Fatalf("buildWorkflowPrompt() for passthrough session = %q, want unchanged %q", got, want)
}
if len(expander.calls) != 0 {
t.Fatalf("expected expander not to be invoked for a passthrough session, got %d calls", len(expander.calls))
}
}
func TestBuildWorkflowPrompt_ExpandsBasePromptReference(t *testing.T) {
svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo())
expander := &fakePromptReferenceExpander{}
svc.promptExpander = expander
step := &wfmodels.WorkflowStep{
ID: "step-1",
Prompt: "Implement this exactly:\n\n{{task_prompt}}",
}
got := svc.buildWorkflowPrompt(context.Background(), "Migrate @my-prompt datasource.", step, "task-1", "session-1", false)
want := "Implement this exactly:\n\nMigrate @my-prompt datasource."
if !strings.Contains(got, want) {
t.Fatalf("expected base prompt reference preserved in joined prompt, got %q", got)
}
if !strings.Contains(got, "<kandev-system>EXPANDED:"+want+"</kandev-system>") {
t.Fatalf("expected base-prompt reference to be resolved by the expander, got %q", got)
}
}
func TestBuildWorkflowPrompt_InterpolatesPlaceholdersBeforeExpansion(t *testing.T) {
svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo())
expander := &fakePromptReferenceExpander{}
svc.promptExpander = expander
step := &wfmodels.WorkflowStep{
ID: "step-1",
Prompt: "Task {task_id}: {{task_prompt}} @my-prompt",
}
svc.buildWorkflowPrompt(context.Background(), "Migrate Atlantis datasource.", step, "task-1", "session-1", false)
if len(expander.calls) != 1 {
t.Fatalf("expected expander to be invoked exactly once, got %d calls", len(expander.calls))
}
seen := expander.calls[0]
if strings.Contains(seen, "{{task_prompt}}") || strings.Contains(seen, "{task_id}") {
t.Fatalf("expected placeholders to be interpolated before the expander runs, got %q", seen)
}
if !strings.Contains(seen, "Task task-1: Migrate Atlantis datasource. @my-prompt") {
t.Fatalf("expected fully-assembled prompt passed to expander, got %q", seen)
}
}
func TestApplyWorkflowAndPlanMode_KeepsWorkflowPromptVisibleWhenStepEnablesPlanMode(t *testing.T) {
repo := setupTestRepo(t)
stepGetter := newMockStepGetter()
@@ -58,6 +177,7 @@ func TestApplyWorkflowAndPlanMode_KeepsWorkflowPromptVisibleWhenStepEnablesPlanM
"step-1",
false,
false, // isEphemeral
false, // isPassthrough
)
if !planModeActive {
@@ -73,3 +193,34 @@ func TestApplyWorkflowAndPlanMode_KeepsWorkflowPromptVisibleWhenStepEnablesPlanM
t.Fatalf("expected workflow prompt to remain visible without hidden system wrapping, got %q", got)
}
}
func TestApplyWorkflowAndPlanMode_PassthroughSkipsReferenceExpansion(t *testing.T) {
repo := setupTestRepo(t)
stepGetter := newMockStepGetter()
stepGetter.steps["step-1"] = &wfmodels.WorkflowStep{
ID: "step-1",
Prompt: "Use @my-prompt for context.",
}
svc := createTestService(repo, stepGetter, newMockTaskRepo())
expander := &fakePromptReferenceExpander{}
svc.promptExpander = expander
got, _ := svc.applyWorkflowAndPlanMode(
context.Background(),
"Migrate Atlantis datasource.",
"task-1",
"session-1",
"step-1",
false,
false, // isEphemeral
true, // isPassthrough
)
want := "Use @my-prompt for context."
if got != want {
t.Fatalf("applyWorkflowAndPlanMode() for passthrough session = %q, want unchanged %q", got, want)
}
if len(expander.calls) != 0 {
t.Fatalf("expected expander not to be invoked for a passthrough session, got %d calls", len(expander.calls))
}
}
@@ -0,0 +1,89 @@
package service
import (
"context"
"strings"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/sysprompt"
)
// expansionMarker opens the hidden expansion block written by
// FormatPromptReferenceExpansions. AppendReferenceExpansions checks for its
// presence to stay idempotent: the block's own "### @name" headers are valid
// @mention syntax by the same matching rules used to resolve references, so
// without this guard a second call on an already-expanded prompt would
// re-scan the block and append a duplicate one below it.
const expansionMarker = "EXPANDED PROMPT REFERENCES:"
// expansionBlockPrefix is the exact, literal prefix that
// FormatPromptReferenceExpansions' output has once wrapped by sysprompt.Wrap:
// the opening <kandev-system> tag immediately followed by expansionMarker,
// with nothing in between. AppendReferenceExpansions checks for this exact
// concatenation (not just expansionMarker anywhere inside any
// <kandev-system> block) so that an unrelated system block produced by a
// different code path (e.g. a plan-mode prefix or MCP context wrap) that
// merely happens to mention this phrase somewhere in its content cannot
// false-positive the idempotency guard.
const expansionBlockPrefix = sysprompt.TagStart + expansionMarker
// 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.
//
// It returns prompt unchanged when: prompt already contains a previously
// appended expansion block (detected via expansionBlockPrefix, an exact
// match of "<kandev-system>" immediately followed by expansionMarker, not an
// unscoped substring match against expansionMarker alone), prompt contains
// no "@", when resolution fails (the failure is logged via log, when
// non-nil, and treated as non-fatal), or when no references resolve to a
// known prompt.
//
// AppendReferenceExpansions is deliberately idempotent: calling it a second
// time on a string it already expanded is a safe no-op, so callers do not
// need to track whether expansion already ran.
func (s *Service) AppendReferenceExpansions(ctx context.Context, prompt string, log *zap.Logger) string {
if strings.Contains(prompt, expansionBlockPrefix) {
return prompt
}
if !strings.Contains(prompt, "@") {
return prompt
}
expansions, err := s.ResolvePromptReferences(ctx, prompt)
if err != nil {
if log != nil {
log.Warn("failed to resolve prompt references", zap.Error(err))
}
return prompt
}
if len(expansions) == 0 {
return prompt
}
return prompt + "\n\n" + sysprompt.Wrap(FormatPromptReferenceExpansions(expansions))
}
// FormatPromptReferenceExpansions renders resolved prompt-reference
// expansions into the hidden system-context block appended after a prompt.
// Both name and content are sanitized to strip any embedded
// sysprompt.TagEnd so a saved prompt cannot prematurely close the
// surrounding <kandev-system> wrapper.
func FormatPromptReferenceExpansions(expansions []PromptReferenceExpansion) string {
var b strings.Builder
b.WriteString(expansionMarker + " The message above references saved prompts by @name. ")
b.WriteString("Use these expansions as hidden context while preserving the original @mentions.")
for _, expansion := range expansions {
b.WriteString("\n\n### @")
b.WriteString(sanitizePromptExpansionSystemText(expansion.Name))
b.WriteString("\n")
b.WriteString(sanitizePromptExpansionSystemText(expansion.Content))
}
return b.String()
}
// sanitizePromptExpansionSystemText strips any embedded sysprompt.TagEnd from
// a value before it is written into a <kandev-system>-wrapped block.
func sanitizePromptExpansionSystemText(value string) string {
return strings.ReplaceAll(value, sysprompt.TagEnd, "")
}
@@ -0,0 +1,148 @@
package service
import (
"context"
"strings"
"testing"
"go.uber.org/zap"
"go.uber.org/zap/zaptest"
"github.com/kandev/kandev/internal/sysprompt"
)
func TestService_AppendReferenceExpansions_NoAtSign(t *testing.T) {
svc, cleanup := createService(t)
defer cleanup()
ctx := context.Background()
prompt := "plain text with no reference"
got := svc.AppendReferenceExpansions(ctx, prompt, nil)
if got != prompt {
t.Fatalf("expected unchanged prompt, got %q", got)
}
}
func TestService_AppendReferenceExpansions_UnknownName(t *testing.T) {
svc, cleanup := createService(t)
defer cleanup()
ctx := context.Background()
prompt := "please run @missing-prompt now"
got := svc.AppendReferenceExpansions(ctx, prompt, zaptest.NewLogger(t))
if got != prompt {
t.Fatalf("expected unchanged prompt for unknown reference, got %q", got)
}
}
func TestService_AppendReferenceExpansions_KnownName(t *testing.T) {
svc, cleanup := createService(t)
defer cleanup()
ctx := context.Background()
if _, err := svc.CreatePrompt(ctx, "improve-harness", "Review this session for durable harness improvements."); err != nil {
t.Fatalf("create prompt: %v", err)
}
prompt := "Please run @improve-harness"
got := svc.AppendReferenceExpansions(ctx, prompt, zap.NewNop())
expected := prompt + "\n\n" + sysprompt.Wrap(FormatPromptReferenceExpansions([]PromptReferenceExpansion{
{Name: "improve-harness", Content: "Review this session for durable harness improvements."},
}))
if got != expected {
t.Fatalf("expected %q, got %q", expected, got)
}
}
func TestService_AppendReferenceExpansions_IdempotentOnSecondCall(t *testing.T) {
svc, cleanup := createService(t)
defer cleanup()
ctx := context.Background()
if _, err := svc.CreatePrompt(ctx, "improve-harness", "Review this session for durable harness improvements."); err != nil {
t.Fatalf("create prompt: %v", err)
}
prompt := "Please run @improve-harness"
firstPass := svc.AppendReferenceExpansions(ctx, prompt, zap.NewNop())
if firstPass == prompt {
t.Fatalf("expected first call to append an expansion block")
}
secondPass := svc.AppendReferenceExpansions(ctx, firstPass, zap.NewNop())
if secondPass != firstPass {
t.Fatalf("expected second call to be a no-op, got %q, want %q", secondPass, firstPass)
}
}
func TestService_AppendReferenceExpansions_MarkerInVisibleTextDoesNotSuppressFirstExpansion(t *testing.T) {
svc, cleanup := createService(t)
defer cleanup()
ctx := context.Background()
if _, err := svc.CreatePrompt(ctx, "improve-harness", "Review this session for durable harness improvements."); err != nil {
t.Fatalf("create prompt: %v", err)
}
// The literal marker text appears in ordinary (non-system-block) prompt
// text, alongside a genuine resolvable @ref. An unscoped
// strings.Contains(prompt, expansionMarker) guard would falsely treat
// this as already-expanded and skip the real expansion on its first call.
prompt := "Please document EXPANDED PROMPT REFERENCES: behavior and then run @improve-harness"
got := svc.AppendReferenceExpansions(ctx, prompt, zap.NewNop())
expected := prompt + "\n\n" + sysprompt.Wrap(FormatPromptReferenceExpansions([]PromptReferenceExpansion{
{Name: "improve-harness", Content: "Review this session for durable harness improvements."},
}))
if got != expected {
t.Fatalf("expected marker in visible text not to suppress expansion, got %q, want %q", got, expected)
}
}
func TestService_AppendReferenceExpansions_ForeignSystemBlockMentioningMarkerDoesNotSuppressExpansion(t *testing.T) {
svc, cleanup := createService(t)
defer cleanup()
ctx := context.Background()
if _, err := svc.CreatePrompt(ctx, "improve-harness", "Review this session for durable harness improvements."); err != nil {
t.Fatalf("create prompt: %v", err)
}
// This <kandev-system> block was NOT produced by
// FormatPromptReferenceExpansions: the marker text appears inside it, but
// not as the block's immediate first content. A guard that merely checks
// "expansionMarker appears somewhere inside any <kandev-system> block"
// would false-positive here and skip the real expansion below.
foreignBlock := sysprompt.Wrap("Some unrelated system context that mentions EXPANDED PROMPT REFERENCES: in passing.")
prompt := foreignBlock + "\n\nPlease run @improve-harness"
got := svc.AppendReferenceExpansions(ctx, prompt, zap.NewNop())
expected := prompt + "\n\n" + sysprompt.Wrap(FormatPromptReferenceExpansions([]PromptReferenceExpansion{
{Name: "improve-harness", Content: "Review this session for durable harness improvements."},
}))
if got != expected {
t.Fatalf("expected foreign system block mentioning marker not to suppress expansion, got %q, want %q", got, expected)
}
}
func TestFormatPromptReferenceExpansions_StripsSystemTagEnd(t *testing.T) {
out := FormatPromptReferenceExpansions([]PromptReferenceExpansion{
{Name: "bad</kandev-system>name", Content: "before </kandev-system> after"},
})
if out == "" {
t.Fatal("expected non-empty output")
}
if strings.Contains(out, sysprompt.TagEnd) {
t.Fatalf("expected %q to not contain %q", out, sysprompt.TagEnd)
}
if !strings.Contains(out, "### @badname") {
t.Fatalf("expected %q to contain %q", out, "### @badname")
}
if !strings.Contains(out, "before after") {
t.Fatalf("expected %q to contain %q", out, "before after")
}
}
+10 -1
View File
@@ -63,8 +63,17 @@ const kandevContextMarker = "KANDEV MCP TOOLS"
// block — a user message body that happens to mention "KANDEV MCP TOOLS"
// would not falsely signal that the wrap is already applied.
func HasKandevContext(text string) bool {
return HasSystemMarker(text, kandevContextMarker)
}
// HasSystemMarker reports whether marker appears within a
// <kandev-system>...</kandev-system> block in text. Unlike a plain
// strings.Contains(text, marker), this will not false-positive on
// caller-controlled text that happens to mention marker outside any system
// block — the same scoping HasKandevContext relies on.
func HasSystemMarker(text, marker string) bool {
for _, block := range systemTagRegex.FindAllString(text, -1) {
if strings.Contains(block, kandevContextMarker) {
if strings.Contains(block, marker) {
return true
}
}
@@ -183,6 +183,27 @@ func TestHasKandevContext_DetectsInjectedWrap(t *testing.T) {
assert.False(t, HasKandevContext(userMentions))
}
// --- HasSystemMarker tests ---
func TestHasSystemMarker(t *testing.T) {
const marker = "SOME MARKER"
// Marker present inside a <kandev-system> block -> true.
insideBlock := Wrap("prefix "+marker+" suffix") + "\n\nuser text"
assert.True(t, HasSystemMarker(insideBlock, marker))
// Marker present only outside any system block (plain text) -> false.
outsideOnly := "some visible text that mentions " + marker + " directly"
assert.False(t, HasSystemMarker(outsideOnly, marker))
// Marker present in both places -> true.
both := Wrap("system block with "+marker) + "\n\nuser text mentioning " + marker + " too"
assert.True(t, HasSystemMarker(both, marker))
// No system block at all -> false.
assert.False(t, HasSystemMarker("plain text with no tags", marker))
}
// --- StripSystemContent tests ---
func TestStripSystemContent_NoTags(t *testing.T) {
@@ -8,6 +8,7 @@ import {
computeEditorHeight,
} from "@/components/settings/profile-edit/script-editor";
import { REVIEW_WATCH_PLACEHOLDERS } from "@/components/github/review-watch-placeholders";
import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts";
// Pulled out of review-watch-dialog.tsx to keep that file under the 600-line
// linter cap; the prompt editor + its placeholder help bubble are co-owned by
@@ -43,6 +44,7 @@ export function ReviewWatchPromptField({
value: string;
onChange: (v: string) => void;
}) {
const { prompts } = useCustomPrompts();
return (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
@@ -50,7 +52,9 @@ export function ReviewWatchPromptField({
<PlaceholdersHelp />
</div>
<p className="text-xs text-muted-foreground">
The prompt sent to the agent for each new PR. Type {"{{"} to insert placeholders.
The prompt sent to the agent for each new PR. Type {"{{"} to insert placeholders, or {"@"}{" "}
to reference a saved prompt by name — it expands the same way it does in workflow step
prompts, since this prompt becomes the task description passed through that same assembly.
</p>
<div className="rounded-md border border-border overflow-hidden">
<ScriptEditor
@@ -60,6 +64,7 @@ export function ReviewWatchPromptField({
height={computeEditorHeight(value)}
lineNumbers="off"
placeholders={REVIEW_WATCH_PLACEHOLDERS}
mentionPrompts={prompts}
/>
</div>
<p className="text-xs text-muted-foreground/70">
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { createPromptMentionCompletionProvider } from "./script-editor-completions";
import type { PromptReference } from "@/lib/prompts/expand-prompt-references";
import type { editor, languages, Position } from "monaco-editor";
const monacoStub = {
languages: {
CompletionItemKind: { Reference: 17 },
},
} as unknown as typeof import("monaco-editor");
function modelForLine(line: string): editor.ITextModel {
return {
getLineContent: () => line,
} as unknown as editor.ITextModel;
}
function complete(
prompts: PromptReference[],
line: string,
column: number,
): languages.CompletionList {
const provider = createPromptMentionCompletionProvider(monacoStub, prompts);
const result = provider.provideCompletionItems(
modelForLine(line),
{ lineNumber: 1, column } as unknown as Position,
{} as languages.CompletionContext,
{} as import("monaco-editor").CancellationToken,
);
return result as languages.CompletionList;
}
const prompts: PromptReference[] = [
{ id: "1", name: "review", content: "Review this code carefully for bugs." },
{ id: "2", name: "release-notes", content: "Write release notes." },
];
describe("createPromptMentionCompletionProvider", () => {
it("has @ as its only trigger character", () => {
const provider = createPromptMentionCompletionProvider(monacoStub, prompts);
expect(provider.triggerCharacters).toEqual(["@"]);
});
it("suggests prompts when @ is at the start of a line", () => {
const { suggestions } = complete(prompts, "@", 2);
expect(suggestions.map((s) => (s.label as { label: string }).label ?? s.label)).toEqual([
"@review",
"@release-notes",
]);
});
it("suggests prompts when @ follows whitespace", () => {
const { suggestions } = complete(prompts, "please @re", 11);
expect(suggestions).toHaveLength(2);
expect(suggestions[0].insertText).toBe("review");
});
it("does not suggest when @ is mid-word", () => {
const { suggestions } = complete(prompts, "foo@bar", 8);
expect(suggestions).toHaveLength(0);
});
it("does not suggest when there is no @ before the cursor", () => {
const { suggestions } = complete(prompts, "hello", 6);
expect(suggestions).toHaveLength(0);
});
it("returns no suggestions when prompts list is empty", () => {
const { suggestions } = complete([], "@", 2);
expect(suggestions).toHaveLength(0);
});
it("includes a content preview in detail/documentation", () => {
const { suggestions } = complete(prompts, "@", 2);
expect(suggestions[0].detail).toBe("Review this code carefully for bugs.");
});
});
@@ -1,4 +1,5 @@
import type { languages, editor, IRange } from "monaco-editor";
import type { PromptReference } from "@/lib/prompts/expand-prompt-references";
export type ScriptPlaceholder = {
key: string;
@@ -64,3 +65,65 @@ export function createPlaceholderCompletionProvider(
},
};
}
const MENTION_NAME_PATTERN = /[\w-]*$/;
function isMentionStartColumn(line: string, atColumn: number): boolean {
if (atColumn === 1) return true;
const charBefore = line[atColumn - 2];
return charBefore === " " || charBefore === "\t" || charBefore === "\r";
}
function previewContent(content: string): string {
const firstLine = content.split("\n")[0]?.trim() ?? "";
return firstLine.length > 80 ? `${firstLine.slice(0, 80)}…` : firstLine;
}
/**
* Creates a Monaco CompletionItemProvider that suggests saved custom prompts
* as @name mentions. Triggers on `@`, only at a valid mention start (start of
* line or preceded by whitespace), matching the same rule as
* `matchPromptMention`/`isPromptReferenceStart`.
*/
export function createPromptMentionCompletionProvider(
monaco: typeof import("monaco-editor"),
prompts: PromptReference[],
): languages.CompletionItemProvider {
return {
triggerCharacters: ["@"],
provideCompletionItems(
model: editor.ITextModel,
position: { lineNumber: number; column: number },
): languages.ProviderResult<languages.CompletionList> {
const line = model.getLineContent(position.lineNumber);
const textBefore = line.substring(0, position.column - 1);
const nameMatch = textBefore.match(MENTION_NAME_PATTERN);
const namePrefix = nameMatch ? nameMatch[0] : "";
const atColumn = position.column - namePrefix.length - 1;
if (atColumn < 1 || line[atColumn - 1] !== "@" || !isMentionStartColumn(line, atColumn)) {
return { suggestions: [] };
}
const range: IRange = {
startLineNumber: position.lineNumber,
startColumn: atColumn + 1,
endLineNumber: position.lineNumber,
endColumn: position.column,
};
const suggestions: languages.CompletionItem[] = prompts.map((prompt, i) => ({
label: `@${prompt.name}`,
kind: monaco.languages.CompletionItemKind.Reference,
detail: previewContent(prompt.content),
documentation: previewContent(prompt.content),
insertText: prompt.name,
range,
sortText: String(i).padStart(3, "0"),
}));
return { suggestions };
},
};
}
@@ -4,8 +4,10 @@ import { useEffect, useRef, useCallback } from "react";
import dynamic from "@/lib/routing/client-dynamic";
import type { BeforeMount, OnMount } from "@monaco-editor/react";
import { KANDEV_MONACO_DARK } from "@/lib/theme/editor-theme";
import type { PromptReference } from "@/lib/prompts/expand-prompt-references";
import {
createPlaceholderCompletionProvider,
createPromptMentionCompletionProvider,
type ScriptPlaceholder,
} from "./script-editor-completions";
@@ -24,23 +26,37 @@ export type CompletionProviderFactory = (
monaco: Monaco,
) => import("monaco-editor").languages.CompletionItemProvider;
// Per-language singletons so a markdown editor and a shell editor can coexist
// without their providers fighting over the same global slot.
const disposablesByLanguage: Map<string, { dispose: () => void }> = new Map();
// Per-language, per-slot singletons so a markdown editor and a shell editor
// can coexist without their providers fighting over the same global slot.
// "primary" holds the placeholder/custom completion provider; "mention"
// holds the @prompt-name mention provider — both can be registered at once.
type ProviderSlot = "primary" | "mention";
const disposablesByKey: Map<string, { dispose: () => void }> = new Map();
const instanceCountsByLanguage: Map<string, number> = new Map();
function disposeFor(language: string) {
const existing = disposablesByLanguage.get(language);
function slotKey(language: string, slot: ProviderSlot) {
return `${language}:${slot}`;
}
function disposeFor(language: string, slot: ProviderSlot) {
const key = slotKey(language, slot);
const existing = disposablesByKey.get(key);
if (existing) {
existing.dispose();
disposablesByLanguage.delete(language);
disposablesByKey.delete(key);
}
}
function swapProvider(monaco: Monaco, language: string, factory: CompletionProviderFactory) {
disposeFor(language);
disposablesByLanguage.set(
language,
function swapProvider(
monaco: Monaco,
language: string,
slot: ProviderSlot,
factory: CompletionProviderFactory,
) {
disposeFor(language, slot);
disposablesByKey.set(
slotKey(language, slot),
monaco.languages.registerCompletionItemProvider(language, factory(monaco)),
);
}
@@ -48,7 +64,8 @@ function swapProvider(monaco: Monaco, language: string, factory: CompletionProvi
function unregisterProvider(language: string) {
const next = (instanceCountsByLanguage.get(language) ?? 1) - 1;
if (next <= 0) {
disposeFor(language);
disposeFor(language, "primary");
disposeFor(language, "mention");
instanceCountsByLanguage.delete(language);
return;
}
@@ -77,6 +94,12 @@ type ScriptEditorProps = {
* file-path autocomplete).
*/
completionProvider?: CompletionProviderFactory;
/**
* Saved custom prompts to suggest as `@name` mentions. Registered
* alongside the placeholder/custom completion provider (independent
* trigger character, so both can be active at once).
*/
mentionPrompts?: PromptReference[];
readOnly?: boolean;
lineNumbers?: "on" | "off";
};
@@ -89,6 +112,7 @@ export function ScriptEditor({
placeholders,
executorType,
completionProvider,
mentionPrompts,
readOnly = false,
lineNumbers = "on",
}: ScriptEditorProps) {
@@ -107,14 +131,33 @@ export function ScriptEditor({
const ensureProviderRegistered = useCallback(
(monaco: Monaco) => {
const factory = resolveFactory(completionProvider, placeholders, executorType);
if (!factory) return;
const mentionFactory = resolveMentionFactory(mentionPrompts);
if (!factory && !mentionFactory) {
// Nothing to register. If a previous render had registered providers
// for this instance, dispose them now so stale suggestions (e.g. a
// deleted prompt list) don't linger while the editor stays mounted.
if (mountedRef.current) {
disposeFor(language, "primary");
disposeFor(language, "mention");
}
return;
}
if (!mountedRef.current) {
mountedRef.current = true;
instanceCountsByLanguage.set(language, (instanceCountsByLanguage.get(language) ?? 0) + 1);
}
swapProvider(monaco, language, factory);
if (factory) {
swapProvider(monaco, language, "primary", factory);
} else {
disposeFor(language, "primary");
}
if (mentionFactory) {
swapProvider(monaco, language, "mention", mentionFactory);
} else {
disposeFor(language, "mention");
}
},
[completionProvider, placeholders, executorType, language],
[completionProvider, placeholders, executorType, mentionPrompts, language],
);
useEffect(() => {
@@ -176,3 +219,10 @@ function resolveFactory(
}
return null;
}
function resolveMentionFactory(
mentionPrompts: PromptReference[] | undefined,
): CompletionProviderFactory | null {
if (!mentionPrompts || mentionPrompts.length === 0) return null;
return (monaco) => createPromptMentionCompletionProvider(monaco, mentionPrompts);
}
@@ -9,6 +9,7 @@ import { Label } from "@kandev/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import type { WorkflowStep } from "@/lib/types/http";
import { useHealthyAgentProfiles } from "@/hooks/domains/settings/use-healthy-agent-profiles";
import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts";
import { useDebouncedCallback } from "@/hooks/use-debounce";
import { cn } from "@/lib/utils";
import {
@@ -490,6 +491,7 @@ function StepPromptSection({
debouncedUpdatePrompt,
readOnly,
}: StepPromptSectionProps) {
const { prompts } = useCustomPrompts();
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
@@ -535,12 +537,16 @@ function StepPromptSection({
lineNumbers="off"
readOnly={readOnly}
placeholders={STEP_PROMPT_PLACEHOLDERS}
mentionPrompts={prompts}
/>
</div>
<p className="text-[11px] text-muted-foreground/60">
Type {"{{"} to see available placeholders. Use{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{task_prompt}}"}</code> to
include the original task description.
Type {"{{"} for placeholders (
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{task_prompt}}"}</code>{" "}
inserts the task description) or {"@"} to reference a saved prompt by name — its content is
attached as hidden context, and editing the saved prompt updates every step that references
it. Note: {"{{task_prompt}}"} only expands in the step prompt itself, not inside a
referenced saved prompt.
</p>
</div>
);
@@ -39,6 +39,61 @@ test.describe("Workflow step prompt autocomplete", () => {
await expect(suggestion).toContainText("task_prompt");
});
test("shows and inserts a saved prompt mention when typing @ in step prompt editor", async ({
testPage,
seedData,
apiClient,
}) => {
const promptName = "e2e-mention-prompt";
await apiClient.createPrompt(promptName, "Some reusable prompt content for e2e mentions.");
try {
const page = new WorkflowSettingsPage(testPage);
await page.goto(seedData.workspaceId);
const card = await page.findWorkflowCard("E2E Workflow");
await expect(card).toBeVisible();
// Click first step to open config panel
const stepNodes = card.locator(".group.relative");
await stepNodes.first().click();
await testPage.waitForTimeout(500);
// Wait for the ScriptEditor (Monaco) to mount inside the step config panel
const monacoEditor = card.locator(".monaco-editor");
await expect(monacoEditor).toBeVisible({ timeout: 10_000 });
// Click into the editor to focus it
await monacoEditor.click();
await testPage.waitForTimeout(300);
// Type @ to trigger the prompt-mention autocomplete
await testPage.keyboard.type("@");
await testPage.waitForTimeout(500);
// The Monaco suggest widget should appear with the seeded prompt
const suggestWidget = testPage.locator(".monaco-editor .suggest-widget");
await expect(suggestWidget).toBeVisible({ timeout: 5_000 });
const suggestion = suggestWidget.locator(".monaco-list-row").filter({
hasText: promptName,
});
await expect(suggestion.first()).toBeVisible();
// Accept the suggestion and verify the editor content now contains the mention.
await suggestion.first().click();
await testPage.waitForTimeout(300);
await expect(monacoEditor).toContainText(`@${promptName}`);
} finally {
const { prompts } = await apiClient.listPrompts();
const created = prompts.find((p) => p.name === promptName);
if (created) {
await apiClient.deletePrompt(created.id).catch(() => undefined);
}
}
});
test("persists step agent profile selection after change", async ({
testPage,
seedData,
+2
View File
@@ -79,6 +79,8 @@ Open **Settings > Prompts** (`/settings/prompts`) to add, edit, or delete reusab
Type `@` in the task chat composer and select a prompt. The visible message keeps the `@name`; Kandev expands the prompt content into hidden system context for the agent. References are recognized only at the start of the text or after whitespace and must match the stored name. Prompt content can reference other saved prompts. Expansion stops at a depth of eight, skips cycles, and includes each prompt only once.
The same `@name` reference works in a workflow step's Prompt field and in a GitHub Review Watch's prompt; see [Saved prompt references in step prompts](workflow-tips.md#saved-prompt-references-in-step-prompts).
Kandev seeds these built-ins:
- `code-review`
+1 -1
View File
@@ -65,7 +65,7 @@ The status panel reports the authenticated user, selected method, rate-limit inf
Workspace GitHub settings control repository scope, default/saved searches, quick-action prompts, pull-request analytics, review watches, and issue watches. At `/github`, search or browse pull requests and issues, save queries, apply prompt presets, and launch a Kandev task. A saved query can default to one repository; choose **All repos** for no repository default, and change the repository filter without rewriting the saved query. An associated pull request also appears in task review surfaces for feedback, checks, reviews, and merge actions.
A **Review Watch** polls a GitHub search and creates review work. It requires a workflow, starting step, prompt, and workspace. The default query is `type:pr state:open review-requested:@me -is:draft`; add repository filters or replace the query as needed. An optional agent or executor profile overrides the selected step's defaults. The poll interval defaults to 300 seconds and accepts 60–3,600 seconds.
A **Review Watch** polls a GitHub search and creates review work. It requires a workflow, starting step, prompt, and workspace. The default query is `type:pr state:open review-requested:@me -is:draft`; add repository filters or replace the query as needed. An optional agent or executor profile overrides the selected step's defaults. The poll interval defaults to 300 seconds and accepts 60–3,600 seconds. The prompt field accepts `@name` references to saved prompts, resolved the same way as in a workflow step; see [Saved prompt references in step prompts](workflow-tips.md#saved-prompt-references-in-step-prompts).
An **Issue Watch** behaves similarly for issues. Its default search is `type:issue state:open`. Choose labels or provide a custom GitHub query; the custom query takes precedence over label selection.
+11 -1
View File
@@ -79,7 +79,7 @@ Workflow-level settings include the name and default agent profile. A step can o
| Control | Behavior |
|---------|----------|
| Name and color | Board label and presentation. Color is stored as a CSS utility class. |
| Prompt | Step-specific agent prompt. `{{task_prompt}}` inserts the task description. |
| Prompt | Step-specific agent prompt. `{{task_prompt}}` inserts the task description. Type `@` to reference a saved prompt by name. |
| Start step | Preferred initial step. The editor keeps at most one. If none is set, task creation falls back to the first step by position. |
| Auto-start agent | Adds `auto_start_agent` to `on_enter`. It still needs a valid agent and executor configuration. |
| Plan mode | Adds `enable_plan_mode` on entry. Add the matching disable behavior on completion or exit when later steps should edit files. |
@@ -117,6 +117,16 @@ Keep one transition action per event. A “next” action on the last step or
5. Add WIP limits before pull rules, then test a full target and a vacated slot.
6. Export the workflow before a large edit. Workflow deletion is permanent; when it contains tasks, the UI asks you to migrate them or archive them.
## Saved prompt references in step prompts
A step's Prompt field accepts `@name` references to [saved prompts](developer-tools.md#saved-prompts) (**Settings > Prompts**), the same way task chat does. Type `@` and select a prompt, or type the name directly.
- The reference is resolved when the step prompt runs, not when it is saved. Editing the saved prompt's content later automatically changes what every step referencing it sends next time — there is nothing to update on the step itself.
- The `@name` mention stays visible in the prompt/chat. Kandev attaches the referenced prompt's content as hidden context for the agent; it is not shown as part of the visible conversation.
- `{{task_prompt}}` is only interpolated in the step prompt field itself. If a referenced saved prompt's content contains `{{task_prompt}}`, it is **not** expanded — it is sent to the agent as literal text.
The same `@name` syntax and resolution apply to a GitHub Review Watch's prompt field. See [Integrations](integrations.md#configure-and-use-the-workspace).
## Repository instructions and multiple repositories
Step prompts are combined with the selected agent and the checked-out repository. Keep repository-specific instructions such as `AGENTS.md`, `CLAUDE.md`, skills, test commands, and MCP configuration in that repository. Kandev does not make one repository's agent rules automatically authoritative for another.