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 (
@@ -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