diff --git a/apps/server/src/agents/tmux/command-builder.ts b/apps/server/src/agents/tmux/command-builder.ts index 35bd95e73..20de87884 100644 --- a/apps/server/src/agents/tmux/command-builder.ts +++ b/apps/server/src/agents/tmux/command-builder.ts @@ -322,6 +322,9 @@ export function buildLaunchGuidance( ? "Report status with dispatch_event as you work and before your final response — blocked means genuinely stuck, not an error you're about to fix. Your reported status is verified against session activity and auto-corrected." : "Report status with dispatch_event. Types: working (making progress — includes debugging, fixing test failures, investigating errors), blocked (completely stuck with no further approach to try — NOT for errors or test failures you plan to fix next), waiting_user (need a decision or approval), done (task complete), idle (no-op, just answered a question). Emit working at turn start and when shifting phases. Emit a terminal event before your final response. Your reported status is verified against session activity and auto-corrected when it doesn't match." ); + rules.push( + "Once you accept a task, do not end a turn after only announcing a plan or status. Continue into substantive work in the same turn, or explicitly report waiting_user or blocked when you genuinely cannot proceed." + ); if (chatSurface) { rules.push(CHAT_SURFACE_GUIDANCE_RULE); } diff --git a/apps/server/test/tmux-command-builder.test.ts b/apps/server/test/tmux-command-builder.test.ts index d7112eb40..764bc3a2e 100644 --- a/apps/server/test/tmux-command-builder.test.ts +++ b/apps/server/test/tmux-command-builder.test.ts @@ -1111,6 +1111,18 @@ describe("buildLaunchGuidance — trimmed variant", () => { expect(text).toContain("auto-corrected"); }); + it("requires accepted tasks to continue past plan-only turns", () => { + for (const agentType of ["claude", "codex"] as const) { + for (const trimmedGuidance of [false, true]) { + const text = guidance({ agentType, trimmedGuidance }); + expect(text).toContain( + "do not end a turn after only announcing a plan or status" + ); + expect(text).toContain("Continue into substantive work"); + } + } + }); + it("folds the two pin rules into one", () => { const full = guidance({ agentType: "claude" }); const text = guidance({ agentType: "claude", trimmedGuidance: true }); diff --git a/apps/web/src/components/app/chat/chat-entries.tsx b/apps/web/src/components/app/chat/chat-entries.tsx index 9de6eb372..f1cef9b89 100644 --- a/apps/web/src/components/app/chat/chat-entries.tsx +++ b/apps/web/src/components/app/chat/chat-entries.tsx @@ -767,6 +767,12 @@ function QuestionOptions({ if (!question) return null; const answer = message.answer; const open = answer === null; + const answeredOption = answer + ? (question.options.find( + (option) => (option.value ?? option.label) === answer.value + ) ?? null) + : null; + const answerDisplay = answeredOption?.label ?? answer?.value ?? ""; const optionsDisabled = answer !== null || answering || answersDisabled; return (
Answered - · {answer.label ?? answer.value} + + + {answeredOption ? ( + + {answerDisplay} + + ) : ( + {answerDisplay} + )} +
)}
@@ -817,7 +832,7 @@ function QuestionOptions({ onClick={() => onAnswer(option)} > {chosen ? : null} - {option.label} + {option.label} ); })} @@ -891,6 +906,7 @@ export const ChatMessageView = memo(function ChatMessageView({ ctx, answering, answersDisabled = false, + answeredOptionLabel = null, onAnswer, }: { message: ChatMessage; @@ -902,6 +918,8 @@ export const ChatMessageView = memo(function ChatMessageView({ answering: boolean; /** Answers go through the same injection as the composer; lock them together. */ answersDisabled?: boolean; + /** Canonical option label when this user row answers a declared option. */ + answeredOptionLabel?: string | null; onAnswer: (messageId: string, option: ChatQuestionOption) => void; }): JSX.Element { const copyAction = message.text ? ( @@ -934,7 +952,11 @@ export const ChatMessageView = memo(function ChatMessageView({ ) : null} {message.text ? (
- {message.text} + {answeredOptionLabel ? ( + {answeredOptionLabel} + ) : ( + message.text + )}
) : null} diff --git a/apps/web/src/components/app/chat/chat-feed.test.tsx b/apps/web/src/components/app/chat/chat-feed.test.tsx index 39eaa3374..e7166ad10 100644 --- a/apps/web/src/components/app/chat/chat-feed.test.tsx +++ b/apps/web/src/components/app/chat/chat-feed.test.tsx @@ -887,7 +887,7 @@ describe("ChatFeed", () => { expect(update!.textContent).toContain("Still going"); }); - it("renders an unanswered question with clickable options", () => { + it("renders an unanswered question with clickable Markdown options", () => { const { onAnswer } = renderFeed([ chat( message({ @@ -895,7 +895,10 @@ describe("ChatFeed", () => { kind: "question", text: "Which one?", question: { - options: [{ label: "Alpha", value: "a" }, { label: "Beta" }], + options: [ + { label: "**Alpha** uses `a`", value: "a" }, + { label: "Beta" }, + ], allowFreeform: true, }, }) @@ -906,6 +909,9 @@ describe("ChatFeed", () => { const options = screen.getAllByTestId("chat-question-option"); expect(options).toHaveLength(2); expect(options.every((o) => !(o as HTMLButtonElement).disabled)).toBe(true); + expect(options[0]!.textContent).toBe("Alpha uses a"); + expect(options[0]!.querySelector("strong")?.textContent).toBe("Alpha"); + expect(options[0]!.querySelector("code")?.textContent).toBe("a"); fireEvent.click(options[1]!); expect(onAnswer).toHaveBeenCalledWith("q1", { label: "Beta" }); @@ -924,23 +930,45 @@ describe("ChatFeed", () => { kind: "question", text: "Which one?", question: { - options: [{ label: "Alpha", value: "a" }, { label: "Beta" }], + options: [ + { label: "**Alpha** uses `a`", value: "a" }, + { label: "Beta" }, + ], allowFreeform: true, }, answer: { value: "a", - label: "Alpha", + label: "**Alpha** uses `a`", replyMessageId: "u9", answeredAt: "2026-09-02T10:01:00.000Z", }, }) ), + chat( + message({ + id: "u9", + authorKind: "user", + text: "**Alpha** uses `a`", + replyTo: "q1", + delivered: true, + createdAt: "2026-09-02T10:01:00.000Z", + updatedAt: "2026-09-02T10:01:00.000Z", + }) + ), ]); expect(screen.queryByTestId("chat-needs-reply")).toBeNull(); expect(screen.queryByText("Or type a reply below.")).toBeNull(); - expect(screen.getByTestId("chat-question-options").textContent).toContain( - "Answered" - ); + const card = screen.getByTestId("chat-question-options"); + expect(card.textContent).toContain("Answered"); + expect(card.textContent).not.toContain("**Alpha**"); + expect(card.querySelector("strong")?.textContent).toBe("Alpha"); + expect(card.querySelector("code")?.textContent).toBe("a"); + const userReply = screen + .getAllByTestId("chat-message") + .find((row) => row.getAttribute("data-message-id") === "u9")!; + expect(userReply.textContent).not.toContain("**Alpha**"); + expect(userReply.querySelector("strong")?.textContent).toBe("Alpha"); + expect(userReply.querySelector("code")?.textContent).toBe("a"); const options = screen.getAllByTestId("chat-question-option"); expect(options.every((o) => (o as HTMLButtonElement).disabled)).toBe(true); expect(options[0]!.getAttribute("aria-pressed")).toBe("true"); @@ -949,6 +977,34 @@ describe("ChatFeed", () => { expect(onAnswer).not.toHaveBeenCalled(); }); + it("keeps a freeform answer literal in the answered summary", () => { + renderFeed([ + chat( + message({ + id: "q-freeform", + kind: "question", + text: "Other?", + question: { + options: [{ label: "Suggested" }], + allowFreeform: true, + }, + answer: { + value: "__init__.py uses `literal` *marks*", + label: "__init__.py uses `literal` *marks*", + replyMessageId: "u10", + answeredAt: "2026-09-02T10:01:00.000Z", + }, + }) + ), + ]); + + const card = screen.getByTestId("chat-question-options"); + expect(card.textContent).toContain("__init__.py uses `literal` *marks*"); + expect(card.querySelector("strong")).toBeNull(); + expect(card.querySelector("code")).toBeNull(); + expect(card.querySelector("em")).toBeNull(); + }); + it("locks options and hides the freeform hint while answers are unavailable", () => { renderFeed( [ diff --git a/apps/web/src/components/app/chat/chat-feed.tsx b/apps/web/src/components/app/chat/chat-feed.tsx index 950d54d5c..6fd9856c0 100644 --- a/apps/web/src/components/app/chat/chat-feed.tsx +++ b/apps/web/src/components/app/chat/chat-feed.tsx @@ -329,6 +329,15 @@ export function ChatFeed({ answersDisabled = false, onAnswer, }: ChatFeedProps): JSX.Element { + const messageDirectory = useMemo( + () => + new Map( + entries + .filter((entry) => entry.type === "chat") + .map((entry) => [entry.message.id, entry.message] as const) + ), + [entries] + ); const rows = useMemo(() => layoutFeed(entries, ctx), [entries, ctx]); const entering = useEnteringEntries(entries); @@ -388,6 +397,17 @@ export function ChatFeed({ } if (row.kind === "status") return null; const entry = row.entry; + const answeredOptionLabel = (() => { + if (entry.type !== "chat" || !entry.message.replyTo) return null; + const question = messageDirectory.get(entry.message.replyTo); + if (question?.answer?.replyMessageId !== entry.message.id) + return null; + const option = question.question?.options.find( + (candidate) => + (candidate.value ?? candidate.label) === question.answer?.value + ); + return option?.label ?? null; + })(); const view = (() => { switch (entry.type) { case "chat": @@ -400,6 +420,7 @@ export function ChatFeed({ ctx={ctx} answering={answeringMessageId === entry.message.id} answersDisabled={answersDisabled} + answeredOptionLabel={answeredOptionLabel} onAnswer={onAnswer} /> ); diff --git a/apps/web/src/components/ui/markdown.tsx b/apps/web/src/components/ui/markdown.tsx index fbee1424f..8b7fd61ab 100644 --- a/apps/web/src/components/ui/markdown.tsx +++ b/apps/web/src/components/ui/markdown.tsx @@ -32,7 +32,7 @@ function getCodeBlock( type MarkdownProps = { children: string; className?: string; - variant?: "default" | "pin" | "caption"; + variant?: "default" | "pin" | "caption" | "inline"; // Colors h1/h2 for skimming a long document (see MarkdownDefault). Off by // default: most `default`-variant consumers are compact cards that pass // their own dimmed base color (e.g. text-muted-foreground, text-foreground/85) @@ -55,6 +55,10 @@ export const Markdown = memo(function Markdown({ return {children}; } + if (variant === "inline") { + return {children}; + } + return ( {children} @@ -62,6 +66,29 @@ export const Markdown = memo(function Markdown({ ); }); +function MarkdownInline({ + children, + className, +}: Pick): JSX.Element { + return ( + + + {children} + + + ); +} + /** * Single-line muted markdown for subtitles (e.g. a shortcut pin's caption). * Inline marks only — block elements are unwrapped so the caption can never