Files
lda-wf/docs/historical/superpowers/plans/2026-07-03-constrained-demo-agent.md
T

34 KiB

Constrained Demo Agent Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a standard AI-app-shaped chat/event surface for presentation mode, backed by one deterministic prepared recipe and ready for a future Vercel AI SDK driver.

Architecture: Add a small demo/agent/ module that owns chat-compatible message parts, finite tool descriptors, a prepared recipe driver, and a React hook. The prepared driver emits the same event shape that a future AI SDK stream can emit, including workflow tools and presentation tools such as selecting the interrupt node.

Tech Stack: React 19, TypeScript, Vitest, existing console RPC operation path, existing demo timeline models. Do not add Vercel AI SDK or model-provider dependencies in this slice.

Global Constraints

  • Keep the prepared demo deterministic; no API key, model provider, or LLM call is required.
  • Chat must be standard AI-app-shaped: user messages, assistant text parts, tool-call parts, tool-result parts, error parts, and disabled/preset input affordance.
  • Separate workflow tools from presentation tools. Workflow tools produce operation evidence; presentation tools mutate only stage focus.
  • The future AI SDK seam must be explicit, but ai / @ai-sdk/react are out of scope for this slice.
  • Effect may be used later at the driver/tool boundary, especially server-side. This browser slice must not add Effect ceremony to render components.
  • The visual style is not the goal of this slice; preserve existing presentation styling unless a minimal class is needed for new chat parts.
  • Copy must say "prepared recipe" or "constrained demo agent", not "autonomous agent".

File Structure

Create:

  • web/apps/console/src/demo/agent/events.ts — chat message/event types and constructors.
  • web/apps/console/src/demo/agent/tools.ts — finite workflow/presentation tool descriptors.
  • web/apps/console/src/demo/agent/recipes.tsprepare-thesis-report recipe metadata and scripted steps.
  • web/apps/console/src/demo/agent/preparedRecipeDriver.ts — deterministic async event driver over replay/live timeline operations.
  • web/apps/console/src/demo/agent/useDemoAgent.ts — React hook that owns agent messages and presentation actions.
  • web/apps/console/src/demo/agent/*.test.ts — focused tests for event shape, tool allow-list, driver order, and hook reset behavior.

Modify:

  • web/apps/console/src/presentation/OperatorChat.tsx — render message parts instead of fixed static copy.
  • web/apps/console/src/presentation/PresentationRoute.tsx — create/use the agent hook and apply emitted presentation actions.
  • web/apps/console/src/presentation/PresentationStage.tsx or child props as needed — receive selected node/evidence state from agent actions.
  • web/apps/console/src/presentation/presentation.css — minimal styles for tool-call/tool-result parts.
  • web/apps/console/src/presentation/OperatorChat.test.tsx and PresentationRoute.test.tsx — update tests for event-driven chat and stage action.
  • web/README.md and docs/current_roadmap.md — document the constrained demo agent slice.

Task 1: Agent Event And Tool Contracts

Files:

  • Create: web/apps/console/src/demo/agent/events.ts
  • Create: web/apps/console/src/demo/agent/tools.ts
  • Test: web/apps/console/src/demo/agent/events.test.ts
  • Test: web/apps/console/src/demo/agent/tools.test.ts

Interfaces:

  • Produces:

    • AgentMessage
    • AgentMessagePart
    • AgentToolName
    • PresentationToolAction
    • agentTextMessage(...)
    • agentToolCallPart(...)
    • agentToolResultPart(...)
    • presentationActionPart(...)
    • isAllowedAgentToolName(...)
  • Step 1: Write event contract tests

Create web/apps/console/src/demo/agent/events.test.ts:

import { describe, expect, it } from "vitest";
import {
  agentTextMessage,
  agentToolCallPart,
  agentToolResultPart,
  presentationActionPart,
} from "./events.js";

describe("agent events", () => {
  it("creates a standard assistant text message", () => {
    const message = agentTextMessage("m1", "assistant", "I will use a prepared recipe.");
    expect(message).toEqual({
      id: "m1",
      role: "assistant",
      parts: [{ type: "text", text: "I will use a prepared recipe." }],
    });
  });

  it("creates tool call, tool result, and presentation action parts", () => {
    expect(agentToolCallPart("c1", "startPreparedReportRun", { deploymentId: "lda_report_case_study.default" })).toEqual({
      type: "tool-call",
      call: {
        id: "c1",
        name: "startPreparedReportRun",
        input: { deploymentId: "lda_report_case_study.default" },
      },
    });
    expect(agentToolResultPart("c1", "startPreparedReportRun", "success", { runId: "run_1" })).toEqual({
      type: "tool-result",
      result: {
        callId: "c1",
        name: "startPreparedReportRun",
        status: "success",
        output: { runId: "run_1" },
      },
    });
    expect(presentationActionPart({ type: "selectWorkflowNode", nodeId: "review_issues" })).toEqual({
      type: "presentation-action",
      action: { type: "selectWorkflowNode", nodeId: "review_issues" },
    });
  });
});
  • Step 2: Write tool allow-list tests

Create web/apps/console/src/demo/agent/tools.test.ts:

import { describe, expect, it } from "vitest";
import { AGENT_TOOLS, isAllowedAgentToolName } from "./tools.js";

describe("agent tools", () => {
  it("separates workflow tools from presentation tools", () => {
    expect(AGENT_TOOLS.inspectDeployment.kind).toBe("workflow");
    expect(AGENT_TOOLS.startPreparedReportRun.kind).toBe("workflow");
    expect(AGENT_TOOLS.resumeIssueReview.kind).toBe("workflow");
    expect(AGENT_TOOLS.readRunTrace.kind).toBe("workflow");
    expect(AGENT_TOOLS.selectWorkflowNode.kind).toBe("presentation");
    expect(AGENT_TOOLS.openEvidence.kind).toBe("presentation");
  });

  it("rejects unknown tool names", () => {
    expect(isAllowedAgentToolName("selectWorkflowNode")).toBe(true);
    expect(isAllowedAgentToolName("readFile")).toBe(false);
    expect(isAllowedAgentToolName("authorArbitraryWorkflow")).toBe(false);
  });
});
  • Step 3: Run tests to verify they fail

Run:

pnpm --dir web --filter @lda/console test -- events tools

Expected: FAIL because the new modules do not exist.

  • Step 4: Implement event and tool contracts

Create web/apps/console/src/demo/agent/tools.ts:

export type WorkflowToolName =
  | "inspectDeployment"
  | "startPreparedReportRun"
  | "resumeIssueReview"
  | "readRunTrace";

export type PresentationToolName =
  | "selectWorkflowNode"
  | "focusOperation"
  | "openEvidence"
  | "showTraceFrame"
  | "setBeat";

export type AgentToolName = WorkflowToolName | PresentationToolName;

export type AgentToolDescriptor = {
  readonly name: AgentToolName;
  readonly kind: "workflow" | "presentation";
  readonly description: string;
};

export const AGENT_TOOLS = {
  inspectDeployment: {
    name: "inspectDeployment",
    kind: "workflow",
    description: "Inspect the prepared report deployment.",
  },
  startPreparedReportRun: {
    name: "startPreparedReportRun",
    kind: "workflow",
    description: "Start the prepared report workflow run.",
  },
  resumeIssueReview: {
    name: "resumeIssueReview",
    kind: "workflow",
    description: "Resume the typed issue-review interrupt.",
  },
  readRunTrace: {
    name: "readRunTrace",
    kind: "workflow",
    description: "Read trace frames for the completed report run.",
  },
  selectWorkflowNode: {
    name: "selectWorkflowNode",
    kind: "presentation",
    description: "Focus a workflow graph node in the presentation.",
  },
  focusOperation: {
    name: "focusOperation",
    kind: "presentation",
    description: "Focus an operation event in the presentation.",
  },
  openEvidence: {
    name: "openEvidence",
    kind: "presentation",
    description: "Open evidence for an operation event.",
  },
  showTraceFrame: {
    name: "showTraceFrame",
    kind: "presentation",
    description: "Focus a trace frame in the presentation.",
  },
  setBeat: {
    name: "setBeat",
    kind: "presentation",
    description: "Move the presentation to a named beat.",
  },
} satisfies Record<AgentToolName, AgentToolDescriptor>;

export const isAllowedAgentToolName = (name: string): name is AgentToolName =>
  Object.hasOwn(AGENT_TOOLS, name);

Create web/apps/console/src/demo/agent/events.ts:

import type { AgentToolName } from "./tools.js";

export type AgentRole = "user" | "assistant";

export type PresentationToolAction =
  | { readonly type: "selectWorkflowNode"; readonly nodeId: string }
  | { readonly type: "focusOperation"; readonly eventId: string }
  | { readonly type: "openEvidence"; readonly eventId: string }
  | { readonly type: "showTraceFrame"; readonly frameIndex: number }
  | { readonly type: "setBeat"; readonly beatId: string };

export type AgentToolCall = {
  readonly id: string;
  readonly name: AgentToolName;
  readonly input: unknown;
};

export type AgentToolResult = {
  readonly callId: string;
  readonly name: AgentToolName;
  readonly status: "success" | "failure";
  readonly output: unknown;
};

export type AgentMessagePart =
  | { readonly type: "text"; readonly text: string }
  | { readonly type: "tool-call"; readonly call: AgentToolCall }
  | { readonly type: "tool-result"; readonly result: AgentToolResult }
  | { readonly type: "presentation-action"; readonly action: PresentationToolAction }
  | { readonly type: "error"; readonly message: string };

export type AgentMessage = {
  readonly id: string;
  readonly role: AgentRole;
  readonly parts: ReadonlyArray<AgentMessagePart>;
};

export const agentTextMessage = (id: string, role: AgentRole, text: string): AgentMessage => ({
  id,
  role,
  parts: [{ type: "text", text }],
});

export const agentToolCallPart = (
  id: string,
  name: AgentToolName,
  input: unknown,
): AgentMessagePart => ({
  type: "tool-call",
  call: { id, name, input },
});

export const agentToolResultPart = (
  callId: string,
  name: AgentToolName,
  status: "success" | "failure",
  output: unknown,
): AgentMessagePart => ({
  type: "tool-result",
  result: { callId, name, status, output },
});

export const presentationActionPart = (action: PresentationToolAction): AgentMessagePart => ({
  type: "presentation-action",
  action,
});
  • Step 5: Run tests

Run:

pnpm --dir web --filter @lda/console test -- events tools

Expected: PASS.

  • Step 6: Commit
git add web/apps/console/src/demo/agent/events.ts web/apps/console/src/demo/agent/tools.ts web/apps/console/src/demo/agent/events.test.ts web/apps/console/src/demo/agent/tools.test.ts
git commit -m "feat: define demo agent event contract"

Task 2: Prepared Recipe Metadata And Replay Driver

Files:

  • Create: web/apps/console/src/demo/agent/recipes.ts
  • Create: web/apps/console/src/demo/agent/preparedRecipeDriver.ts
  • Test: web/apps/console/src/demo/agent/preparedRecipeDriver.test.ts

Interfaces:

  • Consumes:

    • AgentMessage and message-part helpers from Task 1.
    • DemoEvent from web/apps/console/src/demo/timeline/models.ts.
    • loadCanonicalDemoRecording() from web/apps/console/src/demo/timeline/replay.ts.
  • Produces:

    • PREPARE_THESIS_REPORT_RECIPE
    • runPreparedRecipeReplay(): AsyncIterable<AgentMessage>
  • Step 1: Write replay driver tests

Create web/apps/console/src/demo/agent/preparedRecipeDriver.test.ts:

import { describe, expect, it } from "vitest";
import { runPreparedRecipeReplay } from "./preparedRecipeDriver.js";

const collect = async <T>(events: AsyncIterable<T>): Promise<ReadonlyArray<T>> => {
  const collected: T[] = [];
  for await (const event of events) collected.push(event);
  return collected;
};

describe("prepared recipe driver", () => {
  it("emits a standard chat sequence for the replay recipe", async () => {
    const messages = await collect(runPreparedRecipeReplay());
    expect(messages[0]?.role).toBe("user");
    expect(messages.some((message) =>
      message.parts.some((part) => part.type === "tool-call" && part.call.name === "startPreparedReportRun"),
    )).toBe(true);
    expect(messages.some((message) =>
      message.parts.some((part) => part.type === "presentation-action" && part.action.type === "selectWorkflowNode"),
    )).toBe(true);
    expect(messages.at(-1)?.parts.some((part) =>
      part.type === "text" && part.text.includes("run evidence"),
    )).toBe(true);
  });

  it("does not emit unknown tool calls", async () => {
    const messages = await collect(runPreparedRecipeReplay());
    const toolNames = messages.flatMap((message) =>
      message.parts.flatMap((part) => part.type === "tool-call" ? [part.call.name] : []),
    );
    expect(toolNames).toEqual([
      "inspectDeployment",
      "startPreparedReportRun",
      "selectWorkflowNode",
      "resumeIssueReview",
      "readRunTrace",
      "openEvidence",
    ]);
  });
});
  • Step 2: Run test to verify it fails

Run:

pnpm --dir web --filter @lda/console test -- preparedRecipeDriver

Expected: FAIL because driver files do not exist.

  • Step 3: Implement recipe metadata

Create web/apps/console/src/demo/agent/recipes.ts:

import { LDA_REPORT_DEPLOYMENT_ID } from "../ldaReportDemoConfig.js";
import type { AgentToolName } from "./tools.js";

export type PreparedRecipeStep = {
  readonly id: string;
  readonly narration: string;
  readonly toolName: AgentToolName | null;
};

export type PreparedRecipe = {
  readonly id: "prepare-thesis-report";
  readonly title: string;
  readonly userPrompt: string;
  readonly deploymentId: string;
  readonly steps: ReadonlyArray<PreparedRecipeStep>;
};

export const PREPARE_THESIS_REPORT_RECIPE: PreparedRecipe = {
  id: "prepare-thesis-report",
  title: "Prepare thesis readiness report",
  userPrompt: "Prepare the thesis readiness report.",
  deploymentId: LDA_REPORT_DEPLOYMENT_ID,
  steps: [
    {
      id: "select-recipe",
      narration: "I found a prepared workflow recipe for the thesis readiness report.",
      toolName: null,
    },
    {
      id: "inspect-deployment",
      narration: "I will inspect the prepared deployment before starting a run.",
      toolName: "inspectDeployment",
    },
    {
      id: "start-run",
      narration: "I will start the prepared workflow run.",
      toolName: "startPreparedReportRun",
    },
    {
      id: "focus-interrupt",
      narration: "Let's zoom into the typed issue-review interrupt.",
      toolName: "selectWorkflowNode",
    },
    {
      id: "resume",
      narration: "I will resume the run with the selected issues.",
      toolName: "resumeIssueReview",
    },
    {
      id: "trace",
      narration: "I will read the run trace as evidence.",
      toolName: "readRunTrace",
    },
    {
      id: "open-evidence",
      narration: "I will open the evidence linked to the trace call.",
      toolName: "openEvidence",
    },
  ],
};
  • Step 4: Implement replay driver

Create web/apps/console/src/demo/agent/preparedRecipeDriver.ts:

import { loadCanonicalDemoRecording } from "../timeline/replay.js";
import {
  agentTextMessage,
  agentToolCallPart,
  agentToolResultPart,
  presentationActionPart,
  type AgentMessage,
} from "./events.js";
import { PREPARE_THESIS_REPORT_RECIPE } from "./recipes.js";

export async function* runPreparedRecipeReplay(): AsyncIterable<AgentMessage> {
  const recording = loadCanonicalDemoRecording();
  const deploymentId = PREPARE_THESIS_REPORT_RECIPE.deploymentId;
  const runStart = recording.events.find((event) => event.stage === "run_start");
  const resume = recording.events.find((event) => event.stage === "run_resume");
  const trace = recording.events.find((event) => event.stage === "trace_read");
  const runId = runStart?.resultingIds.runId ?? "recorded-run";

  yield agentTextMessage("recipe-user", "user", PREPARE_THESIS_REPORT_RECIPE.userPrompt);
  yield agentTextMessage("recipe-selected", "assistant", PREPARE_THESIS_REPORT_RECIPE.steps[0]!.narration);
  yield {
    id: "inspect-deployment",
    role: "assistant",
    parts: [
      agentToolCallPart("inspect-deployment-call", "inspectDeployment", { deploymentId }),
      agentToolResultPart("inspect-deployment-call", "inspectDeployment", "success", { deploymentId }),
    ],
  };
  yield {
    id: "start-run",
    role: "assistant",
    parts: [
      agentToolCallPart("start-run-call", "startPreparedReportRun", { deploymentId }),
      agentToolResultPart("start-run-call", "startPreparedReportRun", "success", {
        runId,
        eventId: runStart?.id ?? null,
      }),
    ],
  };
  yield {
    id: "focus-interrupt",
    role: "assistant",
    parts: [
      agentToolCallPart("select-interrupt-call", "selectWorkflowNode", { nodeId: "review_issues" }),
      presentationActionPart({ type: "selectWorkflowNode", nodeId: "review_issues" }),
      agentToolResultPart("select-interrupt-call", "selectWorkflowNode", "success", { nodeId: "review_issues" }),
    ],
  };
  yield {
    id: "resume-run",
    role: "assistant",
    parts: [
      agentToolCallPart("resume-run-call", "resumeIssueReview", { runId }),
      agentToolResultPart("resume-run-call", "resumeIssueReview", "success", {
        runId,
        eventId: resume?.id ?? null,
      }),
    ],
  };
  yield {
    id: "read-trace",
    role: "assistant",
    parts: [
      agentToolCallPart("read-trace-call", "readRunTrace", { runId }),
      agentToolResultPart("read-trace-call", "readRunTrace", "success", {
        runId,
        eventId: trace?.id ?? null,
      }),
    ],
  };
  yield {
    id: "open-evidence",
    role: "assistant",
    parts: [
      agentToolCallPart("open-evidence-call", "openEvidence", { eventId: trace?.id ?? "trace" }),
      presentationActionPart({ type: "openEvidence", eventId: trace?.id ?? "trace" }),
      agentToolResultPart("open-evidence-call", "openEvidence", "success", { eventId: trace?.id ?? "trace" }),
    ],
  };
  yield agentTextMessage(
    "summary",
    "assistant",
    `The prepared recipe completed with run evidence for ${runId}.`,
  );
}
  • Step 5: Run tests

Run:

pnpm --dir web --filter @lda/console test -- preparedRecipeDriver

Expected: PASS.

  • Step 6: Commit
git add web/apps/console/src/demo/agent/recipes.ts web/apps/console/src/demo/agent/preparedRecipeDriver.ts web/apps/console/src/demo/agent/preparedRecipeDriver.test.ts
git commit -m "feat: add prepared demo recipe driver"

Task 3: Agent Hook And Presentation Actions

Files:

  • Create: web/apps/console/src/demo/agent/useDemoAgent.ts
  • Test: web/apps/console/src/demo/agent/useDemoAgent.test.tsx

Interfaces:

  • Consumes:

    • runPreparedRecipeReplay() from Task 2.
    • PresentationToolAction from Task 1.
  • Produces:

    • useDemoAgent()
    • DemoAgentController
  • Step 1: Write hook tests

Create web/apps/console/src/demo/agent/useDemoAgent.test.tsx:

import { renderHook, act, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { useDemoAgent } from "./useDemoAgent.js";

describe("useDemoAgent", () => {
  it("runs the prepared replay recipe and collects messages", async () => {
    const { result } = renderHook(() => useDemoAgent());
    await act(async () => {
      await result.current.startPreparedReplay();
    });

    await waitFor(() => {
      expect(result.current.messages.length).toBeGreaterThan(3);
    });
    expect(result.current.phase).toBe("completed");
    expect(result.current.messages[0]?.role).toBe("user");
  });

  it("records presentation actions from the recipe", async () => {
    const { result } = renderHook(() => useDemoAgent());
    await act(async () => {
      await result.current.startPreparedReplay();
    });

    expect(result.current.pendingActions).toContainEqual({ type: "selectWorkflowNode", nodeId: "review_issues" });
  });

  it("reset clears messages and actions", async () => {
    const { result } = renderHook(() => useDemoAgent());
    await act(async () => {
      await result.current.startPreparedReplay();
    });
    act(() => result.current.reset());

    expect(result.current.messages).toEqual([]);
    expect(result.current.pendingActions).toEqual([]);
    expect(result.current.phase).toBe("idle");
  });
});
  • Step 2: Run test to verify it fails

Run:

pnpm --dir web --filter @lda/console test -- useDemoAgent

Expected: FAIL because useDemoAgent.ts does not exist.

  • Step 3: Implement hook

Create web/apps/console/src/demo/agent/useDemoAgent.ts:

import { useCallback, useState } from "react";
import { runPreparedRecipeReplay } from "./preparedRecipeDriver.js";
import type { AgentMessage, PresentationToolAction } from "./events.js";

export type DemoAgentPhase = "idle" | "running" | "completed" | "failed";

export type DemoAgentController = {
  readonly phase: DemoAgentPhase;
  readonly messages: ReadonlyArray<AgentMessage>;
  readonly pendingActions: ReadonlyArray<PresentationToolAction>;
  readonly startPreparedReplay: () => Promise<void>;
  readonly clearPendingActions: () => void;
  readonly reset: () => void;
};

const collectActions = (message: AgentMessage): ReadonlyArray<PresentationToolAction> =>
  message.parts.flatMap((part) => part.type === "presentation-action" ? [part.action] : []);

export const useDemoAgent = (): DemoAgentController => {
  const [phase, setPhase] = useState<DemoAgentPhase>("idle");
  const [messages, setMessages] = useState<ReadonlyArray<AgentMessage>>([]);
  const [pendingActions, setPendingActions] = useState<ReadonlyArray<PresentationToolAction>>([]);

  const reset = useCallback(() => {
    setPhase("idle");
    setMessages([]);
    setPendingActions([]);
  }, []);

  const clearPendingActions = useCallback(() => {
    setPendingActions([]);
  }, []);

  const startPreparedReplay = useCallback(async () => {
    setPhase("running");
    setMessages([]);
    setPendingActions([]);
    try {
      for await (const message of runPreparedRecipeReplay()) {
        setMessages((current) => [...current, message]);
        const actions = collectActions(message);
        if (actions.length > 0) {
          setPendingActions((current) => [...current, ...actions]);
        }
      }
      setPhase("completed");
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      setMessages((current) => [
        ...current,
        { id: "agent-failure", role: "assistant", parts: [{ type: "error", message }] },
      ]);
      setPhase("failed");
    }
  }, []);

  return {
    phase,
    messages,
    pendingActions,
    startPreparedReplay,
    clearPendingActions,
    reset,
  };
};
  • Step 4: Run tests

Run:

pnpm --dir web --filter @lda/console test -- useDemoAgent

Expected: PASS.

  • Step 5: Commit
git add web/apps/console/src/demo/agent/useDemoAgent.ts web/apps/console/src/demo/agent/useDemoAgent.test.tsx
git commit -m "feat: add demo agent hook"

Task 4: Render Standard Chat Parts

Files:

  • Modify: web/apps/console/src/presentation/OperatorChat.tsx
  • Test: web/apps/console/src/presentation/OperatorChat.test.tsx
  • Modify: web/apps/console/src/presentation/presentation.css

Interfaces:

  • Consumes:

    • AgentMessage from Task 1.
  • Produces:

    • OperatorChat accepts messages?: ReadonlyArray<AgentMessage> while preserving a safe fallback.
  • Step 1: Write chat rendering tests

Create or update web/apps/console/src/presentation/OperatorChat.test.tsx:

import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { OperatorChat } from "./OperatorChat.js";
import type { PresentationState } from "./presentation-state.js";
import type { AgentMessage } from "../demo/agent/events.js";

const state: PresentationState = {
  beat: "intro",
  selectedNodeId: null,
  chatMode: "full",
  evidenceMode: "hidden",
  playbackMode: "replay",
};

describe("OperatorChat", () => {
  it("renders standard agent message parts", () => {
    const messages: ReadonlyArray<AgentMessage> = [
      { id: "u1", role: "user", parts: [{ type: "text", text: "Prepare the report." }] },
      {
        id: "a1",
        role: "assistant",
        parts: [
          { type: "text", text: "I will use the prepared recipe." },
          {
            type: "tool-call",
            call: { id: "call-1", name: "selectWorkflowNode", input: { nodeId: "review_issues" } },
          },
          {
            type: "tool-result",
            result: { callId: "call-1", name: "selectWorkflowNode", status: "success", output: { nodeId: "review_issues" } },
          },
        ],
      },
    ];

    render(<OperatorChat state={state} messages={messages} />);

    expect(screen.getByText("Prepare the report.")).toBeInTheDocument();
    expect(screen.getByText("I will use the prepared recipe.")).toBeInTheDocument();
    expect(screen.getByText(/tool call/i)).toBeInTheDocument();
    expect(screen.getByText(/selectWorkflowNode/i)).toBeInTheDocument();
    expect(screen.getByText(/tool result/i)).toBeInTheDocument();
  });
});
  • Step 2: Run test to verify it fails

Run:

pnpm --dir web --filter @lda/console test -- OperatorChat

Expected: FAIL because OperatorChat does not accept messages.

  • Step 3: Implement chat renderer

Update web/apps/console/src/presentation/OperatorChat.tsx:

import type { AgentMessage, AgentMessagePart } from "../demo/agent/events.js";
import type { PresentationState } from "./presentation-state.js";

type OperatorChatProps = {
  readonly state: PresentationState;
  readonly messages?: ReadonlyArray<AgentMessage>;
};

const fallbackMessages = (state: PresentationState): ReadonlyArray<AgentMessage> => [
  { id: "fallback-user", role: "user", parts: [{ type: "text", text: "Prepare the thesis readiness report." }] },
  {
    id: "fallback-system",
    role: "assistant",
    parts: [
      { type: "text", text: "Found prepared workflow recipe: lda_report_case_study." },
      {
        type: "text",
        text: state.playbackMode === "replay"
          ? "Replay mode is active. Live execution is available when connected."
          : "Live execution is active. Operations are being sent to the connected workflow server.",
      },
    ],
  },
];

const renderPart = (part: AgentMessagePart, index: number) => {
  switch (part.type) {
    case "text":
      return <p key={index}>{part.text}</p>;
    case "tool-call":
      return (
        <div key={index} className="chat-tool-part">
          <span>Tool call</span>
          <code>{part.call.name}</code>
        </div>
      );
    case "tool-result":
      return (
        <div key={index} className="chat-tool-part chat-tool-part--result">
          <span>Tool result</span>
          <code>{part.result.name}</code>
          <small>{part.result.status}</small>
        </div>
      );
    case "presentation-action":
      return (
        <div key={index} className="chat-tool-part chat-tool-part--presentation">
          <span>Presentation action</span>
          <code>{part.action.type}</code>
        </div>
      );
    case "error":
      return <p key={index} className="chat-error">{part.message}</p>;
  }
};

export const OperatorChat = ({ state, messages }: OperatorChatProps) => {
  const visibleMessages = messages && messages.length > 0 ? messages : fallbackMessages(state);
  return (
    <aside className="operator-chat" data-mode={state.chatMode} aria-label="scripted operator chat">
      {visibleMessages.map((message) => (
        <div key={message.id} className={`chat-message chat-message--${message.role === "user" ? "operator" : "system"}`}>
          <strong>{message.role === "user" ? "Operator" : "lda.chat"}</strong>
          {message.parts.map(renderPart)}
        </div>
      ))}
    </aside>
  );
};

Append minimal styles to web/apps/console/src/presentation/presentation.css:

.chat-tool-part {
  display: flex;
  align-items: center;
  gap: 0.45rem;
  margin-top: 0.35rem;
  padding: 0.35rem 0.45rem;
  border: 1px solid color-mix(in oklch, var(--presentation-line), transparent 25%);
  border-radius: 0.45rem;
  font-size: 0.78rem;
}

.chat-tool-part span {
  color: var(--presentation-muted);
  text-transform: uppercase;
  letter-spacing: 0.08em;
}

.chat-tool-part--presentation {
  border-color: color-mix(in oklch, var(--presentation-accent), transparent 25%);
}

.chat-error {
  color: var(--presentation-red);
}
  • Step 4: Run tests

Run:

pnpm --dir web --filter @lda/console test -- OperatorChat

Expected: PASS.

  • Step 5: Commit
git add web/apps/console/src/presentation/OperatorChat.tsx web/apps/console/src/presentation/OperatorChat.test.tsx web/apps/console/src/presentation/presentation.css
git commit -m "feat: render demo agent chat parts"

Task 5: Wire Agent Actions Into Presentation Route

Files:

  • Modify: web/apps/console/src/presentation/PresentationRoute.tsx
  • Modify: web/apps/console/src/presentation/PresentationStage.tsx
  • Test: web/apps/console/src/presentation/PresentationRoute.test.tsx

Interfaces:

  • Consumes:

    • useDemoAgent() from Task 3.
    • PresentationToolAction from Task 1.
  • Produces:

    • /present can start the prepared replay agent.
    • selectWorkflowNode action focuses review_issues.
    • Chat receives agent.messages.
  • Step 1: Write route integration test

Update web/apps/console/src/presentation/PresentationRoute.test.tsx with this test:

it("runs the prepared agent and applies the interrupt node action", async () => {
  render(<PresentationRoute />);

  await userEvent.click(screen.getByRole("button", { name: /run prepared agent/i }));

  expect(await screen.findByText(/prepared workflow recipe/i)).toBeInTheDocument();
  expect(await screen.findByText(/selectWorkflowNode/i)).toBeInTheDocument();
  expect(await screen.findByRole("dialog", { name: /issue review/i })).toBeInTheDocument();
});
  • Step 2: Run test to verify it fails

Run:

pnpm --dir web --filter @lda/console test -- PresentationRoute

Expected: FAIL because no prepared-agent button/action wiring exists.

  • Step 3: Wire the hook and action application

In PresentationRoute.tsx:

  • import useDemoAgent;
  • create const agent = useDemoAgent();;
  • add a small button near existing presentation controls with label Run prepared agent;
  • pass messages={agent.messages} into OperatorChat;
  • add an effect that consumes agent.pendingActions;
  • for selectWorkflowNode, dispatch or set local selected node state so NodeSpotlight opens for the node;
  • call agent.clearPendingActions() after applying actions.

Use this action helper shape inside the component:

useEffect(() => {
  for (const action of agent.pendingActions) {
    if (action.type === "selectWorkflowNode") {
      dispatch({ type: "select_node", nodeId: action.nodeId });
    }
    if (action.type === "setBeat") {
      dispatch({ type: "jump_hash", hash: `#${action.beatId}` });
    }
  }
  if (agent.pendingActions.length > 0) {
    agent.clearPendingActions();
  }
}, [agent.pendingActions, agent.clearPendingActions]);

If presentationReducer does not currently expose select_node, add that action with this behavior:

case "select_node":
  return { ...state, selectedNodeId: action.nodeId };
  • Step 4: Run route tests

Run:

pnpm --dir web --filter @lda/console test -- PresentationRoute presentation-state

Expected: PASS.

  • Step 5: Commit
git add web/apps/console/src/presentation/PresentationRoute.tsx web/apps/console/src/presentation/PresentationRoute.test.tsx web/apps/console/src/presentation/presentation-state.ts web/apps/console/src/presentation/presentation-state.test.ts
git commit -m "feat: wire prepared agent into presentation"

Task 6: Documentation And Full Validation

Files:

  • Modify: web/README.md
  • Modify: docs/current_roadmap.md
  • Move after implementation: docs/superpowers/plans/2026-07-03-constrained-demo-agent.md -> docs/historical/superpowers/plans/2026-07-03-constrained-demo-agent.md

Interfaces:

  • Consumes all prior tasks.

  • Produces documented usage and archived plan.

  • Step 1: Update web/README.md

Add a short section under presentation/demo docs:

### Constrained Demo Agent

`/present` includes a prepared agent recipe for the thesis readiness report.
The recipe is deterministic: it emits standard chat message parts, workflow tool
calls, and presentation tool actions without requiring a model provider key.

The current prepared recipe can:

- identify the `lda_report_case_study.default` workflow deployment;
- show tool calls for run start, resume, and trace read;
- focus the `review_issues` interrupt node through a presentation tool action;
- open evidence linked to the run trace.

This is intentionally not a general autonomous planner. A future server-side
Vercel AI SDK driver can feed the same message-part interface.
  • Step 2: Update docs/current_roadmap.md

Change item 8 from planned to completed and include links to this plan and the design spec.

  • Step 3: Move the plan to historical

Run:

git mv docs/superpowers/plans/2026-07-03-constrained-demo-agent.md docs/historical/superpowers/plans/2026-07-03-constrained-demo-agent.md
  • Step 4: Run focused and full web validation

Run:

pnpm --dir web --filter @lda/console test -- agent OperatorChat PresentationRoute
pnpm --dir web test
pnpm --dir web typecheck
pnpm --dir web build
git diff --check

Expected:

  • focused tests pass;

  • full web tests pass;

  • typecheck passes;

  • build passes, allowing the existing Vite chunk-size warning;

  • git diff --check has no whitespace errors.

  • Step 5: Commit

git add web/README.md docs/current_roadmap.md docs/historical/superpowers/plans/2026-07-03-constrained-demo-agent.md
git commit -m "docs: document constrained demo agent"

Self-Review Checklist

  • Spec coverage: the plan covers standard chat parts, workflow tools, presentation tools, replay, live seam, AI SDK future seam, Effect boundary, tests, and docs.
  • Dependency control: the plan does not add Vercel AI SDK, model providers, or Effect dependencies to the console.
  • Type consistency: AgentMessage, AgentMessagePart, PresentationToolAction, AgentToolName, and DemoAgentController names are introduced before use.
  • Scope control: styling is minimal; no generic chat composer, provider UI, or persistent agent sessions are included.