From 0106eee4ffb094e440d1d07d70e21124b9c0fb9d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 14:17:40 -0700 Subject: [PATCH] feat(compound-engineering): live agent output, steering, and a real Q&A surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can now watch everything the agent does while a CE stage works, steer it mid-stage, and read the whole conversation as a proper chat surface. Live output: - New host capability: CreateInteractiveAiSessionOptions.onProgress — the engine adapter streams thinking/text deltas + tool start/end markers from the pi agent hooks (any plugin can use this). - Orchestrator buffers per-session live activity (merged deltas, discrete tool lines, capped), emits throttled progress events over SSE, and GET /sessions/:id attaches it as liveActivity for the polling fallback. - Routes detach turn execution: start/answer/resume return immediately (status active) and clients converge via push/poll — the turn is watchable instead of hidden inside a blocking POST. - Turn timeout is now INACTIVITY-based: an actively-working long turn is never killed; a quiet one interrupts with its working trace preserved. - On settle the trace persists into history as a condensed record. Steering: - Stage protocol: responses may be a direct answer, {value, comment} (answer + guidance), or {feedback} (guidance without answering); the system prompt instructs agents to treat steering as first-class input. - CeFlow: guidance textarea alongside selectable questions — attach to the clicked answer, or "Send guidance" on its own. Q&A UI: - Transcript no longer hides control records: past questions/answers render as chat bubbles (option ids → labels), steering turns marked, working traces as collapsible "Agent work" blocks, completion marker. - Live working pane (pulse + streaming thinking/tool lines) while a turn runs. Tests: 130 plugin tests green (14 new: live buffer/flush ordering, inactivity watchdog survives active work, detached convergence, steering payload shapes, transcript rendering, live pane). Engine seam tests green; plugin/core/ engine/dashboard tsc clean. Core full suite OOMs locally (known orchestrator- shell issue) — covered by CI shards. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../compound-engineering-plugin-scaffold.md | 4 +- packages/core/src/index.ts | 1 + packages/core/src/plugin-types.ts | 21 ++ packages/engine/src/index.ts | 28 ++ .../README.md | 42 ++- .../orchestrator-live-output.test.ts | 190 +++++++++++ .../src/dashboard/CeFlow.tsx | 297 ++++++++++++++++-- .../src/dashboard/CompoundEngineeringView.css | 149 +++++++++ .../src/dashboard/__tests__/CeFlow.test.tsx | 149 ++++++++- .../src/dashboard/hooks/useCeSession.ts | 13 +- .../src/routes/session-routes.ts | 37 ++- .../src/session/orchestrator.ts | 281 ++++++++++++++--- .../src/session/session-store.ts | 20 ++ 13 files changed, 1135 insertions(+), 97 deletions(-) create mode 100644 plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-live-output.test.ts diff --git a/.changeset/compound-engineering-plugin-scaffold.md b/.changeset/compound-engineering-plugin-scaffold.md index d11f71787d..a41e934342 100644 --- a/.changeset/compound-engineering-plugin-scaffold.md +++ b/.changeset/compound-engineering-plugin-scaffold.md @@ -4,7 +4,9 @@ Add the Compound Engineering bundled plugin: a dedicated dashboard surface for compound-engineering artifacts and interactive `ce-*` sessions, a work→board bridge, and bidirectional board↔pipeline sync. Sessions are fully multi-session: a Sessions panel lists every run with stage/status/last-activity, lets you open and switch between concurrent sessions (each keeps running server-side), resume interrupted ones, and discard settled ones (`DELETE /sessions/:id` disposes the live handle before deleting the row). +Sessions show the agent's full working output live (streamed thinking/tool activity with an inactivity-based stall timeout instead of a fixed turn timeout), the user can steer mid-stage with free-text guidance (attached to an answer or sent on its own), and the transcript renders past questions/answers/working traces as a proper chat surface. + This also adds two reusable host capabilities that any plugin benefits from: -- **Interactive agent sessions for plugin routes** (`ctx.createInteractiveAiSession`), with skill-discovery forwarding (`requestedSkillNames` / `additionalSkillPaths`) so a plugin can load a bundled skill into a live session. +- **Interactive agent sessions for plugin routes** (`ctx.createInteractiveAiSession`), with skill-discovery forwarding (`requestedSkillNames` / `additionalSkillPaths`) and live mid-turn progress streaming (`onProgress`: thinking/text deltas + tool markers) so a plugin can load a bundled skill into a live session and surface its work in real time. - **Real plugin event push over SSE**: a plugin's `ctx.emitEvent` calls are forwarded to connected `/api/events` clients as project-scoped `plugin:custom` events, and dashboard views can consume them via the new `subscribePluginEvents` view-context capability. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 65508306d3..c9cec90212 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -528,6 +528,7 @@ export type { AiSessionResult, CreateAiSessionFactory, CreateInteractiveAiSessionOptions, + InteractiveAiSessionProgressEvent, InteractiveAiSessionEvent, InteractiveAiSession, CreateInteractiveAiSessionResult, diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts index c72d6856b4..2af8d98957 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -164,8 +164,29 @@ export interface CreateInteractiveAiSessionOptions { * `requestedSkillNames` are actually discoverable in the live session. */ additionalSkillPaths?: string[]; + /** + * Live progress callback, invoked WHILE a turn runs (the pull-based + * `nextEvent()` only resolves once the turn settles). Receives streaming + * thinking/text deltas and tool start/end markers so a caller can surface + * the agent's work in real time. Optional; ignored by factories that cannot + * stream. Must not throw — implementations should swallow callback errors. + */ + onProgress?: (event: InteractiveAiSessionProgressEvent) => void; } +/** + * A live progress event emitted mid-turn via + * {@link CreateInteractiveAiSessionOptions.onProgress}. + * + * - `thinking` / `text`: an incremental output DELTA (not a snapshot) — the + * consumer accumulates. + * - `tool`: a discrete tool execution start/end marker. + */ +export type InteractiveAiSessionProgressEvent = + | { type: "thinking"; delta: string } + | { type: "text"; delta: string } + | { type: "tool"; name: string; phase: "start" | "end"; isError?: boolean }; + /** * A single event pulled from an interactive AI session. * diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 20a3c6c8f1..863a212463 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -161,6 +161,34 @@ const _createInteractiveAiSessionAdapter: CreateInteractiveAiSessionFactory = ( // discovery dirs make those skills actually visible to the loader. ...(opts.requestedSkillNames?.length ? { skills: opts.requestedSkillNames } : {}), ...(opts.additionalSkillPaths?.length ? { additionalSkillPaths: opts.additionalSkillPaths } : {}), + // Live mid-turn visibility: stream thinking/text deltas and tool + // start/end markers to the caller's onProgress while the pull-based + // nextEvent() is still pending. Callback errors must never break the + // agent turn. + ...(opts.onProgress + ? { + onThinking: (delta: string) => { + try { + opts.onProgress!({ type: "thinking", delta }); + } catch { /* consumer error must not break the turn */ } + }, + onText: (delta: string) => { + try { + opts.onProgress!({ type: "text", delta }); + } catch { /* consumer error must not break the turn */ } + }, + onToolStart: (name: string) => { + try { + opts.onProgress!({ type: "tool", name, phase: "start" }); + } catch { /* consumer error must not break the turn */ } + }, + onToolEnd: (name: string, isError: boolean) => { + try { + opts.onProgress!({ type: "tool", name, phase: "end", isError }); + } catch { /* consumer error must not break the turn */ } + }, + } + : {}), }), options, ); diff --git a/plugins/fusion-plugin-compound-engineering/README.md b/plugins/fusion-plugin-compound-engineering/README.md index 5dc7457ccd..9881c2fa66 100644 --- a/plugins/fusion-plugin-compound-engineering/README.md +++ b/plugins/fusion-plugin-compound-engineering/README.md @@ -78,18 +78,44 @@ activity; from there you can: The list refreshes on any CE push event and falls back to polling `GET /sessions` while any session has a turn in flight. +### Live working output, steering, and the Q&A surface + +Turn execution is **detached**: `POST /sessions`, `/answer`, and `/resume` +return as soon as the session row reflects the request, with the agent turn +running in the background. While it runs: + +- The engine streams **live progress** through the seam's `onProgress` option + (thinking/text deltas + tool start/end markers — a host capability any + plugin can use). The orchestrator accumulates it per session and + `GET /sessions/:id` attaches it as `liveActivity`, so the flow renders a + live working pane (pulsing indicator, muted thinking, per-tool ✓/✗ lines). +- The per-turn timeout is **inactivity-based**: a long but actively-working + turn is never killed; only a turn with no progress for `turnIntervalMs` is + interrupted (its working trace is preserved in the transcript). +- On settle, the working trace is persisted into the conversation history as a + condensed collapsible "Agent work" block — the transcript keeps the full + story: opening message, every past question and answer (option ids rendered + as labels), steering turns, working traces, and completion. + +**Steering**: alongside any selectable question the user can type free-text +guidance — attached to their answer as `{value, comment}`, or sent WITHOUT +answering as `{feedback}`. The stage system prompt instructs the agent to +treat both as first-class input (incorporate, adjust course, re-ask or +proceed). + ### Transport Session updates are **pushed** over the shared `/api/events` SSE stream. The orchestrator emits observable events via `ctx.emitEvent` (turn / question / -completed / error / interrupted); the host forwards them to connected clients as -project-scoped `plugin:custom` events, and the view subscribes through the -`subscribePluginEvents` context capability — refetching the session on each event -(no raw `EventSource`; no deep dashboard import). Client **polling of -`GET /sessions/:id` remains as a fallback** while a turn is mid-flight, so a -missed event still converges. Session identity is project-scoped: the `projectId` -used at `start` is threaded through every later answer/resume/poll so they -resolve the same store and live handle. +completed / error / interrupted, plus throttled mid-turn progress); the host +forwards them to connected clients as project-scoped `plugin:custom` events, +and the view subscribes through the `subscribePluginEvents` context capability +— refetching the session on each event (no raw `EventSource`; no deep +dashboard import). Client **polling of `GET /sessions/:id` remains as a +fallback** while a turn is mid-flight, so a missed event still converges. +Session identity is project-scoped: the `projectId` used at `start` is +threaded through every later answer/resume/poll so they resolve the same store +and live handle. ## Work → board bridge diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-live-output.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-live-output.test.ts new file mode 100644 index 0000000000..4bfbac7b83 --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-live-output.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + CreateInteractiveAiSessionFactory, + InteractiveAiSessionEvent, + InteractiveAiSessionProgressEvent, + PlanningQuestion, +} from "@fusion/core"; +import { buildStageSystemPrompt, CeOrchestrator, CE_EVENTS } from "../session/orchestrator.js"; +import { getStage } from "../session/stage-registry.js"; +import { makeHarness, makeScriptedSession, type TestHarness } from "./_harness.js"; + +/** + * Live working-output + steering-protocol coverage: + * - mid-turn progress (thinking/text deltas, tool markers) is visible via + * getLiveActivity while the turn runs, emitted as observable events, and + * persisted into history as a condensed trace when the turn settles; + * - the turn timeout is INACTIVITY-based — an actively-working long turn is + * never killed, a quiet one is interrupted with its trace preserved; + * - detached start/answer return immediately and converge via persisted state; + * - the stage system prompt documents the steering response shapes. + */ + +const QUESTION: PlanningQuestion = { id: "q1", type: "text", question: "Topic?" }; + +let h: TestHarness; +beforeEach(() => { + h = makeHarness(); +}); +afterEach(() => { + h.close(); + vi.restoreAllMocks(); +}); + +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** A factory exposing the onProgress hook and a controllable nextEvent. */ +function progressFactory(nextEvent: () => Promise) { + const captured: { progress?: (e: InteractiveAiSessionProgressEvent) => void; dispose: ReturnType } = { + dispose: vi.fn(), + }; + const factory: CreateInteractiveAiSessionFactory = vi.fn(async (opts) => { + captured.progress = opts.onProgress; + return { + session: { + prompt: vi.fn(async () => undefined), + answer: vi.fn(async () => undefined), + nextEvent, + dispose: captured.dispose, + }, + }; + }); + return { factory, captured }; +} + +describe("live working output", () => { + it("buffers mid-turn progress, emits observable events, and persists the trace on settle (before the question)", async () => { + const evt = deferred(); + const { factory, captured } = progressFactory(() => evt.promise); + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: factory, + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); + + const started = await orch.start("brainstorm", { openingMessage: "go", detach: true }); + expect(["launching", "active"]).toContain(started.session.status); + await vi.waitFor(() => expect(captured.progress).toBeDefined()); + + // Stream: consecutive deltas of one kind merge; tool start/end are discrete. + captured.progress!({ type: "thinking", delta: "Let me " }); + captured.progress!({ type: "thinking", delta: "look around." }); + captured.progress!({ type: "tool", name: "Read", phase: "start" }); + captured.progress!({ type: "tool", name: "Read", phase: "end", isError: false }); + captured.progress!({ type: "text", delta: "Drafting…" }); + + const live = orch.getLiveActivity(started.session.id); + expect(live.map((t) => t.kind)).toEqual(["thinking", "tool", "text"]); + expect(live[0].text).toBe("Let me look around."); + expect(live[1].done).toBe(true); + expect(live[1].isError).toBeUndefined(); + + // Observable progress event emitted (throttled; the first one is immediate). + expect( + h.emitted.some((e) => e.event === CE_EVENTS.turn && (e.data as { kind?: string }).kind === "progress"), + ).toBe(true); + + // Settle the turn → buffer flushed into history BEFORE the question record. + evt.resolve({ type: "question", data: QUESTION }); + await vi.waitFor(() => expect(orch.getState(started.session.id)?.status).toBe("awaiting_input")); + expect(orch.getLiveActivity(started.session.id)).toHaveLength(0); + + const history = orch.getState(started.session.id)!.conversationHistory; + const activityIdx = history.findIndex((t) => t.role === "agent" && t.text.startsWith('{"activity"')); + const questionIdx = history.findIndex((t) => t.role === "agent" && t.text.startsWith('{"question"')); + expect(activityIdx).toBeGreaterThanOrEqual(0); + expect(questionIdx).toBeGreaterThan(activityIdx); + const trace = JSON.parse(history[activityIdx].text) as { + activity: { turns: Array<{ kind: string; text: string }> }; + }; + expect(trace.activity.turns.map((t) => t.kind)).toEqual(["thinking", "tool", "text"]); + }); + + it("inactivity watchdog: an actively-working long turn survives past the timeout; a quiet one is interrupted with its trace kept", async () => { + const { factory, captured } = progressFactory(() => new Promise(() => undefined)); + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: factory, + projectRoot: h.projectRoot, + turnTimeoutMs: 120, + }); + const started = await orch.start("brainstorm", { openingMessage: "go", detach: true }); + const id = started.session.id; + await vi.waitFor(() => expect(captured.progress).toBeDefined()); + + // Keep working for ~3× the timeout — must NOT be interrupted. + for (let i = 0; i < 8; i++) { + await sleep(45); + captured.progress!({ type: "thinking", delta: "." }); + } + expect(orch.getState(id)?.status).toBe("active"); + + // Go quiet → interrupted after the inactivity window, trace preserved. + await vi.waitFor(() => expect(orch.getState(id)?.status).toBe("interrupted"), { timeout: 2000 }); + expect(orch.getState(id)?.error).toMatch(/no agent activity/i); + const history = orch.getState(id)!.conversationHistory; + expect(history.some((t) => t.text.startsWith('{"activity"'))).toBe(true); + expect(captured.dispose).toHaveBeenCalled(); + }); +}); + +describe("detached turns (route posture)", () => { + it("answer(detach) returns immediately with status active and converges to the next question", async () => { + const NEXT: PlanningQuestion = { id: "q2", type: "text", question: "More?" }; + const scripted = makeScriptedSession([ + { type: "question", data: QUESTION }, + { type: "question", data: NEXT }, + ]); + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: vi.fn(async () => ({ session: scripted })), + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); + const started = await orch.start("brainstorm", { openingMessage: "go" }); + expect(started.session.status).toBe("awaiting_input"); + + const stepped = await orch.answer(started.session.id, "q1", "widgets", { detach: true }); + // Detached return reflects the just-accepted answer, not the settled turn… + expect(stepped.session.status).toBe("active"); + expect(stepped.session.currentQuestion).toBeNull(); + // …and the background turn converges to the next question. + await vi.waitFor(() => expect(orch.getState(started.session.id)?.currentQuestion?.id).toBe("q2")); + expect(orch.getState(started.session.id)?.status).toBe("awaiting_input"); + }); + + it("start(detach) without a working factory converges to an error state (never silent)", async () => { + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: vi.fn(async () => { + throw new Error("factory exploded"); + }), + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); + const started = await orch.start("brainstorm", { openingMessage: "go", detach: true }); + expect(started.session.id).toBeTruthy(); + await vi.waitFor(() => expect(orch.getState(started.session.id)?.status).toBe("error")); + expect(orch.getState(started.session.id)?.error).toContain("factory exploded"); + expect(h.emitted.map((e) => e.event)).toContain(CE_EVENTS.error); + }); +}); + +describe("steering protocol", () => { + it("the stage system prompt documents direct, value+comment, and feedback-only response shapes", () => { + const prompt = buildStageSystemPrompt(getStage("brainstorm")!); + expect(prompt).toContain('"value"'); + expect(prompt).toContain('"comment"'); + expect(prompt).toContain('"feedback"'); + expect(prompt).toMatch(/steering/i); + }); +}); diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/CeFlow.tsx b/plugins/fusion-plugin-compound-engineering/src/dashboard/CeFlow.tsx index 6fb383156f..86598776fb 100644 --- a/plugins/fusion-plugin-compound-engineering/src/dashboard/CeFlow.tsx +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/CeFlow.tsx @@ -1,15 +1,24 @@ import { useMemo, useState } from "react"; import type { PlanningQuestion } from "@fusion/core"; -import type { CeConversationTurn, CeSession } from "../session/session-store.js"; +import type { CeActivityTurn, CeConversationTurn, CeSession } from "../session/session-store.js"; import { canRenderRichly } from "./ce-question-support.js"; /** * CeFlow — the interactive renderer (U6). * * Renders the four interaction types CeFlow expresses richly (`text`, - * `single_select`, `multi_select`, `confirm`) plus streamed `thinking`/`text` - * history. When a turn carries a question CeFlow CANNOT express, it degrades to - * a plain chat view that is VISUALLY MARKED as degraded (R8/AE1) — the stage is + * `single_select`, `multi_select`, `confirm`), the FULL conversation so far — + * past questions and answers as proper chat bubbles, the agent's working + * traces (thinking / tool activity) as collapsible blocks — and, while a turn + * runs, a LIVE working pane streaming the agent's current output. + * + * Steering: alongside any selectable question the user can attach free-text + * guidance to their answer (`{value, comment}`) or send guidance WITHOUT + * answering (`{feedback}`) — the stage system prompt instructs the agent to + * treat both as first-class input. + * + * When a turn carries a question CeFlow CANNOT express, it degrades to a + * plain chat view that is VISUALLY MARKED as degraded (R8/AE1) — the stage is * still completable there via a free-text answer. * * It does NOT import `PlanningModeModal` or any dashboard internal (KTD3 scope @@ -28,28 +37,181 @@ export interface CeFlowProps { onClose?: () => void; } -/** Render the agent/user conversation so far (streamed thinking/text). */ +// ── Transcript parsing ─────────────────────────────────────────────────────── + +type DisplayItem = + | { kind: "chat"; role: "user" | "agent"; text: string } + | { kind: "qa-question"; question: PlanningQuestion } + | { kind: "qa-answer"; question?: PlanningQuestion; response: unknown } + | { kind: "activity"; turns: CeActivityTurn[] } + | { kind: "complete" }; + +function tryParseJson(text: string): Record | undefined { + if (!text.startsWith("{")) return undefined; + try { + const parsed: unknown = JSON.parse(text); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } +} + +/** + * Turn the persisted history (chat turns + serialized control records) into + * renderable items. Control records are no longer hidden — questions, answers, + * and working traces are the conversation. + */ +function parseHistory(history: CeConversationTurn[]): DisplayItem[] { + const items: DisplayItem[] = []; + const questionsById = new Map(); + for (const turn of history) { + const obj = tryParseJson(turn.text); + if (obj && turn.role === "agent") { + const q = obj.question as PlanningQuestion | undefined; + if (q && typeof q.id === "string" && typeof q.question === "string") { + questionsById.set(q.id, q); + items.push({ kind: "qa-question", question: q }); + continue; + } + const activity = obj.activity as { turns?: CeActivityTurn[] } | undefined; + if (activity && Array.isArray(activity.turns)) { + items.push({ kind: "activity", turns: activity.turns }); + continue; + } + if (obj.complete === true) { + items.push({ kind: "complete" }); + continue; + } + } + if (obj && turn.role === "user" && "answer" in obj) { + items.push({ + kind: "qa-answer", + question: typeof obj.questionId === "string" ? questionsById.get(obj.questionId) : undefined, + response: obj.answer, + }); + continue; + } + items.push({ kind: "chat", role: turn.role, text: turn.text }); + } + return items; +} + +/** Human-readable rendering of an answer payload (option ids → labels). */ +function formatAnswer( + response: unknown, + question?: PlanningQuestion, +): { main: string; comment?: string; feedbackOnly?: boolean } { + if (response && typeof response === "object" && !Array.isArray(response)) { + const r = response as Record; + if (typeof r.feedback === "string") return { main: r.feedback, feedbackOnly: true }; + if ("value" in r) { + const base = formatAnswer(r.value, question); + return { + main: base.main, + ...(typeof r.comment === "string" && r.comment ? { comment: r.comment } : {}), + }; + } + } + const label = (id: unknown): string => + question?.options?.find((o) => o.id === id)?.label ?? String(id); + if (Array.isArray(response)) return { main: response.map(label).join(", ") }; + if (typeof response === "boolean") return { main: response ? "Yes" : "No" }; + return { main: label(response) }; +} + +// ── Working-trace rendering ────────────────────────────────────────────────── + +/** Render thinking/text/tool activity turns (persisted trace or live pane). */ +function ActivityTrace({ turns, live }: { turns: CeActivityTurn[]; live?: boolean }) { + return ( +
+ {turns.map((t, i) => + t.kind === "tool" ? ( +
+ {t.isError ? "✗" : t.done ? "✓" : "▸"} {t.text} +
+ ) : ( +
+            {t.text}
+          
+ ), + )} +
+ ); +} + +/** Render the full conversation: chat, Q&A bubbles, and working traces. */ function Transcript({ history }: { history: CeConversationTurn[] }) { - const visible = history.filter((t) => { - // Hide serialized question/answer/complete markers from the readable - // transcript; they are control records, not chat. - if (t.role === "agent" && /^\{"(question|complete)"/.test(t.text)) return false; - if (t.role === "user" && /^\{"answer"/.test(t.text)) return false; - return true; - }); - if (visible.length === 0) return null; + const items = useMemo(() => parseHistory(history), [history]); + if (items.length === 0) return null; return (
    - {visible.map((turn, i) => ( -
  1. - {turn.role === "agent" ? "Agent" : "You"} - {turn.text} -
  2. - ))} + {items.map((item, i) => { + switch (item.kind) { + case "chat": + return ( +
  3. + {item.role === "agent" ? "Agent" : "You"} + {item.text} +
  4. + ); + case "qa-question": + return ( +
  5. + Agent asked + {item.question.question} +
  6. + ); + case "qa-answer": { + const a = formatAnswer(item.response, item.question); + return ( +
  7. + {a.feedbackOnly ? "You steered" : "You answered"} + {a.main} + {a.comment ? ( + + {a.comment} + + ) : null} +
  8. + ); + } + case "activity": + return ( +
  9. +
    + Agent work ({item.turns.length} step{item.turns.length === 1 ? "" : "s"}) + +
    +
  10. + ); + case "complete": + return ( +
  11. + ✓ Stage complete +
  12. + ); + } + })}
); } +// ── Question rendering ─────────────────────────────────────────────────────── + /** Rich renderer for a single supported question type. */ function RichQuestion({ question, @@ -228,11 +390,85 @@ function DegradedQuestion({ ); } +/** + * Question panel with steering. Wraps the rich/degraded renderer and adds the + * guidance channel for selectable questions: + * - guidance typed + an option clicked → `{value, comment}` (answer + steer), + * - guidance typed + "Send guidance" → `{feedback}` (steer without answering). + * Free-text questions skip the extra box — their answer field already takes + * the user's own words. + */ +function QuestionPanel({ + question, + disabled, + onAnswer, +}: { + question: PlanningQuestion; + disabled: boolean; + onAnswer: (questionId: string, response: unknown) => void; +}) { + const [guidance, setGuidance] = useState(""); + const rich = canRenderRichly(question); + + const submitWithGuidance = (questionId: string, response: unknown) => { + const comment = guidance.trim(); + onAnswer(questionId, comment ? { value: response, comment } : response); + setGuidance(""); + }; + + const sendGuidanceOnly = () => { + const feedback = guidance.trim(); + if (!feedback) return; + onAnswer(question.id, { feedback }); + setGuidance(""); + }; + + const showGuidance = rich && question.type !== "text"; + + return ( +
+ {rich ? ( + + ) : ( + + )} + {showGuidance ? ( +
+ +
+