fix: expand nested custom prompts (#1637)
This commit is contained in:
@@ -1112,6 +1112,7 @@ func registerMCPAndDebugRoutes(
|
||||
// Wire config-mode dependencies for agent-native configuration
|
||||
mcpHandlers.SetConfigDeps(p.services.Workflow, p.agentSettingsController, p.mcpConfigSvc)
|
||||
mcpHandlers.SetClarificationInputPauser(p.orchestratorSvc)
|
||||
mcpHandlers.SetPromptReferenceResolver(p.services.Prompts)
|
||||
|
||||
// Enrich list_tasks responses with associated GitHub PRs (link, title,
|
||||
// number, state) when the github service is available.
|
||||
|
||||
@@ -21,6 +21,8 @@ import (
|
||||
"github.com/kandev/kandev/internal/orchestrator"
|
||||
"github.com/kandev/kandev/internal/orchestrator/executor"
|
||||
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
|
||||
promptservice "github.com/kandev/kandev/internal/prompts/service"
|
||||
"github.com/kandev/kandev/internal/sysprompt"
|
||||
"github.com/kandev/kandev/internal/task/dto"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite"
|
||||
@@ -99,6 +101,13 @@ type MessageQueuer interface {
|
||||
TakeQueued(ctx context.Context, sessionID string) (*messagequeue.QueuedMessage, bool)
|
||||
}
|
||||
|
||||
// PromptReferenceResolver expands saved prompt references that appear inside
|
||||
// agent-sent prompts while preserving the original @mentions in the visible
|
||||
// prompt body.
|
||||
type PromptReferenceResolver interface {
|
||||
ResolvePromptReferences(ctx context.Context, content string) ([]promptservice.PromptReferenceExpansion, error)
|
||||
}
|
||||
|
||||
// Handlers provides MCP WebSocket handlers.
|
||||
type Handlers struct {
|
||||
taskSvc *service.Service
|
||||
@@ -113,6 +122,7 @@ type Handlers struct {
|
||||
planService *service.PlanService
|
||||
sessionLauncher SessionLauncher
|
||||
messageQueue MessageQueuer
|
||||
promptResolver PromptReferenceResolver
|
||||
logger *logger.Logger
|
||||
|
||||
// Config-mode dependencies (optional, set via SetConfigDeps)
|
||||
@@ -167,6 +177,10 @@ func (h *Handlers) SetClarificationInputPauser(pauser ClarificationInputPauser)
|
||||
h.inputPauser = pauser
|
||||
}
|
||||
|
||||
func (h *Handlers) SetPromptReferenceResolver(resolver PromptReferenceResolver) {
|
||||
h.promptResolver = resolver
|
||||
}
|
||||
|
||||
// SetConfigDeps sets the config-mode dependencies for agent-native configuration handlers.
|
||||
// These are optional and only needed when config-mode MCP sessions are used.
|
||||
func (h *Handlers) SetConfigDeps(
|
||||
@@ -1439,7 +1453,8 @@ func (h *Handlers) handleMessageTask(ctx context.Context, msg *ws.Message) (*ws.
|
||||
"failed to get session for task: "+err.Error(), nil)
|
||||
}
|
||||
|
||||
wrappedPrompt, senderMeta := wrapAgentMessage(req.Prompt, senderTask, req.SenderSessionID)
|
||||
prompt := h.appendPromptReferenceExpansionContext(ctx, req.Prompt)
|
||||
wrappedPrompt, senderMeta := wrapAgentMessage(prompt, senderTask, req.SenderSessionID)
|
||||
|
||||
result, err := h.dispatchTaskMessage(ctx, req.TaskID, session, wrappedPrompt, senderMeta)
|
||||
if err != nil {
|
||||
@@ -1459,6 +1474,41 @@ func (h *Handlers) handleMessageTask(ctx context.Context, msg *ws.Message) (*ws.
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) appendPromptReferenceExpansionContext(ctx context.Context, prompt string) string {
|
||||
if h.promptResolver == nil {
|
||||
return prompt
|
||||
}
|
||||
if !strings.Contains(prompt, "@") {
|
||||
return prompt
|
||||
}
|
||||
expansions, err := h.promptResolver.ResolvePromptReferences(ctx, prompt)
|
||||
if err != nil {
|
||||
h.logger.Warn("failed to resolve prompt references for message_task", zap.Error(err))
|
||||
return prompt
|
||||
}
|
||||
if len(expansions) == 0 {
|
||||
return prompt
|
||||
}
|
||||
return prompt + "\n\n" + sysprompt.Wrap(formatPromptReferenceExpansions(expansions))
|
||||
}
|
||||
|
||||
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, "")
|
||||
}
|
||||
|
||||
// handleGetTaskConversation returns paginated conversation history for a task.
|
||||
// If session_id is omitted, it uses the task's primary session and falls back
|
||||
// to the latest session when no primary session exists.
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/orchestrator"
|
||||
"github.com/kandev/kandev/internal/orchestrator/executor"
|
||||
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
|
||||
promptservice "github.com/kandev/kandev/internal/prompts/service"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/service"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
@@ -106,6 +107,21 @@ func (f *fakeOrchestrator) ProcessOnTurnStart(ctx context.Context, taskID, sessi
|
||||
|
||||
func (f *fakeOrchestrator) GetMessageQueue() *messagequeue.Service { return f.queue }
|
||||
|
||||
type fakePromptReferenceResolver struct {
|
||||
expansions []promptservice.PromptReferenceExpansion
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakePromptReferenceResolver) ResolvePromptReferences(context.Context, string) ([]promptservice.PromptReferenceExpansion, error) {
|
||||
return f.expansions, f.err
|
||||
}
|
||||
|
||||
type panicPromptReferenceResolver struct{}
|
||||
|
||||
func (panicPromptReferenceResolver) ResolvePromptReferences(context.Context, string) ([]promptservice.PromptReferenceExpansion, error) {
|
||||
panic("prompt reference resolver should not be called")
|
||||
}
|
||||
|
||||
func newMessageTaskHandler(t *testing.T, svc *service.Service, taskRepo ...TaskRepository) (*Handlers, *fakeOrchestrator) {
|
||||
t.Helper()
|
||||
log := testLogger(t)
|
||||
@@ -286,6 +302,78 @@ func TestHandleMessageTask_RunningSession_Queues(t *testing.T) {
|
||||
assert.Empty(t, orch.startCreatedCalls)
|
||||
}
|
||||
|
||||
func TestHandleMessageTask_AppendsPromptReferenceExpansions(t *testing.T) {
|
||||
svc, repo := newTestTaskService(t)
|
||||
sender, target, sess := seedTaskWithSession(t, svc, repo, models.TaskSessionStateRunning)
|
||||
|
||||
h, orch := newMessageTaskHandler(t, svc)
|
||||
h.SetPromptReferenceResolver(fakePromptReferenceResolver{
|
||||
expansions: []promptservice.PromptReferenceExpansion{
|
||||
{Name: "improve-harness", Content: "Review this session for durable harness improvements."},
|
||||
},
|
||||
})
|
||||
|
||||
msg := makeWSMessage(t, ws.ActionMCPMessageTask, senderPayload(target.ID, "Please run @improve-harness", sender.ID))
|
||||
resp, err := h.handleMessageTask(context.Background(), msg)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, ws.MessageTypeResponse, resp.Type)
|
||||
|
||||
status := orch.queue.GetStatus(context.Background(), sess.ID)
|
||||
require.Equal(t, 1, status.Count)
|
||||
entry := status.Entries[0]
|
||||
assert.Contains(t, entry.Content, "Please run @improve-harness")
|
||||
assert.Contains(t, entry.Content, "EXPANDED PROMPT REFERENCES")
|
||||
assert.Contains(t, entry.Content, "### @improve-harness")
|
||||
assert.Contains(t, entry.Content, "Review this session for durable harness improvements.")
|
||||
}
|
||||
|
||||
func TestHandleMessageTask_PromptResolverError_FallsBackToOriginal(t *testing.T) {
|
||||
svc, repo := newTestTaskService(t)
|
||||
sender, target, sess := seedTaskWithSession(t, svc, repo, models.TaskSessionStateRunning)
|
||||
|
||||
h, orch := newMessageTaskHandler(t, svc)
|
||||
h.SetPromptReferenceResolver(fakePromptReferenceResolver{err: errors.New("db error")})
|
||||
|
||||
msg := makeWSMessage(t, ws.ActionMCPMessageTask, senderPayload(target.ID, "plain @missing text", sender.ID))
|
||||
resp, err := h.handleMessageTask(context.Background(), msg)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
|
||||
status := orch.queue.GetStatus(context.Background(), sess.ID)
|
||||
require.Equal(t, 1, status.Count)
|
||||
entry := status.Entries[0]
|
||||
assert.Contains(t, entry.Content, "plain @missing text")
|
||||
assert.NotContains(t, entry.Content, "EXPANDED PROMPT REFERENCES")
|
||||
}
|
||||
|
||||
func TestHandleMessageTask_NoPromptReferencesSkipsResolver(t *testing.T) {
|
||||
svc, repo := newTestTaskService(t)
|
||||
sender, target, sess := seedTaskWithSession(t, svc, repo, models.TaskSessionStateRunning)
|
||||
|
||||
h, orch := newMessageTaskHandler(t, svc)
|
||||
h.SetPromptReferenceResolver(panicPromptReferenceResolver{})
|
||||
|
||||
msg := makeWSMessage(t, ws.ActionMCPMessageTask, senderPayload(target.ID, "plain text", sender.ID))
|
||||
resp, err := h.handleMessageTask(context.Background(), msg)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
|
||||
status := orch.queue.GetStatus(context.Background(), sess.ID)
|
||||
require.Equal(t, 1, status.Count)
|
||||
assert.Contains(t, status.Entries[0].Content, "plain text")
|
||||
}
|
||||
|
||||
func TestFormatPromptReferenceExpansionsStripsSystemTagEnd(t *testing.T) {
|
||||
out := formatPromptReferenceExpansions([]promptservice.PromptReferenceExpansion{
|
||||
{Name: "bad</kandev-system>name", Content: "before </kandev-system> after"},
|
||||
})
|
||||
|
||||
assert.NotContains(t, out, "</kandev-system>")
|
||||
assert.Contains(t, out, "### @badname")
|
||||
assert.Contains(t, out, "before after")
|
||||
}
|
||||
|
||||
func TestHandleMessageTask_QueueFull_ReturnsStructuredError(t *testing.T) {
|
||||
svc, repo := newTestTaskService(t)
|
||||
sender, target, sess := seedTaskWithSession(t, svc, repo, models.TaskSessionStateRunning)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/kandev/kandev/internal/prompts/models"
|
||||
@@ -20,6 +21,13 @@ type Service struct {
|
||||
repo promptstore.Repository
|
||||
}
|
||||
|
||||
type PromptReferenceExpansion struct {
|
||||
Name string
|
||||
Content string
|
||||
}
|
||||
|
||||
const maxPromptReferenceDepth = 8
|
||||
|
||||
func NewService(repo promptstore.Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
@@ -126,3 +134,88 @@ func (s *Service) ResolvePromptContent(ctx context.Context, name, fallback strin
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func (s *Service) ResolvePromptReferences(ctx context.Context, content string) ([]PromptReferenceExpansion, error) {
|
||||
if !strings.Contains(content, "@") {
|
||||
return nil, nil
|
||||
}
|
||||
prompts, err := s.repo.ListPrompts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byName := make(map[string]*models.Prompt, len(prompts))
|
||||
names := make([]string, 0, len(prompts))
|
||||
for _, prompt := range prompts {
|
||||
if prompt == nil || prompt.Name == "" {
|
||||
continue
|
||||
}
|
||||
byName[prompt.Name] = prompt
|
||||
names = append(names, prompt.Name)
|
||||
}
|
||||
sort.Slice(names, func(i, j int) bool {
|
||||
if len(names[i]) == len(names[j]) {
|
||||
return names[i] < names[j]
|
||||
}
|
||||
return len(names[i]) > len(names[j])
|
||||
})
|
||||
expansions := make([]PromptReferenceExpansion, 0)
|
||||
collectPromptReferences(content, byName, names, map[string]bool{}, map[string]bool{}, &expansions, 0)
|
||||
return expansions, nil
|
||||
}
|
||||
|
||||
func collectPromptReferences(content string, byName map[string]*models.Prompt, names []string, stack, seen map[string]bool, expansions *[]PromptReferenceExpansion, depth int) {
|
||||
for index := 0; index < len(content); {
|
||||
if content[index] != '@' || !isPromptReferenceStart(content, index) {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
prompt, referenceEnd, ok := matchPromptReference(content, index, byName, names)
|
||||
if !ok || stack[prompt.Name] || depth >= maxPromptReferenceDepth {
|
||||
index = referenceEnd
|
||||
continue
|
||||
}
|
||||
if !seen[prompt.Name] {
|
||||
seen[prompt.Name] = true
|
||||
*expansions = append(*expansions, PromptReferenceExpansion{Name: prompt.Name, Content: prompt.Content})
|
||||
stack[prompt.Name] = true
|
||||
collectPromptReferences(prompt.Content, byName, names, stack, seen, expansions, depth+1)
|
||||
delete(stack, prompt.Name)
|
||||
}
|
||||
index = referenceEnd
|
||||
}
|
||||
}
|
||||
|
||||
func matchPromptReference(content string, index int, byName map[string]*models.Prompt, names []string) (*models.Prompt, int, bool) {
|
||||
referenceStart := index + 1
|
||||
for _, name := range names {
|
||||
if !strings.HasPrefix(content[referenceStart:], name) {
|
||||
continue
|
||||
}
|
||||
referenceEnd := referenceStart + len(name)
|
||||
if referenceEnd < len(content) && isPromptReferenceNameChar(content[referenceEnd]) {
|
||||
continue
|
||||
}
|
||||
return byName[name], referenceEnd, true
|
||||
}
|
||||
return nil, referenceStart, false
|
||||
}
|
||||
|
||||
func isPromptReferenceStart(content string, index int) bool {
|
||||
if index == 0 {
|
||||
return true
|
||||
}
|
||||
switch content[index-1] {
|
||||
case ' ', '\n', '\t', '\r':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isPromptReferenceNameChar(ch byte) bool {
|
||||
return ch >= 'a' && ch <= 'z' ||
|
||||
ch >= 'A' && ch <= 'Z' ||
|
||||
ch >= '0' && ch <= '9' ||
|
||||
ch == '-' ||
|
||||
ch == '_'
|
||||
}
|
||||
|
||||
@@ -164,6 +164,109 @@ func TestService_ResolvePromptContentFallsBack(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ResolvePromptReferencesRecursive(t *testing.T) {
|
||||
svc, cleanup := createService(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.CreatePrompt(ctx, "outer", "Before @middle after"); err != nil {
|
||||
t.Fatalf("seed outer: %v", err)
|
||||
}
|
||||
if _, err := svc.CreatePrompt(ctx, "middle", "Middle says @inner"); err != nil {
|
||||
t.Fatalf("seed middle: %v", err)
|
||||
}
|
||||
if _, err := svc.CreatePrompt(ctx, "inner", "Resolved inner"); err != nil {
|
||||
t.Fatalf("seed inner: %v", err)
|
||||
}
|
||||
|
||||
got, err := svc.ResolvePromptReferences(ctx, "Please run @outer")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve references: %v", err)
|
||||
}
|
||||
want := []PromptReferenceExpansion{
|
||||
{Name: "outer", Content: "Before @middle after"},
|
||||
{Name: "middle", Content: "Middle says @inner"},
|
||||
{Name: "inner", Content: "Resolved inner"},
|
||||
}
|
||||
if got == nil || len(got) != len(want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("got[%d]=%#v, want %#v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ResolvePromptReferencesSkipsListWithoutAtMention(t *testing.T) {
|
||||
svc := NewService(panicListPromptsRepo{})
|
||||
|
||||
got, err := svc.ResolvePromptReferences(context.Background(), "plain text")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve references: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("got %#v, want empty expansions", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ResolvePromptReferencesSkipsUnknownInlineAndCycles(t *testing.T) {
|
||||
svc, cleanup := createService(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.CreatePrompt(ctx, "outer", "Outer @inner"); err != nil {
|
||||
t.Fatalf("seed outer: %v", err)
|
||||
}
|
||||
if _, err := svc.CreatePrompt(ctx, "inner", "Inner @outer"); err != nil {
|
||||
t.Fatalf("seed inner: %v", err)
|
||||
}
|
||||
|
||||
got, err := svc.ResolvePromptReferences(ctx, "email a@outer @missing @outer")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve references: %v", err)
|
||||
}
|
||||
want := []PromptReferenceExpansion{
|
||||
{Name: "outer", Content: "Outer @inner"},
|
||||
{Name: "inner", Content: "Inner @outer"},
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("got[%d]=%#v, want %#v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ResolvePromptReferencesMatchesStoredNames(t *testing.T) {
|
||||
svc, cleanup := createService(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.CreatePrompt(ctx, "Daily", "Short daily prompt"); err != nil {
|
||||
t.Fatalf("seed daily: %v", err)
|
||||
}
|
||||
if _, err := svc.CreatePrompt(ctx, "Daily Summary", "Summarize the work."); err != nil {
|
||||
t.Fatalf("seed daily summary: %v", err)
|
||||
}
|
||||
|
||||
got, err := svc.ResolvePromptReferences(ctx, "Please run @Daily Summary.")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve references: %v", err)
|
||||
}
|
||||
want := []PromptReferenceExpansion{{Name: "Daily Summary", Content: "Summarize the work."}}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("got[%d]=%#v, want %#v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// raceRepo simulates a TOCTOU loss against the SQLite UNIQUE index: the
|
||||
// pre-check sees no row, but the write fails because a concurrent insert
|
||||
// landed first. The service must translate that into ErrPromptAlreadyExists
|
||||
@@ -174,6 +277,14 @@ type raceRepo struct {
|
||||
updateErr error
|
||||
}
|
||||
|
||||
type panicListPromptsRepo struct {
|
||||
promptstore.Repository
|
||||
}
|
||||
|
||||
func (panicListPromptsRepo) ListPrompts(context.Context) ([]*models.Prompt, error) {
|
||||
panic("ListPrompts should not be called")
|
||||
}
|
||||
|
||||
func (r *raceRepo) GetPromptByID(_ context.Context, id string) (*models.Prompt, error) {
|
||||
return &models.Prompt{ID: id, Name: "old", Content: "x"}, nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useMemo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import {
|
||||
MarkdownFileLinkContext,
|
||||
markdownComponents,
|
||||
@@ -21,18 +21,21 @@ import { normalizeCached } from "@/lib/markdown/normalize-cache";
|
||||
*/
|
||||
type MemoizedMarkdownProps = MarkdownFileLinkContextValue & {
|
||||
content: string;
|
||||
components?: Components;
|
||||
};
|
||||
|
||||
export const MemoizedMarkdown = memo(function MemoizedMarkdown({
|
||||
content,
|
||||
worktreePath,
|
||||
onOpenFile,
|
||||
components,
|
||||
}: MemoizedMarkdownProps) {
|
||||
const fileLinkContext = useMemo(() => ({ worktreePath, onOpenFile }), [worktreePath, onOpenFile]);
|
||||
const resolvedComponents = components ?? markdownComponents;
|
||||
|
||||
return (
|
||||
<MarkdownFileLinkContext.Provider value={fileLinkContext}>
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} components={resolvedComponents}>
|
||||
{normalizeCached(content)}
|
||||
</ReactMarkdown>
|
||||
</MarkdownFileLinkContext.Provider>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { ChatMessage } from "./chat-message";
|
||||
import {
|
||||
sessionId as toSessionId,
|
||||
taskId as toTaskId,
|
||||
type CustomPrompt,
|
||||
type Message,
|
||||
type TaskSession,
|
||||
} from "@/lib/types/http";
|
||||
@@ -13,12 +14,14 @@ import {
|
||||
const SENDER_TASK_ID = "task-sender";
|
||||
const SENDER_TITLE = "Fix login bug";
|
||||
const SENDER_BADGE_SELECTOR = "[data-testid='sender-task-badge']";
|
||||
const MESSAGE_TIMESTAMP = "2026-05-04T00:00:00Z";
|
||||
const PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
|
||||
const OPEN_ATTACHMENT_1_LABEL = "Open Attachment 1";
|
||||
const FULL_SIZE_ATTACHMENT_1_ALT = "Full size Attachment 1";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -30,12 +33,23 @@ function userMessage(overrides: Partial<Message>): Message {
|
||||
author_type: "user",
|
||||
content: "hello",
|
||||
type: "message",
|
||||
created_at: "2026-05-04T00:00:00Z",
|
||||
created_at: MESSAGE_TIMESTAMP,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function wrapper(tasks: Array<{ id: string; title: string }> = []) {
|
||||
function customPrompt(name: string): CustomPrompt {
|
||||
return {
|
||||
id: `prompt-${name}`,
|
||||
name,
|
||||
content: `${name} content`,
|
||||
builtin: false,
|
||||
created_at: MESSAGE_TIMESTAMP,
|
||||
updated_at: MESSAGE_TIMESTAMP,
|
||||
};
|
||||
}
|
||||
|
||||
function wrapper(tasks: Array<{ id: string; title: string }> = [], prompts: CustomPrompt[] = []) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<StateProvider
|
||||
@@ -53,6 +67,7 @@ function wrapper(tasks: Array<{ id: string; title: string }> = []) {
|
||||
parent_id: undefined,
|
||||
})),
|
||||
} as unknown as never,
|
||||
prompts: { items: prompts, loaded: true, loading: false },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -61,6 +76,50 @@ function wrapper(tasks: Array<{ id: string; title: string }> = []) {
|
||||
};
|
||||
}
|
||||
|
||||
describe("ChatMessage prompt mentions", () => {
|
||||
it("renders saved prompt mentions as visual chips", () => {
|
||||
const Wrapper = wrapper([], [customPrompt("hello")]);
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<ChatMessage
|
||||
comment={userMessage({ content: "@hello and @missing" })}
|
||||
label="Message"
|
||||
className=""
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const chips = screen.getAllByTestId("custom-prompt-mention");
|
||||
expect(chips).toHaveLength(1);
|
||||
const [chip] = chips;
|
||||
expect(chip.textContent).toBe("@hello");
|
||||
expect(screen.getByText(/and @missing/)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("preserves GFM attributes while highlighting prompt mentions", () => {
|
||||
const Wrapper = wrapper([], [customPrompt("hello")]);
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<ChatMessage
|
||||
comment={userMessage({
|
||||
content: "- [x] @hello\n\n| Name |\n| :---: |\n| @hello |",
|
||||
})}
|
||||
label="Message"
|
||||
className=""
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox") as HTMLInputElement;
|
||||
expect(screen.getAllByTestId("custom-prompt-mention")).toHaveLength(2);
|
||||
expect(checkbox.checked).toBe(true);
|
||||
expect(checkbox.closest("li")?.className).toContain("task-list-item");
|
||||
expect(screen.getByRole("cell").getAttribute("style")).toContain("text-align: center");
|
||||
});
|
||||
});
|
||||
|
||||
function renderWithSender(
|
||||
tasks: Array<{ id: string; title: string }>,
|
||||
metadata: Partial<Message["metadata"] & object>,
|
||||
@@ -78,8 +137,8 @@ function renderAgentMessageWithSession(session: Partial<TaskSession>, metadata =
|
||||
id: toSessionId("sess-1"),
|
||||
task_id: toTaskId("task-target"),
|
||||
state: "COMPLETED",
|
||||
started_at: "2026-05-04T00:00:00Z",
|
||||
updated_at: "2026-05-04T00:00:00Z",
|
||||
started_at: MESSAGE_TIMESTAMP,
|
||||
updated_at: MESSAGE_TIMESTAMP,
|
||||
...session,
|
||||
};
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
@@ -202,6 +261,75 @@ describe("ChatMessage sender badge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatMessage raw view", () => {
|
||||
it("shows user raw_content with hidden kandev-system blocks when raw view is enabled", () => {
|
||||
const raw = `<kandev-system>This message was sent by an agent working in task "Sender" (${SENDER_TASK_ID}).</kandev-system>
|
||||
|
||||
@improve-task
|
||||
|
||||
<kandev-system>EXPANDED PROMPT REFERENCES: The message above references saved prompts by @name.
|
||||
|
||||
### @improve-task
|
||||
Review this task for durable improvements.</kandev-system>`;
|
||||
const Wrapper = wrapper([{ id: SENDER_TASK_ID, title: SENDER_TITLE }]);
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<ChatMessage
|
||||
comment={userMessage({
|
||||
content: "@improve-task",
|
||||
raw_content: raw,
|
||||
metadata: {
|
||||
sender_task_id: SENDER_TASK_ID,
|
||||
sender_task_title: SENDER_TITLE,
|
||||
has_hidden_prompts: true,
|
||||
},
|
||||
})}
|
||||
label="Message"
|
||||
className=""
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/EXPANDED PROMPT REFERENCES/)).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show raw text" }));
|
||||
|
||||
expect(screen.getByText(/This message was sent by an agent/)).not.toBeNull();
|
||||
expect(screen.getByText(/EXPANDED PROMPT REFERENCES/)).not.toBeNull();
|
||||
expect(screen.getByText(/### @improve-task/)).not.toBeNull();
|
||||
expect(screen.getByText(/Review this task for durable improvements/)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows agent raw_content with hidden kandev-system blocks when raw view is enabled", () => {
|
||||
const raw = `<kandev-system>Hidden agent context.</kandev-system>
|
||||
|
||||
Visible agent response.`;
|
||||
const Wrapper = wrapper([]);
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<ChatMessage
|
||||
comment={userMessage({
|
||||
author_type: "agent",
|
||||
content: "Visible agent response.",
|
||||
raw_content: raw,
|
||||
})}
|
||||
label="Message"
|
||||
className=""
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/Hidden agent context/)).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show raw text" }));
|
||||
|
||||
expect(screen.getByText(/Hidden agent context/)).not.toBeNull();
|
||||
expect(screen.getByText(/Visible agent response/)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatMessage agent session config metadata", () => {
|
||||
it("opens markdown file links with the provided chat file opener", () => {
|
||||
const onOpenFile = vi.fn();
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState, useCallback } from "react";
|
||||
import {
|
||||
Children,
|
||||
memo,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { Components } from "react-markdown";
|
||||
import { IconWand, IconMessageDots, IconFile } from "@tabler/icons-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
@@ -9,7 +18,13 @@ import { MessageActions } from "@/components/task/chat/messages/message-actions"
|
||||
import { useUserMessageNavigation } from "@/hooks/use-message-navigation";
|
||||
import { SenderTaskBadge, type SenderTaskInfo } from "./sender-task-badge";
|
||||
import { MemoizedMarkdown } from "@/components/shared/memoized-markdown";
|
||||
import { markdownComponents } from "@/components/shared/markdown-components";
|
||||
import { ImagePreviewDialog } from "@/components/task/chat/image-preview-dialog";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import {
|
||||
buildPromptMentionNames,
|
||||
splitPreparedPromptMentionSegments,
|
||||
} from "@/lib/prompts/prompt-mention-segments";
|
||||
import {
|
||||
WorkflowStepMessageBadge,
|
||||
workflowMessageInfoFromMetadata,
|
||||
@@ -78,6 +93,7 @@ type UserMessageBodyOptions = {
|
||||
hasAttachments: boolean;
|
||||
content: string;
|
||||
rawContent?: string;
|
||||
promptMentionComponents?: Components;
|
||||
worktreePath?: string;
|
||||
onOpenFile?: (path: string) => void;
|
||||
};
|
||||
@@ -88,6 +104,7 @@ function renderUserMessageBody({
|
||||
hasAttachments,
|
||||
content,
|
||||
rawContent,
|
||||
promptMentionComponents,
|
||||
worktreePath,
|
||||
onOpenFile,
|
||||
}: UserMessageBodyOptions): React.ReactNode {
|
||||
@@ -97,7 +114,12 @@ function renderUserMessageBody({
|
||||
if (hasContent) {
|
||||
return (
|
||||
<div className="markdown-body markdown-body-user max-w-none">
|
||||
<MemoizedMarkdown content={content} worktreePath={worktreePath} onOpenFile={onOpenFile} />
|
||||
<MemoizedMarkdown
|
||||
content={content}
|
||||
components={promptMentionComponents}
|
||||
worktreePath={worktreePath}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -130,6 +152,113 @@ type UserMessageMetadata = WorkflowMessageMetadata & {
|
||||
sender_session_id?: string;
|
||||
};
|
||||
|
||||
type PromptMentionMarkdownTag =
|
||||
| "p"
|
||||
| "li"
|
||||
| "h1"
|
||||
| "h2"
|
||||
| "h3"
|
||||
| "h4"
|
||||
| "h5"
|
||||
| "h6"
|
||||
| "blockquote"
|
||||
| "td"
|
||||
| "th";
|
||||
|
||||
type MarkdownChildrenProps<T extends PromptMentionMarkdownTag> = ComponentPropsWithoutRef<T> & {
|
||||
children?: ReactNode;
|
||||
node?: unknown;
|
||||
};
|
||||
|
||||
function usePromptMentionNames() {
|
||||
const prompts = useAppStore((state) => state.prompts.items);
|
||||
return useMemo(() => prompts.map((prompt) => prompt.name), [prompts]);
|
||||
}
|
||||
|
||||
function usePromptMentionMarkdownComponents(promptNames: string[]): Components | undefined {
|
||||
return useMemo(() => {
|
||||
const mentionNames = buildPromptMentionNames(promptNames);
|
||||
if (mentionNames.length === 0) return undefined;
|
||||
const renderChildren = (children: ReactNode, keyPrefix: string) =>
|
||||
renderChildrenWithPromptMentions(children, mentionNames, keyPrefix);
|
||||
return {
|
||||
...markdownComponents,
|
||||
p: ({ children, node, ...props }: MarkdownChildrenProps<"p">) => {
|
||||
void node;
|
||||
return <p {...props}>{renderChildren(children, "p")}</p>;
|
||||
},
|
||||
li: ({ children, node, ...props }: MarkdownChildrenProps<"li">) => {
|
||||
void node;
|
||||
return <li {...props}>{renderChildren(children, "li")}</li>;
|
||||
},
|
||||
h1: ({ children, node, ...props }: MarkdownChildrenProps<"h1">) => {
|
||||
void node;
|
||||
return <h1 {...props}>{renderChildren(children, "h1")}</h1>;
|
||||
},
|
||||
h2: ({ children, node, ...props }: MarkdownChildrenProps<"h2">) => {
|
||||
void node;
|
||||
return <h2 {...props}>{renderChildren(children, "h2")}</h2>;
|
||||
},
|
||||
h3: ({ children, node, ...props }: MarkdownChildrenProps<"h3">) => {
|
||||
void node;
|
||||
return <h3 {...props}>{renderChildren(children, "h3")}</h3>;
|
||||
},
|
||||
h4: ({ children, node, ...props }: MarkdownChildrenProps<"h4">) => {
|
||||
void node;
|
||||
return <h4 {...props}>{renderChildren(children, "h4")}</h4>;
|
||||
},
|
||||
h5: ({ children, node, ...props }: MarkdownChildrenProps<"h5">) => {
|
||||
void node;
|
||||
return <h5 {...props}>{renderChildren(children, "h5")}</h5>;
|
||||
},
|
||||
h6: ({ children, node, ...props }: MarkdownChildrenProps<"h6">) => {
|
||||
void node;
|
||||
return <h6 {...props}>{renderChildren(children, "h6")}</h6>;
|
||||
},
|
||||
blockquote: ({ children, node, ...props }: MarkdownChildrenProps<"blockquote">) => {
|
||||
void node;
|
||||
return <blockquote {...props}>{renderChildren(children, "blockquote")}</blockquote>;
|
||||
},
|
||||
td: ({ children, node, ...props }: MarkdownChildrenProps<"td">) => {
|
||||
void node;
|
||||
return <td {...props}>{renderChildren(children, "td")}</td>;
|
||||
},
|
||||
th: ({ children, node, ...props }: MarkdownChildrenProps<"th">) => {
|
||||
void node;
|
||||
return <th {...props}>{renderChildren(children, "th")}</th>;
|
||||
},
|
||||
};
|
||||
}, [promptNames]);
|
||||
}
|
||||
|
||||
function renderChildrenWithPromptMentions(
|
||||
children: ReactNode,
|
||||
promptNames: string[],
|
||||
keyPrefix: string,
|
||||
) {
|
||||
return Children.toArray(children).flatMap((child, index) => {
|
||||
if (typeof child !== "string") return child;
|
||||
return renderTextWithPromptMentions(child, promptNames, `${keyPrefix}-${index}`);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTextWithPromptMentions(text: string, promptNames: string[], keyPrefix: string) {
|
||||
return splitPreparedPromptMentionSegments(text, promptNames).map((segment, index) => {
|
||||
if (segment.kind === "text") return segment.value;
|
||||
return (
|
||||
<span
|
||||
key={`${keyPrefix}-prompt-${index}`}
|
||||
data-testid="custom-prompt-mention"
|
||||
data-prompt-name={segment.name}
|
||||
title={`Custom prompt: ${segment.name}`}
|
||||
className="inline rounded-md border border-emerald-300/35 bg-emerald-400/20 px-1.5 py-0.5 font-mono text-[0.88em] font-semibold text-emerald-950 box-decoration-clone break-all dark:text-emerald-100"
|
||||
>
|
||||
{segment.value}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function parseUserMessageMetadata(comment: Message) {
|
||||
const metadata = comment.metadata as UserMessageMetadata | undefined;
|
||||
const imageAttachments = (metadata?.attachments || []).filter((att) => att.type === "image");
|
||||
@@ -215,6 +344,8 @@ function UserMessageContent({
|
||||
onScrollToMessage,
|
||||
}: UserMessageProps) {
|
||||
const userNavigation = useUserMessageNavigation(sessionId ?? null, comment.id);
|
||||
const promptNames = usePromptMentionNames();
|
||||
const promptMentionComponents = usePromptMentionMarkdownComponents(promptNames);
|
||||
const {
|
||||
imageAttachments,
|
||||
fileAttachments,
|
||||
@@ -266,6 +397,7 @@ function UserMessageContent({
|
||||
hasAttachments,
|
||||
content: comment.content,
|
||||
rawContent: comment.raw_content,
|
||||
promptMentionComponents,
|
||||
worktreePath,
|
||||
onOpenFile,
|
||||
})}
|
||||
@@ -319,7 +451,7 @@ function AgentMessageContent({
|
||||
<div className="flex-1 min-w-0">
|
||||
{showRaw ? (
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs bg-muted/20 p-3 rounded-md">
|
||||
{comment.content || "(empty)"}
|
||||
{comment.raw_content || comment.content || "(empty)"}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="markdown-body max-w-none">
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildTaskMentionsContext } from "./use-message-handler";
|
||||
import { buildContextFilesContext, buildTaskMentionsContext } from "./use-message-handler";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
import type { TaskMentionData } from "./use-inline-mention";
|
||||
|
||||
const IMPROVE_HARNESS_PROMPT = "improve-harness";
|
||||
const IMPROVE_HARNESS_CONTENT = "Review this session for durable harness improvements.";
|
||||
|
||||
function makeState(overrides: Partial<AppState> = {}): AppState {
|
||||
const base = {
|
||||
kanban: { workflowId: "wf-1", steps: [], tasks: [] },
|
||||
@@ -118,3 +121,66 @@ describe("buildTaskMentionsContext", () => {
|
||||
expect(out).toContain("step: Review");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildContextFilesContext", () => {
|
||||
it("preserves saved prompt references and appends their expansion as hidden context", () => {
|
||||
const out = buildContextFilesContext(
|
||||
[{ path: "prompt:outer", name: "outer" }],
|
||||
[
|
||||
{
|
||||
id: "outer",
|
||||
name: "outer",
|
||||
content: "Send this to peers:\n@improve-harness",
|
||||
builtin: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
{
|
||||
id: "inner",
|
||||
name: IMPROVE_HARNESS_PROMPT,
|
||||
content: IMPROVE_HARNESS_CONTENT,
|
||||
builtin: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(out).toContain("Send this to peers:");
|
||||
expect(out).toContain("@improve-harness");
|
||||
expect(out).toContain("EXPANDED PROMPT REFERENCES");
|
||||
expect(out).toContain("### @improve-harness");
|
||||
expect(out).toContain(IMPROVE_HARNESS_CONTENT);
|
||||
});
|
||||
|
||||
it("does not repeat prompt expansions for directly attached prompts", () => {
|
||||
const out = buildContextFilesContext(
|
||||
[
|
||||
{ path: "prompt:outer", name: "outer" },
|
||||
{ path: "prompt:inner", name: IMPROVE_HARNESS_PROMPT },
|
||||
],
|
||||
[
|
||||
{
|
||||
id: "outer",
|
||||
name: "outer",
|
||||
content: "Send this to peers:\n@improve-harness",
|
||||
builtin: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
{
|
||||
id: "inner",
|
||||
name: IMPROVE_HARNESS_PROMPT,
|
||||
content: IMPROVE_HARNESS_CONTENT,
|
||||
builtin: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(out).toContain("### improve-harness");
|
||||
expect(out).toContain(IMPROVE_HARNESS_CONTENT);
|
||||
expect(out).not.toContain("### @improve-harness");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,10 @@ import type { ContextFile } from "@/lib/state/context-files-store";
|
||||
import type { CustomPrompt, Message } from "@/lib/types/http";
|
||||
import type { TaskMentionData } from "@/hooks/use-inline-mention";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
import {
|
||||
collectPromptReferenceExpansions,
|
||||
formatPromptReferenceExpansions,
|
||||
} from "@/lib/prompts/expand-prompt-references";
|
||||
|
||||
function buildDocumentContext(
|
||||
activeDocument: ActiveDocument | null,
|
||||
@@ -96,16 +100,31 @@ export function buildContextFilesContext(
|
||||
|
||||
if (promptFiles.length > 0) {
|
||||
const promptsById = new Map(prompts.map((p) => [p.id, p]));
|
||||
const resolved = promptFiles
|
||||
.map((f) => {
|
||||
const id = f.path.replace("prompt:", "");
|
||||
const prompt = promptsById.get(id);
|
||||
return prompt ? `### ${prompt.name}\n${prompt.content}` : null;
|
||||
const selectedPrompts = promptFiles
|
||||
.map((f) => promptsById.get(f.path.replace("prompt:", "")))
|
||||
.filter((prompt): prompt is CustomPrompt => Boolean(prompt));
|
||||
const selectedPromptNames = new Set(selectedPrompts.map((prompt) => prompt.name));
|
||||
const promptExpansions = new Map<string, string>();
|
||||
const resolved = selectedPrompts
|
||||
.map((prompt) => {
|
||||
for (const expansion of collectPromptReferenceExpansions(
|
||||
prompt.content,
|
||||
prompts,
|
||||
prompt.name,
|
||||
selectedPromptNames,
|
||||
)) {
|
||||
if (!promptExpansions.has(expansion.name)) {
|
||||
promptExpansions.set(expansion.name, expansion.content);
|
||||
}
|
||||
}
|
||||
return `### ${prompt.name}\n${prompt.content}`;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (resolved.length > 0) {
|
||||
context += `\n\n<kandev-system>\nCONTEXT PROMPTS: The user has included the following prompt instructions as context:\n${resolved.join("\n\n")}\n</kandev-system>`;
|
||||
const expansions = Array.from(promptExpansions, ([name, content]) => ({ name, content }));
|
||||
const expansionContext = formatPromptReferenceExpansions(expansions);
|
||||
context += `\n\n<kandev-system>\nCONTEXT PROMPTS: The user has included the following prompt instructions as context:\n${resolved.join("\n\n")}${expansionContext ? "\n\n" + expansionContext : ""}\n</kandev-system>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectPromptReferenceExpansions,
|
||||
formatPromptReferenceExpansions,
|
||||
type PromptReference,
|
||||
} from "./expand-prompt-references";
|
||||
|
||||
function prompt(name: string, content: string): PromptReference {
|
||||
return { id: name, name, content };
|
||||
}
|
||||
|
||||
const OUTER_PROMPT = "outer";
|
||||
const INNER_PROMPT = "inner";
|
||||
const OUTER_CONTENT = "Outer @inner";
|
||||
const INNER_CONTENT = "Inner @outer";
|
||||
|
||||
describe("collectPromptReferenceExpansions", () => {
|
||||
it("recursively collects saved prompt references without rewriting content", () => {
|
||||
const prompts = [
|
||||
prompt("outer", "Before @middle after"),
|
||||
prompt("middle", "middle says @inner"),
|
||||
prompt("inner", "resolved inner"),
|
||||
];
|
||||
|
||||
expect(collectPromptReferenceExpansions("@outer", prompts)).toEqual([
|
||||
{ name: "outer", content: "Before @middle after" },
|
||||
{ name: "middle", content: "middle says @inner" },
|
||||
{ name: "inner", content: "resolved inner" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves unknown references and inline email-like text unchanged", () => {
|
||||
const prompts = [prompt("known", "resolved")];
|
||||
|
||||
expect(collectPromptReferenceExpansions("ping a@known @missing @known.", prompts)).toEqual([
|
||||
{ name: "known", content: "resolved" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not recurse forever when prompts reference each other", () => {
|
||||
const prompts = [prompt(OUTER_PROMPT, OUTER_CONTENT), prompt(INNER_PROMPT, INNER_CONTENT)];
|
||||
|
||||
expect(collectPromptReferenceExpansions("@outer", prompts)).toEqual([
|
||||
{ name: OUTER_PROMPT, content: OUTER_CONTENT },
|
||||
{ name: INNER_PROMPT, content: INNER_CONTENT },
|
||||
]);
|
||||
expect(collectPromptReferenceExpansions("@inner", prompts, "outer")).toEqual([
|
||||
{ name: INNER_PROMPT, content: INNER_CONTENT },
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches stored prompt names instead of only slug-shaped names", () => {
|
||||
const prompts = [
|
||||
prompt("Daily", "short daily prompt"),
|
||||
prompt("Daily Summary", "summarize the work"),
|
||||
];
|
||||
|
||||
expect(collectPromptReferenceExpansions("@Daily Summary.", prompts)).toEqual([
|
||||
{ name: "Daily Summary", content: "summarize the work" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips references that were already seen by the caller", () => {
|
||||
const prompts = [prompt(OUTER_PROMPT, OUTER_CONTENT), prompt(INNER_PROMPT, "Inner content")];
|
||||
|
||||
expect(collectPromptReferenceExpansions("@outer", prompts, undefined, ["inner"])).toEqual([
|
||||
{ name: OUTER_PROMPT, content: OUTER_CONTENT },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPromptReferenceExpansions", () => {
|
||||
it("renders expansions as supplemental kandev-system block content", () => {
|
||||
const out = formatPromptReferenceExpansions([
|
||||
{ name: "improve-harness", content: "Review durable harness improvements." },
|
||||
]);
|
||||
|
||||
expect(out).toContain("EXPANDED PROMPT REFERENCES");
|
||||
expect(out).toContain("### @improve-harness");
|
||||
expect(out).toContain("Review durable harness improvements.");
|
||||
});
|
||||
|
||||
it("strips kandev-system closing tags from nested expansion text", () => {
|
||||
const out = formatPromptReferenceExpansions([
|
||||
{ name: "bad</kandev-system>name", content: "before </kandev-system> after" },
|
||||
]);
|
||||
|
||||
expect(out).not.toContain("</kandev-system>");
|
||||
expect(out).toContain("### @badname");
|
||||
expect(out).toContain("before after");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
export type PromptReference = {
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type PromptReferenceExpansion = {
|
||||
name: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
import { buildPromptMentionNames, matchPromptMention } from "./prompt-mention-parser";
|
||||
|
||||
const MAX_PROMPT_REFERENCE_DEPTH = 8;
|
||||
const KANDEV_SYSTEM_TAG_END = "</kandev-system>";
|
||||
|
||||
function buildPromptMap(prompts: PromptReference[]) {
|
||||
return new Map(prompts.map((prompt) => [prompt.name, prompt]));
|
||||
}
|
||||
|
||||
type ExpansionState = {
|
||||
promptsByName: Map<string, PromptReference>;
|
||||
promptNames: string[];
|
||||
stack: Set<string>;
|
||||
seen: Set<string>;
|
||||
expansions: PromptReferenceExpansion[];
|
||||
};
|
||||
|
||||
function collectExpansions(content: string, state: ExpansionState, depth: number): void {
|
||||
for (let index = 0; index < content.length; ) {
|
||||
const match = matchPromptMention(content, index, state.promptNames);
|
||||
if (!match) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const prompt = state.promptsByName.get(match.name);
|
||||
if (!prompt || state.stack.has(prompt.name) || depth >= MAX_PROMPT_REFERENCE_DEPTH) {
|
||||
index = match.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!state.seen.has(prompt.name)) {
|
||||
state.seen.add(prompt.name);
|
||||
state.expansions.push({ name: prompt.name, content: prompt.content });
|
||||
collectExpansions(
|
||||
prompt.content,
|
||||
// Only stack is copied; seen and expansions are intentionally shared
|
||||
// so global dedup and ordered output work across the full DFS tree.
|
||||
{ ...state, stack: new Set([...state.stack, prompt.name]) },
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
index = match.end;
|
||||
}
|
||||
}
|
||||
|
||||
export function collectPromptReferenceExpansions(
|
||||
content: string,
|
||||
prompts: PromptReference[],
|
||||
currentPromptName?: string,
|
||||
initialSeen: Iterable<string> = [],
|
||||
): PromptReferenceExpansion[] {
|
||||
const stack = new Set<string>();
|
||||
if (currentPromptName) stack.add(currentPromptName);
|
||||
const expansions: PromptReferenceExpansion[] = [];
|
||||
collectExpansions(
|
||||
content,
|
||||
{
|
||||
promptsByName: buildPromptMap(prompts),
|
||||
promptNames: buildPromptMentionNames(prompts.map((prompt) => prompt.name)),
|
||||
stack,
|
||||
seen: new Set(initialSeen),
|
||||
expansions,
|
||||
},
|
||||
0,
|
||||
);
|
||||
return expansions;
|
||||
}
|
||||
|
||||
export function formatPromptReferenceExpansions(expansions: PromptReferenceExpansion[]) {
|
||||
if (expansions.length === 0) return "";
|
||||
return [
|
||||
"EXPANDED PROMPT REFERENCES: The message above references saved prompts by @name. Use these expansions as hidden context while preserving the original @mentions.",
|
||||
...expansions.map(
|
||||
(expansion) =>
|
||||
`### @${stripKandevSystemTagEnd(expansion.name)}\n${stripKandevSystemTagEnd(expansion.content)}`,
|
||||
),
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
function stripKandevSystemTagEnd(value: string) {
|
||||
return value.replaceAll(KANDEV_SYSTEM_TAG_END, "");
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export type PromptMentionMatch = {
|
||||
start: number;
|
||||
end: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function buildPromptMentionNames(promptNames: string[]) {
|
||||
return Array.from(new Set(promptNames.filter(Boolean))).sort(
|
||||
(a, b) => b.length - a.length || a.localeCompare(b),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match using names ordered by buildPromptMentionNames so longer names win
|
||||
* over shorter prefixes.
|
||||
*/
|
||||
export function matchPromptMention(
|
||||
content: string,
|
||||
index: number,
|
||||
promptNames: string[],
|
||||
): PromptMentionMatch | null {
|
||||
if (content[index] !== "@" || !isMentionStart(content, index)) return null;
|
||||
|
||||
const referenceStart = index + 1;
|
||||
for (const name of promptNames) {
|
||||
if (!content.startsWith(name, referenceStart)) continue;
|
||||
const referenceEnd = referenceStart + name.length;
|
||||
if (referenceEnd < content.length && isMentionNameChar(content[referenceEnd])) continue;
|
||||
return { start: index, end: referenceEnd, name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isMentionStart(content: string, index: number) {
|
||||
return index === 0 || isWhitespace(content[index - 1]);
|
||||
}
|
||||
|
||||
function isWhitespace(value: string) {
|
||||
return value === " " || value === "\n" || value === "\t" || value === "\r";
|
||||
}
|
||||
|
||||
function isMentionNameChar(value: string) {
|
||||
return /[A-Za-z0-9_-]/.test(value);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { splitPromptMentionSegments } from "./prompt-mention-segments";
|
||||
|
||||
describe("splitPromptMentionSegments", () => {
|
||||
it("marks stored prompt mentions without marking unknown mentions", () => {
|
||||
expect(splitPromptMentionSegments("Run @hello and @missing", ["hello"])).toEqual([
|
||||
{ kind: "text", value: "Run " },
|
||||
{ kind: "prompt", value: "@hello", name: "hello" },
|
||||
{ kind: "text", value: " and @missing" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches stored prompt names with spaces longest-first", () => {
|
||||
expect(splitPromptMentionSegments("Use @Daily Summary.", ["Daily", "Daily Summary"])).toEqual([
|
||||
{ kind: "text", value: "Use " },
|
||||
{ kind: "prompt", value: "@Daily Summary", name: "Daily Summary" },
|
||||
{ kind: "text", value: "." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches stored prompt mentions at the start of the string", () => {
|
||||
expect(splitPromptMentionSegments("@hello world", ["hello"])).toEqual([
|
||||
{ kind: "prompt", value: "@hello", name: "hello" },
|
||||
{ kind: "text", value: " world" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches stored prompt mentions at the end of the string", () => {
|
||||
expect(splitPromptMentionSegments("Run @hello", ["hello"])).toEqual([
|
||||
{ kind: "text", value: "Run " },
|
||||
{ kind: "prompt", value: "@hello", name: "hello" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns a text segment when content is empty", () => {
|
||||
expect(splitPromptMentionSegments("", ["hello"])).toEqual([{ kind: "text", value: "" }]);
|
||||
});
|
||||
|
||||
it("returns a text segment when no prompt names are provided", () => {
|
||||
expect(splitPromptMentionSegments("@hello", [])).toEqual([{ kind: "text", value: "@hello" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
export type PromptMentionSegment =
|
||||
| { kind: "text"; value: string }
|
||||
| { kind: "prompt"; value: string; name: string };
|
||||
|
||||
import { buildPromptMentionNames, matchPromptMention } from "./prompt-mention-parser";
|
||||
|
||||
export { buildPromptMentionNames } from "./prompt-mention-parser";
|
||||
|
||||
export function splitPromptMentionSegments(
|
||||
content: string,
|
||||
promptNames: string[],
|
||||
): PromptMentionSegment[] {
|
||||
return splitPreparedPromptMentionSegments(content, buildPromptMentionNames(promptNames));
|
||||
}
|
||||
|
||||
export function splitPreparedPromptMentionSegments(
|
||||
content: string,
|
||||
promptNames: string[],
|
||||
): PromptMentionSegment[] {
|
||||
if (content.length === 0 || promptNames.length === 0) return [{ kind: "text", value: content }];
|
||||
|
||||
const segments: PromptMentionSegment[] = [];
|
||||
let lastIndex = 0;
|
||||
for (let index = 0; index < content.length; ) {
|
||||
const match = matchPromptMention(content, index, promptNames);
|
||||
if (!match) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (match.start > lastIndex) {
|
||||
segments.push({ kind: "text", value: content.slice(lastIndex, match.start) });
|
||||
}
|
||||
segments.push({
|
||||
kind: "prompt",
|
||||
value: content.slice(match.start, match.end),
|
||||
name: match.name,
|
||||
});
|
||||
index = match.end;
|
||||
lastIndex = match.end;
|
||||
}
|
||||
|
||||
if (lastIndex < content.length) {
|
||||
segments.push({ kind: "text", value: content.slice(lastIndex) });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
Reference in New Issue
Block a user