From 75cb85cd074bd5edb36e1d64a595a5a1e023116b Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Wed, 10 Jun 2026 04:02:35 +0000 Subject: [PATCH 1/4] fix(agent): surface ask_user question text in tool actions, never raw JSON --- server/internal/agent/pirun/decoder.go | 8 +- server/internal/agent/pirun/decoder_test.go | 82 +++++++++++++++++++++ server/internal/agent/pirun/protocol.go | 20 +++++ 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/server/internal/agent/pirun/decoder.go b/server/internal/agent/pirun/decoder.go index a42ea54..ff77b5d 100644 --- a/server/internal/agent/pirun/decoder.go +++ b/server/internal/agent/pirun/decoder.go @@ -134,12 +134,18 @@ func decodeToolStart(line []byte) (Event, error) { if err := json.Unmarshal(line, &p); err != nil { return Event{Kind: KindUnknown, RawType: "tool_execution_start"}, err } + // ask_user is question-bearing: surface the question text, never the args + // JSON (R9 — a question must not render as a raw tool-call string). + arg := headlineArg(p.Args) + if strings.EqualFold(p.ToolName, "ask_user") { + arg = askUserHeadline(p.Args) + } return Event{ Kind: KindToolStarted, RawType: "tool_execution_start", ToolCallID: p.ToolCallID, Tool: normalizeTool(p.ToolName), - Arg: headlineArg(p.Args), + Arg: arg, }, nil } diff --git a/server/internal/agent/pirun/decoder_test.go b/server/internal/agent/pirun/decoder_test.go index f641dda..c636cc2 100644 --- a/server/internal/agent/pirun/decoder_test.go +++ b/server/internal/agent/pirun/decoder_test.go @@ -173,6 +173,7 @@ func TestNormalizeTool(t *testing.T) { cases := map[string]string{ "bash": "Bash", "read": "Read", "write": "Write", "edit": "Edit", "grep": "Grep", "BASH": "Bash", "custom_tool": "Custom_tool", "": "", + "ask_user": "Ask", "Ask_user": "Ask", } for in, want := range cases { if got := normalizeTool(in); got != want { @@ -181,6 +182,87 @@ func TestNormalizeTool(t *testing.T) { } } +// TestDecodeAskUserToolStart pins the no-JSON guarantee on the action-log path +// (R9): an ask_user tool call surfaces the question text as its arg, never the +// args object dumped as JSON. +func TestDecodeAskUserToolStart(t *testing.T) { + cases := []struct { + name string + line string + want string + }{ + { + name: "question only", + line: `{"type":"tool_execution_start","toolCallId":"t1","toolName":"ask_user","args":{"question":"Which file?"}}`, + want: "Which file?", + }, + { + name: "question with kind and options", + line: `{"type":"tool_execution_start","toolCallId":"t2","toolName":"ask_user","args":{"question":"Which framework?","kind":"select","options":["React","Vue"]}}`, + want: "Which framework?", + }, + { + name: "JSON-ish question text passes through verbatim", + line: `{"type":"tool_execution_start","toolCallId":"t3","toolName":"ask_user","args":{"question":"Use {\"mode\":\"strict\"} here?"}}`, + want: `Use {"mode":"strict"} here?`, + }, + { + name: "missing question degrades to placeholder", + line: `{"type":"tool_execution_start","toolCallId":"t4","toolName":"ask_user","args":{"kind":"confirm"}}`, + want: askUserPlaceholder, + }, + { + name: "non-string question degrades to placeholder", + line: `{"type":"tool_execution_start","toolCallId":"t5","toolName":"ask_user","args":{"question":{"nested":true}}}`, + want: askUserPlaceholder, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ev, err := Decode([]byte(tc.line)) + if err != nil { + t.Fatalf("decode: %v", err) + } + if ev.Kind != KindToolStarted || ev.Tool != "Ask" { + t.Fatalf("decoded as kind=%q tool=%q, want tool_started/Ask", ev.Kind, ev.Tool) + } + if ev.Arg != tc.want { + t.Errorf("arg = %q, want %q", ev.Arg, tc.want) + } + if strings.Contains(ev.Arg, `"question"`) { + t.Errorf("arg leaked raw args JSON: %q", ev.Arg) + } + }) + } +} + +// TestDecodeAskUserToolEnd: the tool result (the user's answer) rides through +// unchanged under the normalized name. +func TestDecodeAskUserToolEnd(t *testing.T) { + ev, err := Decode([]byte(`{"type":"tool_execution_end","toolCallId":"t1","toolName":"ask_user","isError":false,"result":{"content":[{"type":"text","text":"Vue"}]}}`)) + if err != nil { + t.Fatalf("decode: %v", err) + } + if ev.Kind != KindToolCompleted || ev.Tool != "Ask" || ev.Output != "Vue" || ev.IsError { + t.Errorf("decoded as %+v, want completed Ask with output Vue", ev) + } +} + +// TestDecodeOtherToolsKeepJSONFallback: the generic compact-JSON arg fallback +// is unchanged for tools that aren't question-bearing. +func TestDecodeOtherToolsKeepJSONFallback(t *testing.T) { + ev, err := Decode([]byte(`{"type":"tool_execution_start","toolCallId":"t9","toolName":"custom_tool","args":{"question":"not special here","foo":1}}`)) + if err != nil { + t.Fatalf("decode: %v", err) + } + if ev.Tool != "Custom_tool" { + t.Fatalf("tool = %q, want Custom_tool", ev.Tool) + } + if !strings.Contains(ev.Arg, `"foo":1`) { + t.Errorf("generic fallback arg = %q, want compact JSON of all args", ev.Arg) + } +} + func TestMarshalCommandInjectsType(t *testing.T) { b, err := Marshal(Prompt{Message: "hi", ID: "r1"}) if err != nil { diff --git a/server/internal/agent/pirun/protocol.go b/server/internal/agent/pirun/protocol.go index 72105f7..fcfa489 100644 --- a/server/internal/agent/pirun/protocol.go +++ b/server/internal/agent/pirun/protocol.go @@ -107,6 +107,8 @@ func normalizeTool(name string) string { return "Bash" case "think": return "Think" + case "ask_user": + return "Ask" case "": return "" default: @@ -118,6 +120,24 @@ func normalizeTool(name string) string { // argument, in priority order. bash uses "command"; file tools use a path key. var argKeys = []string{"command", "path", "file_path", "filePath", "pattern", "url"} +// askUserPlaceholder is the headline arg for an ask_user call whose question +// can't be extracted. The question must never surface as raw JSON (R9), so the +// generic compact-JSON fallback is off-limits for this tool. +const askUserPlaceholder = "(question unavailable)" + +// askUserHeadline extracts the question text from an ask_user call's args. The +// extension schema requires "question" as a string; anything else degrades to a +// readable placeholder, never the args JSON. +func askUserHeadline(args map[string]json.RawMessage) string { + if v, ok := args["question"]; ok { + var s string + if err := json.Unmarshal(v, &s); err == nil && strings.TrimSpace(s) != "" { + return strings.TrimSpace(s) + } + } + return askUserPlaceholder +} + // headlineArg picks a single human-readable argument string from a tool's args // object: a known key if present, otherwise the compact JSON of the whole map // so nothing is silently lost. From ae159dd849c822c5beb5bbad7a2e935c2aebdd3e Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Wed, 10 Jun 2026 04:06:22 +0000 Subject: [PATCH 2/4] fix(super-threads): render ask_user actions as friendly question rows --- .../super-threads/AgentTaskCard.tsx | 15 ++++- src/components/super-threads/atoms.tsx | 32 +++++++++ .../super-threads/question-action.test.ts | 66 +++++++++++++++++++ src/components/super-threads/utils.ts | 24 +++++++ 4 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 src/components/super-threads/question-action.test.ts diff --git a/src/components/super-threads/AgentTaskCard.tsx b/src/components/super-threads/AgentTaskCard.tsx index ea2923d..750d8c4 100644 --- a/src/components/super-threads/AgentTaskCard.tsx +++ b/src/components/super-threads/AgentTaskCard.tsx @@ -14,7 +14,7 @@ import { import type { AgentTask } from "@/types"; import { DEUCE } from "@/lib/deuce"; import { AgentAvatar, StopButton, TypingDots } from "./atoms"; -import { stripMention } from "./utils"; +import { askUserQuestion, stripMention } from "./utils"; export function AgentTaskCard({ sessionId, @@ -29,6 +29,9 @@ export function AgentTaskCard({ }) { const state = task.state; const latest = task.actions[task.actions.length - 1]; + // An in-flight ask_user call shows its question on the live line, never the + // raw tool args (R9). Covers post-fix "Ask" rows and legacy "Ask_user" rows. + const latestQuestion = latest ? askUserQuestion(latest.tool, latest.arg) : null; return (
- {latest?.tool === "Think" ? "Thinking" : latest?.tool ?? "Starting"} + {latest?.tool === "Think" + ? "Thinking" + : latestQuestion !== null + ? "Asked" + : latest?.tool ?? "Starting"} - {latest && latest.tool !== "Think" ? latest.arg : ""} + {latest && latest.tool !== "Think" + ? latestQuestion ?? latest.arg + : ""}
diff --git a/src/components/super-threads/atoms.tsx b/src/components/super-threads/atoms.tsx index f91b6a1..342c832 100644 --- a/src/components/super-threads/atoms.tsx +++ b/src/components/super-threads/atoms.tsx @@ -14,12 +14,14 @@ import { Loader2, CircleDot, AlertCircle, + MessageCircleQuestion, Square, type LucideIcon, } from "lucide-react"; import type { AgentAction } from "@/types"; import { DEUCE } from "@/lib/deuce"; import { api } from "@/lib/api"; +import { askUserQuestion } from "./utils"; // Tool → icon, mirroring TOOL_ICON in the prototype. Unknown tools fall back to // a neutral dot. @@ -149,6 +151,36 @@ function ActionItem({ action }: { action: AgentAction }) { ); } + // ask_user calls render as a question row, never as tool(rawArgs) — covers + // both post-fix "Ask" rows and legacy persisted "Ask_user" rows whose arg is + // still the raw JSON (R9). No spinner while started: the "needs your input" + // prompt below the log is the waiting affordance, and a question that never + // completed shouldn't spin forever in a terminal task's history. The + // completed row keeps its q-out block, pairing the question with the answer. + const question = askUserQuestion(action.tool, action.arg); + if (question !== null) { + return ( +
+
+ + + + + Asked + + {question} + + {cls !== "run" && ( + + + + )} +
+ {cls !== "run" && action.text &&
{action.text}
} +
+ ); + } + const ToolIcon = TOOL_ICON[action.tool] ?? CircleDot; return (
diff --git a/src/components/super-threads/question-action.test.ts b/src/components/super-threads/question-action.test.ts new file mode 100644 index 0000000..76f5b82 --- /dev/null +++ b/src/components/super-threads/question-action.test.ts @@ -0,0 +1,66 @@ +// askUserQuestion — display extraction for ask_user tool actions (R9: a +// question never renders as raw JSON in the action log or task card). +// +// NOTE: The frontend has no test runner wired up yet (see agent-runs.test.ts). +// These Vitest-style specs capture the intended behavior and run as soon as a +// runner is added. + +import { describe, expect, it } from "vitest"; + +import { askUserQuestion } from "./utils"; + +describe("askUserQuestion", () => { + it("passes a post-fix Ask row's arg through verbatim", () => { + expect(askUserQuestion("Ask", "Which file?")).toBe("Which file?"); + }); + + it("does not re-parse an Ask row whose question text is itself JSON", () => { + // The server already extracted the question; JSON-looking text is the + // question, not a payload to unwrap. + expect(askUserQuestion("Ask", '{"not":"a question"}')).toBe( + '{"not":"a question"}', + ); + }); + + it("extracts the question from a legacy Ask_user JSON arg", () => { + expect(askUserQuestion("Ask_user", '{"question":"Which file?"}')).toBe( + "Which file?", + ); + }); + + it("extracts just the question from a legacy select-kind arg", () => { + const arg = + '{"question":"Which framework?","kind":"select","options":["React","Vue"]}'; + expect(askUserQuestion("Ask_user", arg)).toBe("Which framework?"); + }); + + it("unescapes escaped content in a legacy arg", () => { + expect( + askUserQuestion("Ask_user", '{"question":"Name it \\"deuce\\"?\\nSure?"}'), + ).toBe('Name it "deuce"?\nSure?'); + }); + + it("degrades a truncated legacy arg to a readable label, never throws", () => { + expect(askUserQuestion("Ask_user", '{"question":"Which fi')).toBe( + "(question unavailable)", + ); + }); + + it("degrades a legacy arg with no question key to a readable label", () => { + expect(askUserQuestion("Ask_user", '{"kind":"confirm"}')).toBe( + "(question unavailable)", + ); + }); + + it("degrades a legacy arg with a non-string question to a readable label", () => { + expect(askUserQuestion("Ask_user", '{"question":{"nested":true}}')).toBe( + "(question unavailable)", + ); + }); + + it("returns null for non-question tools, even with JSON-looking args", () => { + expect(askUserQuestion("Bash", '{"question":"trick"}')).toBeNull(); + expect(askUserQuestion("Custom_tool", '{"foo":1}')).toBeNull(); + expect(askUserQuestion("Think", "")).toBeNull(); + }); +}); diff --git a/src/components/super-threads/utils.ts b/src/components/super-threads/utils.ts index e046e82..805f260 100644 --- a/src/components/super-threads/utils.ts +++ b/src/components/super-threads/utils.ts @@ -5,3 +5,27 @@ export function stripMention(text: string): string { return text.replace(/@\w+/, "").replace(/^[\s,]+/, "").trim(); } + +// askUserQuestion returns the display question for an ask_user tool action, or +// null when the action isn't one. Two shapes exist (R9 — a question must never +// render as raw JSON): +// - "Ask": post-fix rows where the server already extracted the question; the +// arg passes through verbatim (even JSON-looking question text — never +// re-parsed, the server settled it). +// - "Ask_user": legacy persisted rows whose arg is the raw args object +// (`{"question":"..."}`); parse it for the question, degrading to a +// readable label when the JSON is truncated or the key is missing. +export function askUserQuestion(tool: string, arg: string): string | null { + if (tool === "Ask") return arg; + if (tool !== "Ask_user") return null; + try { + const parsed: unknown = JSON.parse(arg); + if (parsed && typeof parsed === "object") { + const q = (parsed as { question?: unknown }).question; + if (typeof q === "string" && q.trim() !== "") return q.trim(); + } + } catch { + // Truncated/garbled legacy arg — fall through to the readable label. + } + return "(question unavailable)"; +} From f788c85b29dd5ef280d2ca7c0c8a1a2a5c3475ce Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Wed, 10 Jun 2026 04:13:39 +0000 Subject: [PATCH 3/4] fix(super-threads): handle missing ask_user action arg with readable fallback --- .../super-threads/question-action.test.ts | 18 ++++++++++++++---- src/components/super-threads/utils.ts | 13 ++++++++++--- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/components/super-threads/question-action.test.ts b/src/components/super-threads/question-action.test.ts index 76f5b82..e656c86 100644 --- a/src/components/super-threads/question-action.test.ts +++ b/src/components/super-threads/question-action.test.ts @@ -1,9 +1,5 @@ // askUserQuestion — display extraction for ask_user tool actions (R9: a // question never renders as raw JSON in the action log or task card). -// -// NOTE: The frontend has no test runner wired up yet (see agent-runs.test.ts). -// These Vitest-style specs capture the intended behavior and run as soon as a -// runner is added. import { describe, expect, it } from "vitest"; @@ -58,6 +54,20 @@ describe("askUserQuestion", () => { ); }); + it("degrades an Ask row with a missing or empty arg to the readable label", () => { + // A synthesized row (action_completed seen without its action_started) + // carries no arg. + expect(askUserQuestion("Ask", undefined)).toBe("(question unavailable)"); + expect(askUserQuestion("Ask", "")).toBe("(question unavailable)"); + expect(askUserQuestion("Ask", " ")).toBe("(question unavailable)"); + }); + + it("degrades an Ask_user row with a missing arg, never throws", () => { + expect(askUserQuestion("Ask_user", undefined)).toBe( + "(question unavailable)", + ); + }); + it("returns null for non-question tools, even with JSON-looking args", () => { expect(askUserQuestion("Bash", '{"question":"trick"}')).toBeNull(); expect(askUserQuestion("Custom_tool", '{"foo":1}')).toBeNull(); diff --git a/src/components/super-threads/utils.ts b/src/components/super-threads/utils.ts index 805f260..65560bb 100644 --- a/src/components/super-threads/utils.ts +++ b/src/components/super-threads/utils.ts @@ -15,11 +15,18 @@ export function stripMention(text: string): string { // - "Ask_user": legacy persisted rows whose arg is the raw args object // (`{"question":"..."}`); parse it for the question, degrading to a // readable label when the JSON is truncated or the key is missing. -export function askUserQuestion(tool: string, arg: string): string | null { - if (tool === "Ask") return arg; +// arg can be undefined (a synthesized row from an action_completed seen +// without its action_started carries no arg) — degrade to the label. +export function askUserQuestion( + tool: string, + arg: string | undefined, +): string | null { + if (tool === "Ask") { + return arg && arg.trim() !== "" ? arg : "(question unavailable)"; + } if (tool !== "Ask_user") return null; try { - const parsed: unknown = JSON.parse(arg); + const parsed: unknown = JSON.parse(arg ?? ""); if (parsed && typeof parsed === "object") { const q = (parsed as { question?: unknown }).question; if (typeof q === "string" && q.trim() !== "") return q.trim(); From 8b856df17c8323062e3ff2da97d55a8a1e1f07d6 Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Wed, 10 Jun 2026 04:13:54 +0000 Subject: [PATCH 4/4] docs: plan for ask_user action row rendering fix --- ...-fix-ask-user-action-row-rendering-plan.md | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 docs/plans/2026-06-10-001-fix-ask-user-action-row-rendering-plan.md diff --git a/docs/plans/2026-06-10-001-fix-ask-user-action-row-rendering-plan.md b/docs/plans/2026-06-10-001-fix-ask-user-action-row-rendering-plan.md new file mode 100644 index 0000000..ba31a8f --- /dev/null +++ b/docs/plans/2026-06-10-001-fix-ask-user-action-row-rendering-plan.md @@ -0,0 +1,213 @@ +--- +title: "fix: Render ask_user tool calls as friendly question rows, never raw JSON" +type: fix +status: completed +date: 2026-06-10 +origin: docs/brainstorms/2026-06-08-interactive-agent-questions-requirements.md +--- + +# fix: Render ask_user tool calls as friendly question rows, never raw JSON + +## Summary + +When an agent calls the `ask_user` tool, the interactive question prompt renders +correctly — but the tool call itself also appears in the agent-thread action log +(and on the task card's live "working" line) as raw JSON: +`Ask_user({"question":"What would you like to name the file…"})`. This is the +one display surface the interactive-questions work missed: `normalizeTool` +title-cases unknown tool names and `headlineArg` has no `question` key, so it +falls back to dumping the whole args object as compact JSON. The origin +requirement that "a question is never displayed to the user as raw JSON, on any +path" is violated on the action-log path (see origin: `docs/brainstorms/2026-06-08-interactive-agent-questions-requirements.md`, R9). + +Fix the display at two layers: server-side at decode time, so new persisted +rows and live broadcasts carry a friendly label and the question text; and +client-side at render time, so historical rows already persisted with the raw +JSON arg also render clean. The row stays visible while the prompt is active +and pairs the question with the user's answer in task history. + +## Requirements + +- R1. An `ask_user` tool call renders in the thread action log as a friendly + question row showing the question text — never raw JSON or a literal + tool-call string (closes origin R9 on the action-log path). +- R2. The task card's live "working" line shows the same friendly form for an + in-flight `ask_user` call, never raw JSON. +- R3. Historical action rows already persisted with tool `Ask_user` and a raw + JSON arg render friendly too — on first load, after a snapshot refetch, and + in a terminal task's collapsed action log. +- R4. The action row remains visible while the interactive prompt is showing; + once answered, the completed row pairs the question with the user's answer in + task history. +- R5. Rendering of all other tools is unchanged, and an unparseable or + unexpected arg never crashes the renderer — it degrades to readable text. + +## Key Technical Decisions + +- **Fix at decode time AND render time.** The server-side fix (decoder/ + `headlineArg`) makes the persisted row and the live broadcast identical, so a + snapshot refetch can never flip a friendly row back to raw JSON. But rows + persisted before the fix carry the raw arg forever, so the frontend must also + special-case rendering. Neither layer alone covers everything. +- **Friendly vocabulary: normalized tool name `Ask`, arg = the question text.** + `normalizeTool("ask_user")` gets an explicit case (today it falls to the + generic title-case branch producing `Ask_user`); the headline arg for this + tool is the extracted `question` value, not the args JSON. The frontend + special-case must match both the new `Ask` name and the legacy persisted + `Ask_user` name. +- **Server fallback floor is readable text, not JSON.** If the `question` key + is missing or unparseable at decode time, the arg degrades to a short + readable placeholder — under R1, falling back to the compact-JSON dump is no + longer acceptable for this tool. +- **Legacy rows: extract the question client-side, accept losing kind/options + from history.** The frontend parses the legacy JSON arg (`JSON.parse` in a + try/catch) to pull `question` for display. A select question's options exist + in history only inside that raw arg; they were never rendered historically + and are deliberately not resurrected — the question text alone is the + historical record. (The live prompt's options are unaffected; they ride + `pendingQuestionOptions`.) +- **Keep the row while the prompt is active.** The friendly row and the + "needs your input" prompt coexist; after the answer, the row is the durable + question-and-answer record in the collapsed action history. + +## Implementation Units + +### U1. Server: decode ask_user tool events into a friendly action + +**Goal:** `tool_execution_start` / `tool_execution_end` for `ask_user` produce +an action with tool name `Ask` and the question text as the arg, in both the +persisted row and the live broadcast. + +**Requirements:** R1, R2 (data side), R5. + +**Dependencies:** none. + +**Files:** +- `server/internal/agent/pirun/protocol.go` (`normalizeTool`, `headlineArg` / + a tool-aware variant) +- `server/internal/agent/pirun/decoder.go` (`decodeToolStart` — the only place + with access to both the raw tool name and the args map) +- `server/internal/agent/pirun/decoder_test.go` + +**Approach:** Add an explicit `ask_user` case to `normalizeTool` returning +`Ask`. Make headline-arg extraction tool-aware for this one tool: pull the +`question` string from the args map; when absent or not a string, use a short +readable placeholder instead of the compact-JSON fallback. `decodeToolEnd` +needs only the name normalization (its output is the tool result, which for +`ask_user` is the user's answer — already plain text). Match the raw tool name +case-insensitively, consistent with `normalizeTool`'s existing behavior. + +**Patterns to follow:** the existing switch cases in `normalizeTool` and the +`argKeys` priority lookup in `headlineArg` (`server/internal/agent/pirun/protocol.go`); +existing fixtures in `decoder_test.go` (`TestNormalizeTool`, the +tool-event decode tests). + +**Test scenarios:** +- An `ask_user` `tool_execution_start` with `{"question":"Which file?"}` + decodes to `Tool: "Ask"`, `Arg: "Which file?"`. +- An `ask_user` start with `kind` and `options` alongside `question` still + yields just the question text as the arg. +- An `ask_user` start with no `question` key (or a non-string value) yields the + readable placeholder — never the JSON dump. +- An `ask_user` `tool_execution_end` decodes with `Tool: "Ask"` and the + result text (the user's answer) as output. +- A question whose text contains braces/quotes/JSON-ish content passes through + verbatim. +- Other unknown tools still title-case and still fall back to compact JSON + (`custom_tool` → `Custom_tool`, args dump) — the generic path is unchanged. + +**Verification:** Decoder tests pass; in a live session, a new `ask_user` call +shows `Ask(Which file?)`-shaped data in the `action_started` WS frame and the +persisted row. + +### U2. Frontend: friendly question row in the action log and task card + +**Goal:** Render `ask_user` actions — both new (`Ask`) and legacy persisted +(`Ask_user` with raw JSON arg) — as a question-styled row, mirroring the +existing `Think` special case; the task card live line follows suit. + +**Requirements:** R1, R2, R3, R4, R5. + +**Dependencies:** U1 (pins the new tool name the frontend must also match). + +**Files:** +- `src/components/super-threads/atoms.tsx` (`ActionItem`, `TOOL_ICON`) +- `src/components/super-threads/AgentTaskCard.tsx` (live line special case) +- `src/components/super-threads/utils.ts` (pure question-extraction helper) +- `src/components/super-threads/question-action.test.ts` (helper spec, + following the repo's existing runner-less spec convention — see + `src/stores/agent-runs.test.ts`) + +**Approach:** Add a pure helper that, given an action's tool + arg, answers +"is this an ask_user action, and what question text should display?" — matching +tool names `Ask` and `Ask_user`, parsing a JSON-shaped arg for `question` in a +try/catch, passing a plain-text arg through, and degrading to a readable label +when neither works. In `ActionItem`, branch on it beside the `Think` case: +render a question icon (e.g. a message/help Lucide icon added to `TOOL_ICON` +or used directly) with "Asked — {question}" phrasing instead of `tool(arg)` +parens; keep the completed row's output block (the user's answer) as-is so +history reads question → answer. In `AgentTaskCard`'s live line, apply the +same helper so the in-flight display shows the question, not the arg. + +**Patterns to follow:** the `Think` branch in `ActionItem` +(`src/components/super-threads/atoms.tsx`) and the `Think` ternary in +`AgentTaskCard`'s live line; `stripMention`-style pure helpers in +`src/components/super-threads/utils.ts`. + +**Test scenarios:** +- Legacy row `{tool: "Ask_user", arg: '{"question":"Which file?"}'}` extracts + "Which file?". +- Legacy select-kind arg (question + kind + options JSON) extracts just the + question. +- New row `{tool: "Ask", arg: "Which file?"}` passes the arg through. +- Unparseable arg (truncated JSON, empty string) degrades to a readable label — + the helper never throws. +- A non-ask_user tool with a JSON-looking arg is not treated as a question row. +- An escaped question (`\"` / `\n` inside the JSON string) is unescaped by the + parse, and a question containing an `@mention` renders as plain text. + +**Verification:** `npx tsc --noEmit` and `npm run lint` clean. Manual pass in a +live session: while the agent waits, the thread shows the friendly row above +the "needs your input" prompt and the task card live line shows the question; +after answering, the collapsed history pairs question and answer; a session +with pre-fix history renders its old rows clean, including after a reconnect +(snapshot refetch). + +## Scope Boundaries + +**Deferred to Follow-Up Work** + +Surfaced by research on this bug but outside the confirmed display-fix scope: + +- Observability hardening for the structured-question pipeline: the decoder + silently ignores `extension_error` events and logs unknown event types at + Debug (`server/internal/agent/pirun/decoder.go`) — both invisible at default + log levels if the pipeline ever does break. +- A watchdog for an `ask_user` call that never produces `extension_ui_request` + (including the hang variant where no `tool_execution_end` arrives either), + with a session-visible notice. +- Pi version pinning or version logging — Pi floats per-container, frozen at + whatever pi.dev served at first provision; the event protocol was only ever + verified against 0.74.2. +- `ask_user` behavior inside pi-subagents child agents (child Pi instances are + invisible to Deuce's event channel — documented open design question in + `docs/solutions/architecture-patterns/pi-loads-agent-skills-standard-in-rpc-mode.md`). +- Started-but-never-completed action rows spinning forever in terminal tasks + (pre-existing for all tools when a process is torn down mid-call). +- Establishing a frontend test runner (vitest) so the accumulated spec files + actually run. + +## Sources & Research + +- Origin requirements: `docs/brainstorms/2026-06-08-interactive-agent-questions-requirements.md` + (R9 is the violated guarantee; this plan closes its last display path). +- Prior implementation plan: `docs/plans/2026-06-08-001-feat-interactive-agent-questions-plan.md` + (shipped the prompt UI and the narrated-text backstop; the action log sat + outside all three of its defense tiers). +- Raw-render mechanics: `server/internal/agent/pirun/protocol.go` + (`normalizeTool` title-case default, `headlineArg` JSON fallback); + render sites `src/components/super-threads/atoms.tsx` (`ActionItem`), + `src/components/super-threads/AgentTaskCard.tsx` (live line). +- Persistence/replay constraint: action rows are stored verbatim and replayed + via snapshot (`server/internal/agent/dbstore.go`, `src/stores/agent-runs.ts`), + which is why the fix needs both layers.