From e2eaad01ed0b21c73a211d9965b70db56e20086c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Thu, 9 Jul 2026 21:37:22 +0100 Subject: [PATCH] fix: expand nested custom prompts (#1637) --- apps/backend/internal/backendapp/helpers.go | 1 + .../backend/internal/mcp/handlers/handlers.go | 52 ++++++- .../mcp/handlers/message_task_test.go | 88 +++++++++++ .../internal/prompts/service/service.go | 93 ++++++++++++ .../internal/prompts/service/service_test.go | 111 ++++++++++++++ .../components/shared/memoized-markdown.tsx | 7 +- .../task/chat/messages/chat-message.test.tsx | 138 +++++++++++++++++- .../task/chat/messages/chat-message.tsx | 138 +++++++++++++++++- apps/web/hooks/use-message-handler.test.ts | 68 ++++++++- apps/web/hooks/use-message-handler.ts | 31 +++- .../prompts/expand-prompt-references.test.ts | 92 ++++++++++++ .../lib/prompts/expand-prompt-references.ts | 94 ++++++++++++ apps/web/lib/prompts/prompt-mention-parser.ts | 44 ++++++ .../prompts/prompt-mention-segments.test.ts | 42 ++++++ .../lib/prompts/prompt-mention-segments.ts | 47 ++++++ 15 files changed, 1028 insertions(+), 18 deletions(-) create mode 100644 apps/web/lib/prompts/expand-prompt-references.test.ts create mode 100644 apps/web/lib/prompts/expand-prompt-references.ts create mode 100644 apps/web/lib/prompts/prompt-mention-parser.ts create mode 100644 apps/web/lib/prompts/prompt-mention-segments.test.ts create mode 100644 apps/web/lib/prompts/prompt-mention-segments.ts diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index acf7183c8d..9459a49568 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -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. diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 9b8e618dea..c14dc902be 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -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. diff --git a/apps/backend/internal/mcp/handlers/message_task_test.go b/apps/backend/internal/mcp/handlers/message_task_test.go index 53ba0fea0b..b9269e9e02 100644 --- a/apps/backend/internal/mcp/handlers/message_task_test.go +++ b/apps/backend/internal/mcp/handlers/message_task_test.go @@ -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: "badname", Content: "before after"}, + }) + + assert.NotContains(t, out, "") + 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) diff --git a/apps/backend/internal/prompts/service/service.go b/apps/backend/internal/prompts/service/service.go index d06a573b37..6d1218c0ba 100644 --- a/apps/backend/internal/prompts/service/service.go +++ b/apps/backend/internal/prompts/service/service.go @@ -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 == '_' +} diff --git a/apps/backend/internal/prompts/service/service_test.go b/apps/backend/internal/prompts/service/service_test.go index 1b272a12c6..7bb06b6057 100644 --- a/apps/backend/internal/prompts/service/service_test.go +++ b/apps/backend/internal/prompts/service/service_test.go @@ -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 } diff --git a/apps/web/components/shared/memoized-markdown.tsx b/apps/web/components/shared/memoized-markdown.tsx index d2a3423e98..5e64062e8d 100644 --- a/apps/web/components/shared/memoized-markdown.tsx +++ b/apps/web/components/shared/memoized-markdown.tsx @@ -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 ( - + {normalizeCached(content)} diff --git a/apps/web/components/task/chat/messages/chat-message.test.tsx b/apps/web/components/task/chat/messages/chat-message.test.tsx index 812763e234..d65bd1d6f1 100644 --- a/apps/web/components/task/chat/messages/chat-message.test.tsx +++ b/apps/web/components/task/chat/messages/chat-message.test.tsx @@ -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 { 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 ( = []) { 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( + + + , + ); + + 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( + + + , + ); + + 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, @@ -78,8 +137,8 @@ function renderAgentMessageWithSession(session: Partial, 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 = `This message was sent by an agent working in task "Sender" (${SENDER_TASK_ID}). + +@improve-task + +EXPANDED PROMPT REFERENCES: The message above references saved prompts by @name. + +### @improve-task +Review this task for durable improvements.`; + const Wrapper = wrapper([{ id: SENDER_TASK_ID, title: SENDER_TITLE }]); + + render( + + + , + ); + + 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 = `Hidden agent context. + +Visible agent response.`; + const Wrapper = wrapper([]); + + render( + + + , + ); + + 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(); diff --git a/apps/web/components/task/chat/messages/chat-message.tsx b/apps/web/components/task/chat/messages/chat-message.tsx index c8762b1c4f..334321cf13 100644 --- a/apps/web/components/task/chat/messages/chat-message.tsx +++ b/apps/web/components/task/chat/messages/chat-message.tsx @@ -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 (
- +
); } @@ -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 = ComponentPropsWithoutRef & { + 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

{renderChildren(children, "p")}

; + }, + li: ({ children, node, ...props }: MarkdownChildrenProps<"li">) => { + void node; + return
  • {renderChildren(children, "li")}
  • ; + }, + h1: ({ children, node, ...props }: MarkdownChildrenProps<"h1">) => { + void node; + return

    {renderChildren(children, "h1")}

    ; + }, + h2: ({ children, node, ...props }: MarkdownChildrenProps<"h2">) => { + void node; + return

    {renderChildren(children, "h2")}

    ; + }, + h3: ({ children, node, ...props }: MarkdownChildrenProps<"h3">) => { + void node; + return

    {renderChildren(children, "h3")}

    ; + }, + h4: ({ children, node, ...props }: MarkdownChildrenProps<"h4">) => { + void node; + return

    {renderChildren(children, "h4")}

    ; + }, + h5: ({ children, node, ...props }: MarkdownChildrenProps<"h5">) => { + void node; + return
    {renderChildren(children, "h5")}
    ; + }, + h6: ({ children, node, ...props }: MarkdownChildrenProps<"h6">) => { + void node; + return
    {renderChildren(children, "h6")}
    ; + }, + blockquote: ({ children, node, ...props }: MarkdownChildrenProps<"blockquote">) => { + void node; + return
    {renderChildren(children, "blockquote")}
    ; + }, + td: ({ children, node, ...props }: MarkdownChildrenProps<"td">) => { + void node; + return {renderChildren(children, "td")}; + }, + th: ({ children, node, ...props }: MarkdownChildrenProps<"th">) => { + void node; + return {renderChildren(children, "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 ( + + {segment.value} + + ); + }); +} + 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({
    {showRaw ? (
    -            {comment.content || "(empty)"}
    +            {comment.raw_content || comment.content || "(empty)"}
               
    ) : (
    diff --git a/apps/web/hooks/use-message-handler.test.ts b/apps/web/hooks/use-message-handler.test.ts index 6e7238ea70..6205f494f8 100644 --- a/apps/web/hooks/use-message-handler.test.ts +++ b/apps/web/hooks/use-message-handler.test.ts @@ -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 { 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"); + }); +}); diff --git a/apps/web/hooks/use-message-handler.ts b/apps/web/hooks/use-message-handler.ts index 983d648f46..b48d980190 100644 --- a/apps/web/hooks/use-message-handler.ts +++ b/apps/web/hooks/use-message-handler.ts @@ -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(); + 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\nCONTEXT PROMPTS: The user has included the following prompt instructions as context:\n${resolved.join("\n\n")}\n`; + const expansions = Array.from(promptExpansions, ([name, content]) => ({ name, content })); + const expansionContext = formatPromptReferenceExpansions(expansions); + context += `\n\n\nCONTEXT PROMPTS: The user has included the following prompt instructions as context:\n${resolved.join("\n\n")}${expansionContext ? "\n\n" + expansionContext : ""}\n`; } } diff --git a/apps/web/lib/prompts/expand-prompt-references.test.ts b/apps/web/lib/prompts/expand-prompt-references.test.ts new file mode 100644 index 0000000000..41f22f9cf6 --- /dev/null +++ b/apps/web/lib/prompts/expand-prompt-references.test.ts @@ -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: "badname", content: "before after" }, + ]); + + expect(out).not.toContain(""); + expect(out).toContain("### @badname"); + expect(out).toContain("before after"); + }); +}); diff --git a/apps/web/lib/prompts/expand-prompt-references.ts b/apps/web/lib/prompts/expand-prompt-references.ts new file mode 100644 index 0000000000..ef8d6971c1 --- /dev/null +++ b/apps/web/lib/prompts/expand-prompt-references.ts @@ -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 = ""; + +function buildPromptMap(prompts: PromptReference[]) { + return new Map(prompts.map((prompt) => [prompt.name, prompt])); +} + +type ExpansionState = { + promptsByName: Map; + promptNames: string[]; + stack: Set; + seen: Set; + 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 = [], +): PromptReferenceExpansion[] { + const stack = new Set(); + 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, ""); +} diff --git a/apps/web/lib/prompts/prompt-mention-parser.ts b/apps/web/lib/prompts/prompt-mention-parser.ts new file mode 100644 index 0000000000..a835dc9d3b --- /dev/null +++ b/apps/web/lib/prompts/prompt-mention-parser.ts @@ -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); +} diff --git a/apps/web/lib/prompts/prompt-mention-segments.test.ts b/apps/web/lib/prompts/prompt-mention-segments.test.ts new file mode 100644 index 0000000000..636e68a0d6 --- /dev/null +++ b/apps/web/lib/prompts/prompt-mention-segments.test.ts @@ -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" }]); + }); +}); diff --git a/apps/web/lib/prompts/prompt-mention-segments.ts b/apps/web/lib/prompts/prompt-mention-segments.ts new file mode 100644 index 0000000000..6f402fd37d --- /dev/null +++ b/apps/web/lib/prompts/prompt-mention-segments.ts @@ -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; +}