refactor: name presentation evidence states

This commit is contained in:
lda
2026-07-06 03:56:53 +07:00 Verified
parent bd2081fe8a
commit baf26156e5
14 changed files with 108 additions and 89 deletions
@@ -5,7 +5,6 @@ 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 };
export type AgentToolCall = {
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { loadCanonicalDemoRecording } from "../timeline/replay.js";
import { runPreparedRecipeReplay } from "./preparedRecipeDriver.js";
import { isAllowedAgentToolName } from "./tools.js";
const collect = async <T>(events: AsyncIterable<T>): Promise<ReadonlyArray<T>> => {
const collected: T[] = [];
@@ -38,8 +39,9 @@ describe("prepared recipe driver", () => {
"selectWorkflowNode",
"resumeIssueReview",
"readRunTrace",
"openEvidence",
]);
expect(isAllowedAgentToolName("openEvidence")).toBe(false);
});
it("emits approval-request at resumeIssueReview and waits for decision", async () => {
@@ -112,17 +112,6 @@ export async function* runPreparedRecipeReplay(
eventId: trace?.id ?? null,
});
break;
case "openEvidence":
yield {
id: step.id,
role: "assistant",
parts: [
agentToolCallPart(`${step.id}-call`, step.toolName, { eventId: trace?.id ?? "trace" }),
presentationActionPart({ type: "openEvidence", eventId: trace?.id ?? "trace" }),
agentToolResultPart(`${step.id}-call`, step.toolName, "success", { eventId: trace?.id ?? "trace" }),
],
};
break;
default:
assertNever(step.toolName);
}
+1 -7
View File
@@ -5,8 +5,7 @@ export type RecipeTool =
| "startPreparedReportRun"
| "selectWorkflowNode"
| "resumeIssueReview"
| "readRunTrace"
| "openEvidence";
| "readRunTrace";
export type PreparedRecipeStep = {
readonly id: string;
@@ -58,10 +57,5 @@ export const PREPARE_THESIS_REPORT_RECIPE: PreparedRecipe = {
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",
},
],
};
@@ -8,7 +8,6 @@ describe("agent tools", () => {
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", () => {
-6
View File
@@ -7,7 +7,6 @@ export type WorkflowToolName =
export type PresentationToolName =
| "selectWorkflowNode"
| "focusOperation"
| "openEvidence"
| "showTraceFrame";
export type AgentToolName = WorkflowToolName | PresentationToolName;
@@ -49,11 +48,6 @@ export const AGENT_TOOLS = {
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",
@@ -18,7 +18,7 @@ const record: EvidenceRecord = {
describe("EvidenceDrawer", () => {
it("renders current evidence records and can close", () => {
const close = vi.fn();
render(<EvidenceDrawer records={[record]} mode="open" close={close} />);
render(<EvidenceDrawer records={[record]} mode="receipt" close={close} />);
expect(screen.getByRole("complementary", { name: /presentation evidence/i })).toBeInTheDocument();
expect(screen.getByText(/workflow.runs.start/i)).toBeInTheDocument();
@@ -1,10 +1,10 @@
import type { EvidenceRecord } from "../app/state.js";
import { formatJson } from "./format.js";
import type { EvidenceMode } from "./storyboard.js";
import type { EvidencePresentation } from "./storyboard.js";
type EvidenceDrawerProps = {
readonly records: readonly EvidenceRecord[];
readonly mode: EvidenceMode;
readonly mode: EvidencePresentation;
readonly close: () => void;
};
@@ -117,14 +117,6 @@ export const PresentationRoute = () => {
case "selectWorkflowNode":
dispatch({ type: "select_node", nodeId: action.nodeId });
break;
case "openEvidence": {
const hasLiveEvidence = evidence.length > 0;
if (!hasLiveEvidence) {
setEvidence(replayEvidence);
}
dispatch({ type: "set_evidence_mode", mode: "open" });
break;
}
case "focusOperation":
case "showTraceFrame":
break;
@@ -164,7 +156,7 @@ export const PresentationRoute = () => {
onDeny={agent.phase === "awaiting-approval" ? handleDeny : undefined}
jump={handleJump}
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
openEvidence={() => dispatch({ type: "set_evidence_mode", mode: "open" })}
openEvidence={() => dispatch({ type: "set_evidence_presentation", presentation: "inspector" })}
closeOverlay={() => dispatch({ type: "close_overlay" })}
openDiscussion={handleOpenDiscussion}
closeDiscussion={handleCloseDiscussion}
@@ -53,7 +53,7 @@ export const PresentationStage = ({
<div
className="presentation-stage"
data-chat-mode={composition.chatMode}
data-evidence-mode={composition.evidenceMode}
data-evidence-presentation={composition.evidencePresentation}
data-scene-view={activeSceneView}
>
<aside className="presentation-stage__chat" aria-label="agent chat region">
@@ -79,7 +79,7 @@ export const PresentationStage = ({
)}
</section>
<aside className="presentation-stage__evidence" aria-label="evidence region">
<EvidenceDrawer records={evidence} mode={composition.evidenceMode} close={closeOverlay} />
<EvidenceDrawer records={evidence} mode={composition.evidencePresentation} close={closeOverlay} />
</aside>
{state.location.kind === "main" && (
<SceneProgress location={state.location} />
@@ -72,27 +72,27 @@ describe("presentationReducer", () => {
expect(state.selectedNodeId).toBe("review_issues");
});
it("closes overlays in priority order: node, evidence, discussion", () => {
it("closes overlays in priority order: inspector, node, discussion", () => {
const withNode = presentationReducer(initialPresentationState, {
type: "select_node",
nodeId: "review_issues",
});
const withEvidence = presentationReducer(withNode, {
type: "set_evidence_mode",
mode: "open",
const withInspector = presentationReducer(withNode, {
type: "set_evidence_presentation",
presentation: "inspector",
});
const opened = presentationReducer(withEvidence, {
const opened = presentationReducer(withInspector, {
type: "open_discussion",
branchId: "hosted-automation",
});
const closed1 = presentationReducer(opened, { type: "close_overlay" });
expect(closed1.selectedNodeId).toBeNull();
expect(closed1.evidenceModeOverride).toBe("open");
expect(closed1.evidencePresentationOverride).toBe("hidden");
expect(closed1.selectedNodeId).toBe("review_issues");
expect(closed1.location.kind).toBe("discussion");
const closed2 = presentationReducer(closed1, { type: "close_overlay" });
expect(closed2.evidenceModeOverride).toBe("hidden");
expect(closed2.selectedNodeId).toBeNull();
const closed3 = presentationReducer(closed2, { type: "close_overlay" });
expect(closed3.location.kind).toBe("main");
@@ -123,11 +123,11 @@ describe("presentationReducer", () => {
it("closes overlays before rewinding content", () => {
const opened = presentationReducer(initialPresentationState, {
type: "set_evidence_mode",
mode: "open",
type: "set_evidence_presentation",
presentation: "inspector",
});
const closed = presentationReducer(opened, { type: "close_overlay" });
expect(closed.evidenceModeOverride).toBe("hidden");
expect(closed.evidencePresentationOverride).toBe("hidden");
expect(closed.location).toEqual(initialPresentationState.location);
});
@@ -136,13 +136,14 @@ describe("presentationReducer", () => {
type: "jump",
location: { kind: "main", sceneId: "interrupt-evidence", beatId: "trace", focusPath: [] },
});
expect(stateAtTrace.evidenceModeOverride).toBeNull();
expect(stateAtTrace.evidencePresentationOverride).toBeNull();
const closed = presentationReducer(stateAtTrace, { type: "close_overlay" });
expect(closed.evidenceModeOverride).toBe("hidden");
expect(closed.evidencePresentationOverride).toBeNull();
expect(closed.location.kind).toBe("main");
const secondEscape = presentationReducer(closed, { type: "close_overlay" });
expect(secondEscape.evidenceModeOverride).toBe("hidden");
expect(secondEscape.evidencePresentationOverride).toBeNull();
expect(secondEscape.location.kind).toBe("main");
});
@@ -183,4 +184,51 @@ describe("presentationReducer", () => {
expect(presentationReducer(opened, { type: "close_discussion" }).location)
.toEqual(deepRuntimeState.location);
});
it("derives a receipt from beat metadata without opening an inspector", () => {
const state = presentationReducer(initialPresentationState, {
type: "jump",
location: { kind: "main", sceneId: "architecture", beatId: "node-use", focusPath: ["node-use"] },
});
expect(compositionForState(state).evidencePresentation).toBe("receipt");
expect(state.evidencePresentationOverride).toBeNull();
});
it("closes an explicit inspector before the node spotlight", () => {
const withNode = presentationReducer(initialPresentationState, {
type: "select_node",
nodeId: "review_issues",
});
const withInspector = presentationReducer(withNode, {
type: "set_evidence_presentation",
presentation: "inspector",
});
const firstEscape = presentationReducer(withInspector, { type: "close_overlay" });
expect(firstEscape.evidencePresentationOverride).toBe("hidden");
expect(firstEscape.selectedNodeId).toBe("review_issues");
const secondEscape = presentationReducer(firstEscape, { type: "close_overlay" });
expect(secondEscape.selectedNodeId).toBeNull();
});
it("closes the inspector and recomputes receipt state when the beat changes", () => {
const atReceiptBeat = presentationReducer(initialPresentationState, {
type: "jump",
location: { kind: "main", sceneId: "architecture", beatId: "node-use", focusPath: ["node-use"] },
});
const opened = presentationReducer(atReceiptBeat, {
type: "set_evidence_presentation",
presentation: "inspector",
});
const advanced = presentationReducer(opened, { type: "next" });
expect(advanced.evidencePresentationOverride).toBeNull();
expect(compositionForState(advanced).evidencePresentation).not.toBe("inspector");
});
it("does not treat a receipt as an Escape-closeable overlay", () => {
const receipt = presentationReducer(initialPresentationState, {
type: "jump",
location: { kind: "main", sceneId: "authoring", beatId: "diagnose", focusPath: [] },
});
expect(presentationReducer(receipt, { type: "close_overlay" })).toEqual(receipt);
});
});
@@ -2,9 +2,10 @@ import {
defaultMainLocation,
findDiscussionBranch,
findScene,
type BeatEvidencePresentation,
type ChatMode,
type DiscussionBranchId,
type EvidenceMode,
type EvidencePresentation,
type MainLocation,
type PresentationLocation,
} from "./storyboard.js";
@@ -19,7 +20,7 @@ export type PresentationState = {
readonly location: PresentationLocation;
readonly discussionReturn: MainLocation | null;
readonly selectedNodeId: string | null;
readonly evidenceModeOverride: EvidenceMode | null;
readonly evidencePresentationOverride: EvidencePresentation | null;
readonly playbackMode: "replay" | "live";
readonly motionDisabled: boolean;
readonly startedAt: number;
@@ -34,7 +35,7 @@ export type PresentationAction =
| { readonly type: "close_discussion" }
| { readonly type: "select_node"; readonly nodeId: string }
| { readonly type: "clear_node" }
| { readonly type: "set_evidence_mode"; readonly mode: EvidenceMode }
| { readonly type: "set_evidence_presentation"; readonly presentation: EvidencePresentation }
| { readonly type: "close_overlay" }
| { readonly type: "set_playback_mode"; readonly mode: PresentationState["playbackMode"] }
| { readonly type: "set_focus_path"; readonly path: readonly string[] }
@@ -44,7 +45,7 @@ export const initialPresentationState: PresentationState = {
location: defaultMainLocation,
discussionReturn: null,
selectedNodeId: null,
evidenceModeOverride: null,
evidencePresentationOverride: null,
playbackMode: "replay",
motionDisabled: false,
startedAt: Date.now(),
@@ -52,29 +53,29 @@ export const initialPresentationState: PresentationState = {
const compositionForLocation = (
location: PresentationLocation,
evidenceOverride: EvidenceMode | null,
evidenceOverride: EvidencePresentation | null,
): {
readonly chatMode: ChatMode;
readonly evidenceMode: EvidenceMode;
readonly evidencePresentation: EvidencePresentation;
} => {
if (location.kind === "discussion") {
return {
chatMode: "hidden",
evidenceMode: evidenceOverride ?? "hidden",
evidencePresentation: evidenceOverride ?? "hidden",
};
}
const scene = findScene(location.sceneId);
const beat = scene?.beats.find((b) => b.id === location.beatId);
return {
chatMode: beat?.chatMode ?? "hidden",
evidenceMode: evidenceOverride ?? beat?.evidenceMode ?? "hidden",
evidencePresentation: evidenceOverride ?? beat?.evidencePresentation ?? "hidden",
};
};
export const compositionForState = (state: PresentationState) =>
compositionForLocation(
state.location,
state.evidenceModeOverride,
state.evidencePresentationOverride,
);
const isValidMainLocation = (location: PresentationLocation): location is MainLocation =>
@@ -93,6 +94,14 @@ const clampMainLocation = (location: MainLocation): MainLocation => {
return defaultMainLocation;
};
const moveToLocation = (
state: PresentationState,
location: PresentationLocation,
): PresentationState => ({
...state,
location,
});
export const presentationReducer = (
state: PresentationState,
action: PresentationAction,
@@ -101,27 +110,27 @@ export const presentationReducer = (
case "next": {
if (!isValidMainLocation(state.location)) return state;
const next = nextMainLocation(state.location);
return { ...state, location: next };
return { ...moveToLocation(state, next), evidencePresentationOverride: null };
}
case "previous": {
if (!isValidMainLocation(state.location)) return state;
const prev = previousMainLocation(state.location);
return { ...state, location: prev };
return { ...moveToLocation(state, prev), evidencePresentationOverride: null };
}
case "jump": {
if (state.location.kind === "discussion") return state;
return { ...state, location: action.location };
return { ...moveToLocation(state, action.location), evidencePresentationOverride: null };
}
case "jump_hash": {
const parsed = locationFromHash(action.hash);
if (parsed.kind === "main") {
return { ...state, location: clampMainLocation(parsed), discussionReturn: null };
return { ...moveToLocation(state, clampMainLocation(parsed)), discussionReturn: null, evidencePresentationOverride: null };
}
const branch = findDiscussionBranch(parsed.branchId);
const returnLoc = branch
? firstBeatOfScene(branch.parentSceneId) ?? defaultMainLocation
: defaultMainLocation;
return { ...state, location: parsed, discussionReturn: returnLoc };
return { ...moveToLocation(state, parsed), discussionReturn: returnLoc };
}
case "open_discussion": {
const branch = findDiscussionBranch(action.branchId);
@@ -138,8 +147,7 @@ export const presentationReducer = (
case "close_discussion": {
if (state.location.kind !== "discussion") return state;
return {
...state,
location: state.discussionReturn ?? defaultMainLocation,
...moveToLocation(state, state.discussionReturn ?? defaultMainLocation),
discussionReturn: null,
};
}
@@ -147,18 +155,11 @@ export const presentationReducer = (
return { ...state, selectedNodeId: action.nodeId };
case "clear_node":
return { ...state, selectedNodeId: null };
case "set_evidence_mode":
return { ...state, evidenceModeOverride: action.mode };
case "set_evidence_presentation":
return { ...state, evidencePresentationOverride: action.presentation };
case "close_overlay": {
if (state.evidencePresentationOverride === "inspector") return { ...state, evidencePresentationOverride: "hidden" };
if (state.selectedNodeId !== null) return { ...state, selectedNodeId: null };
const isEvidenceVisible = (() => {
if (state.evidenceModeOverride !== null) return state.evidenceModeOverride !== "hidden";
if (state.location.kind === "discussion") return false;
const scene = findScene(state.location.sceneId);
const beat = scene?.beats.find((b) => b.id === (state.location as MainLocation).beatId);
return beat?.evidenceMode === "open" || beat?.evidenceMode === "peek";
})();
if (isEvidenceVisible) return { ...state, evidenceModeOverride: "hidden" };
if (state.location.kind === "discussion") {
return {
...state,
@@ -48,7 +48,7 @@ const mockBeat = {
caption: "Human and agent clients use the same public lifecycle surface.",
chatMode: "rail" as const,
chatTheme: "light" as const,
evidenceMode: "hidden" as const,
evidencePresentation: "hidden" as const,
figure: { catalogId: "system-architecture", focusPath: [] as readonly string[], activeNodeId: "client-operations" },
};
@@ -2,7 +2,8 @@ export type ClaimClass = "motivation" | "implemented" | "evaluated" | "external-
export type StageTheme = "paper" | "night";
export type ChatTheme = "light" | "dark";
export type ChatMode = "hidden" | "full" | "rail" | "dock";
export type EvidenceMode = "hidden" | "peek" | "open";
export type EvidencePresentation = "hidden" | "receipt" | "inspector";
export type BeatEvidencePresentation = Exclude<EvidencePresentation, "inspector">;
export type SceneView =
| "narrative"
| "positioning"
@@ -27,7 +28,7 @@ export type SceneBeatDefinition = {
readonly caption: string;
readonly chatMode: ChatMode;
readonly chatTheme: ChatTheme;
readonly evidenceMode: EvidenceMode;
readonly evidencePresentation: BeatEvidencePresentation;
readonly figure: FigureBeatDefinition | null;
};
@@ -48,14 +49,14 @@ const sceneBeat = (
id: string,
title: string,
caption: string,
options: Partial<Pick<SceneBeatDefinition, "chatMode" | "chatTheme" | "evidenceMode" | "figure">> = {},
options: Partial<Pick<SceneBeatDefinition, "chatMode" | "chatTheme" | "evidencePresentation" | "figure">> = {},
): SceneBeatDefinition => ({
id,
title,
caption,
chatMode: options.chatMode ?? "hidden",
chatTheme: options.chatTheme ?? "dark",
evidenceMode: options.evidenceMode ?? "hidden",
evidencePresentation: options.evidencePresentation ?? "hidden",
figure: options.figure ?? null,
});
@@ -140,7 +141,7 @@ export const mainScenes = defineScenes([
sceneBeat("client", "Client operations", "Human and agent clients use the same public lifecycle surface.", { figure: { catalogId: "system-architecture", focusPath: [], activeNodeId: "client-operations" } }),
sceneBeat("api", "Transport and API", "JSON-RPC reaches WorkflowApi without owning domain behavior.", { figure: { catalogId: "system-architecture", focusPath: [], activeNodeId: "application-lifecycle" } }),
sceneBeat("runtime", "Runtime and providers", "The runtime resolves provider-neutral capabilities and stores lifecycle records.", { figure: { catalogId: "system-architecture", focusPath: ["runtime-providers"], activeNodeId: "configured-providers" } }),
sceneBeat("node-use", "NodeUse", "One callable node validates input, invokes a capability, and reduces output into state.", { evidenceMode: "peek", figure: { catalogId: "system-architecture", focusPath: ["node-use"], activeNodeId: "invoke-handler" } }),
sceneBeat("node-use", "NodeUse", "One callable node validates input, invokes a capability, and reduces output into state.", { evidencePresentation: "receipt", figure: { catalogId: "system-architecture", focusPath: ["node-use"], activeNodeId: "invoke-handler" } }),
],
},
{
@@ -154,7 +155,7 @@ export const mainScenes = defineScenes([
beats: [
sceneBeat("discover", "Discover", "Inspect capabilities and schemas before authoring."),
sceneBeat("author", "Author", "Focused operations build and connect the draft."),
sceneBeat("diagnose", "Diagnose", "Structured diagnostics identify invalid state.", { evidenceMode: "peek" }),
sceneBeat("diagnose", "Diagnose", "Structured diagnostics identify invalid state.", { evidencePresentation: "receipt" }),
sceneBeat("repair", "Repair", "Repair hints lead to a valid compiled workflow."),
],
},
@@ -197,7 +198,7 @@ export const mainScenes = defineScenes([
sceneBeat("approval", "Approval", "The operator reviews a schema-backed resume request.", { chatMode: "rail", chatTheme: "light" }),
sceneBeat("resume", "Resume", "The approved payload resumes the same persisted run.", { chatMode: "rail", chatTheme: "light" }),
sceneBeat("output", "Output", "The workflow produces the report and issue-board changes.", { chatMode: "dock", chatTheme: "light" }),
sceneBeat("trace", "Evidence", "Trace frames and protocol evidence remain inspectable.", { chatMode: "dock", chatTheme: "light", evidenceMode: "open" }),
sceneBeat("trace", "Evidence", "Trace frames and protocol evidence remain inspectable.", { chatMode: "dock", chatTheme: "light", evidencePresentation: "receipt" }),
],
},
{