fix: resume workflow on revision requests

This commit is contained in:
lda
2026-07-11 23:04:47 +07:00 Verified
parent 31fb170dba
commit d99447ebd7
30 changed files with 411 additions and 187 deletions
+3 -3
View File
@@ -130,7 +130,7 @@ Implementation order:
proof. Implementation: proof. Implementation:
[`schema approval surface`](historical/superpowers/plans/2026-07-09-schema-approval-surface.md). [`schema approval surface`](historical/superpowers/plans/2026-07-09-schema-approval-surface.md).
16. Completed: presentation chat now drives the prepared workflow timeline. 16. Completed: presentation chat now drives the prepared workflow timeline.
The chat run action, schema approval submit/cancel, graph, evidence, and The chat run action, schema approval submit/revision, graph, evidence, and
live/replay execution all share `useDemoTimeline`; AI SDK remains a later live/replay execution all share `useDemoTimeline`; AI SDK remains a later
driver for the same seam. Implementation: driver for the same seam. Implementation:
[`presentation chat timeline bridge`](historical/superpowers/plans/2026-07-09-presentation-chat-timeline-bridge.md). [`presentation chat timeline bridge`](historical/superpowers/plans/2026-07-09-presentation-chat-timeline-bridge.md).
@@ -152,8 +152,8 @@ Implementation order:
[`presentation live/replay truth plan`](historical/superpowers/plans/2026-07-09-presentation-live-replay-truth.md). [`presentation live/replay truth plan`](historical/superpowers/plans/2026-07-09-presentation-live-replay-truth.md).
20. Completed: Scene 10 now presents factual run state: workflow input, 20. Completed: Scene 10 now presents factual run state: workflow input,
interrupt payload, operator resume decision, output, and trace frame facts. interrupt payload, operator resume decision, output, and trace frame facts.
Cancel is terminal in presentation mode and does not advance into submitted Request revision resumes the same run through the negative outcome branch;
evidence. Implementation: it does not create issues. Implementation:
[`Scene 10 factual run state`](historical/superpowers/plans/2026-07-09-scene-10-factual-run-state.md). [`Scene 10 factual run state`](historical/superpowers/plans/2026-07-09-scene-10-factual-run-state.md).
21. Completed: presentation lifecycle story expansion splits the demo climax 21. Completed: presentation lifecycle story expansion splits the demo climax
into prepared lifecycle, run start, typed human boundary, and into prepared lifecycle, run start, typed human boundary, and
@@ -27,7 +27,7 @@ const baseController = {
play: vi.fn(), play: vi.fn(),
next: vi.fn(), next: vi.fn(),
submitSelectedIssues: vi.fn(), submitSelectedIssues: vi.fn(),
cancelReview: vi.fn(), requestRevision: vi.fn(),
restart: vi.fn(), restart: vi.fn(),
primeReplayToStage: vi.fn(), primeReplayToStage: vi.fn(),
}; };
@@ -115,8 +115,8 @@ export const LdaReportDemoPanel = ({ controller }: { readonly controller: DemoTi
Resume and create selected issues Resume and create selected issues
</button> </button>
)} )}
<button onClick={() => void controller.cancelReview(comment)}> <button onClick={() => void controller.requestRevision(comment)}>
Cancel review Request revision
</button> </button>
</div> </div>
{state.mode === "replay" && ( {state.mode === "replay" && (
@@ -49,7 +49,7 @@ const demoController = (
play: vi.fn(), play: vi.fn(),
next: vi.fn(async () => {}), next: vi.fn(async () => {}),
submitSelectedIssues: vi.fn(async () => {}), submitSelectedIssues: vi.fn(async () => {}),
cancelReview: vi.fn(async () => {}), requestRevision: vi.fn(async () => {}),
restart: vi.fn(), restart: vi.fn(),
primeReplayToStage: vi.fn(), primeReplayToStage: vi.fn(),
...overrides, ...overrides,
@@ -128,17 +128,17 @@ describe("useTimelineAgent", () => {
expect(submitSelectedIssues).toHaveBeenCalledWith(["risk-1"], "Create the selected issue."); expect(submitSelectedIssues).toHaveBeenCalledWith(["risk-1"], "Create the selected issue.");
}); });
it("cancels review through the timeline", async () => { it("requests revision through the timeline", async () => {
const cancelReview = vi.fn(async () => {}); const requestRevision = vi.fn(async () => {});
const demo = demoController({ const demo = demoController({
state: { ...initialDemoTimelineState, phase: "review" }, state: { ...initialDemoTimelineState, phase: "review" },
cancelReview, requestRevision,
}); });
const { result } = renderHook(() => useTimelineAgent(demo, { mode: "live", status: readyStatus })); const { result } = renderHook(() => useTimelineAgent(demo, { mode: "live", status: readyStatus }));
await act(async () => result.current.cancelReview()); await act(async () => result.current.requestRevision());
expect(cancelReview).toHaveBeenCalledWith("Cancelled by operator."); expect(requestRevision).toHaveBeenCalledWith("Request revisions before creating issues.");
}); });
it("disables run when the timeline cannot start", () => { it("disables run when the timeline cannot start", () => {
@@ -147,19 +147,19 @@ describe("useTimelineAgent", () => {
expect(result.current.canRun).toBe(false); expect(result.current.canRun).toBe(false);
}); });
it("does not advance replay cancellation into the submitted recording branch", async () => { it("advances replay revision requests through the negative branch", async () => {
const cancelReview = vi.fn(async () => {}); const requestRevision = vi.fn(async () => {});
const next = vi.fn(async () => {}); const next = vi.fn(async () => {});
const demo = demoController({ const demo = demoController({
state: { ...initialDemoTimelineState, mode: "replay", phase: "review" }, state: { ...initialDemoTimelineState, mode: "replay", phase: "review" },
cancelReview, requestRevision,
next, next,
}); });
const { result } = renderHook(() => useTimelineAgent(demo, { mode: "replay", status: replayStatus })); const { result } = renderHook(() => useTimelineAgent(demo, { mode: "replay", status: replayStatus }));
await act(async () => result.current.cancelReview()); await act(async () => result.current.requestRevision());
expect(cancelReview).toHaveBeenCalledWith("Cancelled by operator."); expect(requestRevision).toHaveBeenCalledWith("Request revisions before creating issues.");
expect(next).not.toHaveBeenCalled(); expect(next).not.toHaveBeenCalled();
expect(result.current.messages.at(-1)?.parts).toEqual( expect(result.current.messages.at(-1)?.parts).toEqual(
expect.arrayContaining([ expect.arrayContaining([
@@ -168,20 +168,20 @@ describe("useTimelineAgent", () => {
); );
}); });
it("live cancel does not call next", async () => { it("live revision request advances the timeline", async () => {
const cancelReview = vi.fn(async () => {}); const requestRevision = vi.fn(async () => {});
const next = vi.fn(async () => {}); const next = vi.fn(async () => {});
const demo = demoController({ const demo = demoController({
state: { ...initialDemoTimelineState, mode: "live", phase: "review" }, state: { ...initialDemoTimelineState, mode: "live", phase: "review" },
cancelReview, requestRevision,
next, next,
}); });
const { result } = renderHook(() => useTimelineAgent(demo, { mode: "live", status: readyStatus })); const { result } = renderHook(() => useTimelineAgent(demo, { mode: "live", status: readyStatus }));
await act(async () => result.current.cancelReview()); await act(async () => result.current.requestRevision());
expect(cancelReview).toHaveBeenCalledWith("Cancelled by operator."); expect(requestRevision).toHaveBeenCalledWith("Request revisions before creating issues.");
expect(next).not.toHaveBeenCalled(); expect(next).toHaveBeenCalledOnce();
}); });
it("uses replay label when live target failed", () => { it("uses replay label when live target failed", () => {
@@ -22,7 +22,7 @@ export type TimelineAgentController = {
readonly runLabel: string; readonly runLabel: string;
readonly runPreparedWorkflow: () => Promise<void>; readonly runPreparedWorkflow: () => Promise<void>;
readonly submitSelectedIssues: () => Promise<void>; readonly submitSelectedIssues: () => Promise<void>;
readonly cancelReview: () => Promise<void>; readonly requestRevision: () => Promise<void>;
}; };
const DEFAULT_COMMENT = "Create the selected issue."; const DEFAULT_COMMENT = "Create the selected issue.";
@@ -120,16 +120,15 @@ export const useTimelineAgent = (
)); ));
}, [demo, selectedIssueIds]); }, [demo, selectedIssueIds]);
const cancelReview = useCallback(async () => { const requestRevision = useCallback(async () => {
await demo.cancelReview("Cancelled by operator."); await demo.requestRevision("Request revisions before creating issues.");
// Cancellation is terminal in presentation mode. Do not call next() or the UI if (modeLabel === "live") await demo.next();
// would falsely advance into submitted/resume evidence.
setMessages((current) => appendToolMessage( setMessages((current) => appendToolMessage(
current, current,
"timeline-agent-cancel", "timeline-agent-revision",
"resumeIssueReview", "resumeIssueReview",
{}, { approved: false, outcome: "cancelled" },
{ outcome: "cancelled" }, { outcome: "cancelled", label: "revision requested" },
)); ));
}, [demo]); }, [demo]);
@@ -139,6 +138,6 @@ export const useTimelineAgent = (
runLabel, runLabel,
runPreparedWorkflow, runPreparedWorkflow,
submitSelectedIssues, submitSelectedIssues,
cancelReview, requestRevision,
}; };
}; };
@@ -110,7 +110,7 @@ describe("demoTimelineReducer", () => {
expect(restarted.appliedCount).toBe(0); expect(restarted.appliedCount).toBe(0);
}); });
it("cancels review as a terminal non-autoplay phase", () => { it("resumes review autoplay", () => {
const reviewing = { const reviewing = {
...initialDemoTimelineState, ...initialDemoTimelineState,
mode: "replay" as const, mode: "replay" as const,
@@ -119,10 +119,10 @@ describe("demoTimelineReducer", () => {
appliedCount: 1, appliedCount: 1,
autoplay: false, autoplay: false,
}; };
const cancelled = demoTimelineReducer(reviewing, { type: "cancel_review" }); const continued = demoTimelineReducer(reviewing, { type: "continue_review" });
expect(cancelled.phase).toBe("cancelled"); expect(continued.phase).toBe("running");
expect(cancelled.autoplay).toBe(false); expect(continued.autoplay).toBe(true);
expect(cancelled.appliedCount).toBe(1); expect(continued.appliedCount).toBe(1);
}); });
}); });
@@ -36,7 +36,6 @@ export type DemoTimelineAction =
| { readonly type: "pause" } | { readonly type: "pause" }
| { readonly type: "play" } | { readonly type: "play" }
| { readonly type: "continue_review" } | { readonly type: "continue_review" }
| { readonly type: "cancel_review" }
| { readonly type: "fail"; readonly message: string; readonly event?: DemoEvent } | { readonly type: "fail"; readonly message: string; readonly event?: DemoEvent }
| { readonly type: "restart" } | { readonly type: "restart" }
| { | {
@@ -104,10 +103,6 @@ export const demoTimelineReducer = (
return state.phase === "review" return state.phase === "review"
? { ...state, phase: "running", autoplay: true } ? { ...state, phase: "running", autoplay: true }
: state; : state;
case "cancel_review":
return state.phase === "review"
? { ...state, phase: "cancelled", autoplay: false }
: state;
case "fail": case "fail":
return { return {
...state, ...state,
@@ -1,5 +1,9 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { loadCanonicalDemoRecording, nextReplayEvent } from "./replay.js"; import {
loadCanonicalDemoRecording,
nextReplayEvent,
revisionReplayRecording,
} from "./replay.js";
vi.mock("../../connection/api.js", () => ({ vi.mock("../../connection/api.js", () => ({
callOperation: vi.fn(() => { callOperation: vi.fn(() => {
@@ -27,4 +31,24 @@ describe("canonical demo recording", () => {
expect(nextReplayEvent(recording, 0)?.stage).toBe("deployment_check"); expect(nextReplayEvent(recording, 0)?.stage).toBe("deployment_check");
expect(nextReplayEvent(recording, recording.events.length)).toBeNull(); expect(nextReplayEvent(recording, recording.events.length)).toBeNull();
}); });
it("projects a truthful revision-requested branch", () => {
const recording = revisionReplayRecording(loadCanonicalDemoRecording());
const resume = recording.events.find((event) => event.stage === "run_resume");
const completed = recording.events.find((event) => event.stage === "completed");
expect(recording.recordingId).toBe("lda-report-revision-v1");
expect(resume?.params).toMatchObject({
resume_outcome: "cancelled",
resume_payload: { approved: false, selected_issue_ids: [] },
});
expect((resume?.interpreted as { output: { approved: boolean; created_issues: unknown[] } }).output).toEqual({
approved: false,
markdown: "# Revision Requested\n\nRequest revisions before creating issues.",
created_issues: [],
selected_issue_ids: [],
comment: "Request revisions before creating issues.",
});
expect((completed?.interpreted as { trace: { frames: unknown[] } }).trace.frames).toHaveLength(9);
});
}); });
@@ -1,6 +1,148 @@
import recordingText from "../recordings/lda-report-success.v1.json?raw"; import recordingText from "../recordings/lda-report-success.v1.json?raw";
import { decodeDemoRecording, type DemoEvent, type DemoRecording } from "./models.js"; import { decodeDemoRecording, type DemoEvent, type DemoRecording } from "./models.js";
export const REVISION_REQUEST_COMMENT = "Request revisions before creating issues.";
const revisionRunId = "run_recorded_lda_report_revision";
const revisionOutput = {
approved: false,
markdown: "# Revision Requested\n\nRequest revisions before creating issues.",
created_issues: [],
selected_issue_ids: [],
comment: REVISION_REQUEST_COMMENT,
};
const revisionTrace = {
frames: [
"reset_board",
"read_docs",
"analyze",
"build_report",
"draft_issues",
"review_issues",
"review_issues",
"revision_requested",
"end_cancelled",
].map((nodeId, index) => ({
nodeId,
stepType: nodeId === "end_cancelled" ? "end" : nodeId === "review_issues" && index === 6 ? "interrupt" : "node",
outcome: nodeId === "review_issues" && index === 5 ? "interrupt" : nodeId === "end_cancelled" ? "cancelled" : "ok",
resolvedInput: {},
output: {},
stateChanges: {},
})),
traceStart: 0,
traceLimit: 50,
traceTruncated: false,
};
/**
* Projects the real negative workflow outcome into the deterministic replay.
* The success recording is still the source for discovery and interruption;
* only the post-decision branch is replaced with facts captured from RPC.
*/
export const revisionReplayRecording = (recording: DemoRecording): DemoRecording => {
const events = recording.events.map((event) => {
const resultingIds = { ...event.resultingIds, runId: event.stage === "deployment_check" ? null : revisionRunId };
if (event.stage === "run_start") {
const interpreted = event.interpreted as Record<string, unknown>;
return {
...event,
resultingIds,
interpreted: { ...interpreted, runId: revisionRunId },
rawResponse: { result: { run_id: revisionRunId, status: "interrupted" } },
};
}
if (event.stage === "interrupt") return { ...event, resultingIds };
if (event.stage === "run_resume") {
return {
...event,
id: "revision-3-run-resume",
reason: "Resume the interrupted run with revision requested.",
resultingIds,
equivalentCli: `uv run wf run resume ${revisionRunId} --payload '<json>'`,
params: {
run_id: revisionRunId,
resume_payload: {
approved: false,
selected_issue_ids: [],
comment: REVISION_REQUEST_COMMENT,
},
resume_outcome: "cancelled",
trace_range: { start: 0, limit: 50 },
},
rawResponse: {
result: {
run_id: revisionRunId,
status: "completed",
outcome: "cancelled",
output: revisionOutput,
trace_count: revisionTrace.frames.length,
},
},
interpreted: {
runId: revisionRunId,
deploymentId: recording.deploymentId,
artifactId: "lda_report_case_study",
artifactVersion: 1,
status: "completed",
resumeReadiness: "not_applicable",
interrupt: null,
outcome: "cancelled",
error: null,
output: revisionOutput,
diagnostics: [],
traceCount: revisionTrace.frames.length,
nextActions: {
canContinue: false,
canSaveNow: null,
recommendedNextTool: null,
reason: "Run completed after revision was requested.",
patchExamples: [],
warnings: [],
},
},
};
}
if (event.stage === "trace_read") {
return {
...event,
id: "revision-4-trace-read",
reason: "Read the revision-requested run trace.",
resultingIds,
params: { run_id: revisionRunId, trace_range: { start: 0, limit: 50 } },
rawResponse: {
result: {
run_id: revisionRunId,
status: "completed",
trace_count: revisionTrace.frames.length,
trace: revisionTrace.frames,
},
},
interpreted: { runId: revisionRunId, status: "completed", ...revisionTrace },
};
}
if (event.stage === "completed") {
return {
...event,
id: "revision-5-completed",
reason: "The revision-requested report workflow completed.",
resultingIds,
interpreted: { output: revisionOutput, trace: revisionTrace },
};
}
return { ...event, resultingIds };
});
return decodeDemoRecording({
...recording,
recordingId: "lda-report-revision-v1",
title: "lda.chat report workflow revision requested",
events,
});
};
export const loadCanonicalDemoRecording = (): DemoRecording => { export const loadCanonicalDemoRecording = (): DemoRecording => {
let parsed: unknown; let parsed: unknown;
try { try {
@@ -403,7 +403,7 @@ describe("useDemoTimeline", () => {
expect(result.current.trace?.frames.length).toBeGreaterThan(0); expect(result.current.trace?.frames.length).toBeGreaterThan(0);
}); });
it("replay cancellation stops without consuming submitted branch", async () => { it("replay revision request resumes through the negative branch", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const { result } = renderHook(() => useDemoTimeline(null, vi.fn())); const { result } = renderHook(() => useDemoTimeline(null, vi.fn()));
act(() => result.current.setMode("replay")); act(() => result.current.setMode("replay"));
@@ -412,12 +412,13 @@ describe("useDemoTimeline", () => {
await act(async () => vi.advanceTimersByTimeAsync(900)); await act(async () => vi.advanceTimersByTimeAsync(900));
} }
await act(async () => result.current.cancelReview("Cancelled.")); await act(async () => result.current.requestRevision("Request revisions."));
await act(async () => vi.advanceTimersByTimeAsync(1800));
expect(result.current.state.phase).toBe("cancelled"); expect(result.current.state.phase).toBe("paused");
expect(result.current.state.events[result.current.state.appliedCount - 1]?.stage).toBe("interrupt"); expect(result.current.state.events[result.current.state.appliedCount - 1]?.stage).toBe("run_resume");
expect(result.current.output).toBeNull(); expect(result.current.output?.approved).toBe(false);
expect(result.current.output?.created_issues).toHaveLength(0);
expect(result.current.trace).toBeNull();
}); });
it("missingDeploymentMessage shows when live mode with null target", () => { it("missingDeploymentMessage shows when live mode with null target", () => {
@@ -474,7 +475,7 @@ describe("useDemoTimeline", () => {
expect(result.current.interruptPayload).toBeNull(); expect(result.current.interruptPayload).toBeNull();
}); });
it("live cancellation is terminal and does not advance", async () => { it("live revision request resumes through the negative branch", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
mockedCallOperation mockedCallOperation
.mockResolvedValueOnce({ .mockResolvedValueOnce({
@@ -533,6 +534,41 @@ describe("useDemoTimeline", () => {
exchange: { request: {}, response: {} }, exchange: { request: {}, response: {} },
equivalentCli: "uv run wf run start lda_report_case_study.default --input '<json>'", equivalentCli: "uv run wf run start lda_report_case_study.default --input '<json>'",
durationMs: 88, durationMs: 88,
})
.mockResolvedValueOnce({
ok: true,
operation: "workflow.runs.resume" as const,
label: "Resume run",
interpreted: {
runId: "run_demo",
deploymentId: "lda_report_case_study.default",
artifactId: "lda_report_case_study",
artifactVersion: 1,
status: "completed",
resumeReadiness: "not_applicable",
interrupt: null,
outcome: "cancelled",
error: null,
output: {
approved: false,
markdown: "# Revision Requested",
created_issues: [],
selected_issue_ids: [],
},
diagnostics: [],
traceCount: 9,
nextActions: {
canContinue: false,
canSaveNow: null,
recommendedNextTool: null,
reason: "Run completed after revision was requested.",
patchExamples: [],
warnings: [],
},
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf run resume run_demo --payload '<json>'",
durationMs: 88,
}); });
const { result } = renderHook(() => const { result } = renderHook(() =>
@@ -543,12 +579,12 @@ describe("useDemoTimeline", () => {
await act(async () => vi.advanceTimersByTimeAsync(900)); await act(async () => vi.advanceTimersByTimeAsync(900));
expect(result.current.state.phase).toBe("review"); expect(result.current.state.phase).toBe("review");
await act(async () => result.current.cancelReview("Cancelled.")); await act(async () => result.current.requestRevision("Request revisions."));
await act(async () => vi.advanceTimersByTimeAsync(1800)); await act(async () => result.current.next());
expect(result.current.state.phase).toBe("cancelled"); expect(result.current.state.phase).toBe("paused");
expect(result.current.output).toBeNull(); expect(result.current.output?.approved).toBe(false);
expect(result.current.trace).toBeNull(); expect(result.current.output?.created_issues).toHaveLength(0);
expect(mockedCallOperation).toHaveBeenCalledTimes(2); expect(mockedCallOperation).toHaveBeenCalledTimes(3);
}); });
}); });
+30 -9
View File
@@ -21,7 +21,7 @@ import {
type DemoApproval, type DemoApproval,
type LiveDemoContext, type LiveDemoContext,
} from "./timeline/live.js"; } from "./timeline/live.js";
import { loadCanonicalDemoRecording } from "./timeline/replay.js"; import { loadCanonicalDemoRecording, revisionReplayRecording } from "./timeline/replay.js";
type EvidenceRecorder = (record: EvidenceRecord) => void; type EvidenceRecorder = (record: EvidenceRecord) => void;
@@ -43,7 +43,7 @@ export type DemoTimelineController = {
selectedIssueIds: ReadonlyArray<string>, selectedIssueIds: ReadonlyArray<string>,
comment: string, comment: string,
) => Promise<void>; ) => Promise<void>;
readonly cancelReview: (comment: string) => Promise<void>; readonly requestRevision: (comment: string) => Promise<void>;
readonly restart: () => void; readonly restart: () => void;
readonly primeReplayToStage: (stage: DemoEvent["stage"] | null) => void; readonly primeReplayToStage: (stage: DemoEvent["stage"] | null) => void;
}; };
@@ -86,10 +86,11 @@ export const useDemoTimeline = (
const generationRef = useRef(0); const generationRef = useRef(0);
const [inFlight, setInFlight] = useState(false); const [inFlight, setInFlight] = useState(false);
const approvalRef = useRef<DemoApproval | null>(null); const approvalRef = useRef<DemoApproval | null>(null);
const activeRecording = useRef<DemoRecording | null>(recording ?? null); const canonicalRecording = useRef<DemoRecording | null>(recording ?? null);
if (activeRecording.current === null) { if (canonicalRecording.current === null) {
activeRecording.current = loadCanonicalDemoRecording(); canonicalRecording.current = loadCanonicalDemoRecording();
} }
const activeRecording = useRef<DemoRecording | null>(canonicalRecording.current);
const [interruptPayload, setInterruptPayload] = useState<LdaReportInterruptPayload | null>(null); const [interruptPayload, setInterruptPayload] = useState<LdaReportInterruptPayload | null>(null);
const [output, setOutput] = useState<LdaReportOutput | null>(null); const [output, setOutput] = useState<LdaReportOutput | null>(null);
@@ -101,6 +102,7 @@ export const useDemoTimeline = (
setInFlight(false); setInFlight(false);
liveContextRef.current = initialLiveDemoContext; liveContextRef.current = initialLiveDemoContext;
approvalRef.current = null; approvalRef.current = null;
activeRecording.current = canonicalRecording.current;
setInterruptPayload(null); setInterruptPayload(null);
setOutput(null); setOutput(null);
setTrace(null); setTrace(null);
@@ -285,15 +287,32 @@ export const useDemoTimeline = (
dispatch({ type: "continue_review" }); dispatch({ type: "continue_review" });
}, []); }, []);
const cancelReview = useCallback(async (comment: string) => { const requestRevision = useCallback(async (comment: string) => {
approvalRef.current = { approvalRef.current = {
approved: false, approved: false,
selectedIssueIds: [], selectedIssueIds: [],
comment, comment,
outcome: "cancelled", outcome: "cancelled",
}; };
dispatch({ type: "cancel_review" }); if (target === null) {
}, []); const recording = activeRecording.current;
if (recording) {
const revisionRecording = revisionReplayRecording(recording);
const appliedCount = appliedCountForStage(revisionRecording.events, "run_resume");
resetRuntime();
activeRecording.current = revisionRecording;
projectTransientState(revisionRecording.events, appliedCount);
dispatch({
type: "prime_replay",
events: revisionRecording.events,
appliedCount,
phase: "paused",
});
}
return;
}
dispatch({ type: "continue_review" });
}, [projectTransientState, resetRuntime, target]);
const restart = useCallback(() => { const restart = useCallback(() => {
resetRuntime(); resetRuntime();
@@ -302,6 +321,8 @@ export const useDemoTimeline = (
const primeReplayToStage = useCallback((stage: DemoEvent["stage"] | null) => { const primeReplayToStage = useCallback((stage: DemoEvent["stage"] | null) => {
if (stage === null || state.mode !== "replay") return; if (stage === null || state.mode !== "replay") return;
const isRevisionBranch = state.events.some((event) => event.id === "revision-3-run-resume");
if (isRevisionBranch) return;
const recording = activeRecording.current; const recording = activeRecording.current;
if (!recording) return; if (!recording) return;
const appliedCount = appliedCountForStage(recording.events, stage); const appliedCount = appliedCountForStage(recording.events, stage);
@@ -334,7 +355,7 @@ export const useDemoTimeline = (
play, play,
next, next,
submitSelectedIssues, submitSelectedIssues,
cancelReview, requestRevision,
restart, restart,
primeReplayToStage, primeReplayToStage,
}; };
@@ -35,7 +35,7 @@ const demo: DemoTimelineController = {
play: noop, play: noop,
next: noopAsync, next: noopAsync,
submitSelectedIssues: noopAsync, submitSelectedIssues: noopAsync,
cancelReview: noopAsync, requestRevision: noopAsync,
restart: noop, restart: noop,
primeReplayToStage: noop, primeReplayToStage: noop,
}; };
@@ -192,14 +192,14 @@ describe("DemoWorkflowScene", () => {
approvalActions: { approvalActions: {
state: "ready", state: "ready",
canSubmit: true, canSubmit: true,
canCancel: true, canRequestRevision: true,
submit: vi.fn(async () => {}), submit: vi.fn(async () => {}),
cancel: vi.fn(async () => {}), requestRevision: vi.fn(async () => {}),
}, },
}); });
expect(screen.getByRole("button", { name: "Submit" })).toBeEnabled(); expect(screen.getByRole("button", { name: "Submit" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Cancel" })).toBeEnabled(); expect(screen.getByRole("button", { name: "Request revision" })).toBeEnabled();
}); });
it("shows a factual decision form for the approval beat instead of raw schema as the primary visual", () => { it("shows a factual decision form for the approval beat instead of raw schema as the primary visual", () => {
@@ -38,7 +38,7 @@ const demo = {
play: vi.fn(), play: vi.fn(),
next: vi.fn(), next: vi.fn(),
submitSelectedIssues: vi.fn(), submitSelectedIssues: vi.fn(),
cancelReview: vi.fn(), requestRevision: vi.fn(),
restart: vi.fn(), restart: vi.fn(),
primeReplayToStage: vi.fn(), primeReplayToStage: vi.fn(),
} as unknown as DemoTimelineController; } as unknown as DemoTimelineController;
@@ -63,9 +63,9 @@ describe("GuidedProductMoment", () => {
approvalActions={{ approvalActions={{
state: "ready", state: "ready",
canSubmit: true, canSubmit: true,
canCancel: true, canRequestRevision: true,
submit: vi.fn(async () => {}), submit: vi.fn(async () => {}),
cancel: vi.fn(async () => {}), requestRevision: vi.fn(async () => {}),
}} }}
openEvidence={vi.fn()} openEvidence={vi.fn()}
/>, />,
@@ -109,9 +109,9 @@ describe("GuidedProductMoment", () => {
approvalActions={{ approvalActions={{
state: "ready", state: "ready",
canSubmit: true, canSubmit: true,
canCancel: true, canRequestRevision: true,
submit: vi.fn(async () => {}), submit: vi.fn(async () => {}),
cancel: vi.fn(async () => {}), requestRevision: vi.fn(async () => {}),
}} }}
openEvidence={vi.fn()} openEvidence={vi.fn()}
/>, />,
@@ -134,9 +134,9 @@ describe("GuidedProductMoment", () => {
approvalActions={{ approvalActions={{
state: "ready", state: "ready",
canSubmit: true, canSubmit: true,
canCancel: true, canRequestRevision: true,
submit: vi.fn(async () => {}), submit: vi.fn(async () => {}),
cancel: vi.fn(async () => {}), requestRevision: vi.fn(async () => {}),
}} }}
openEvidence={vi.fn()} openEvidence={vi.fn()}
/>, />,
@@ -156,9 +156,9 @@ describe("GuidedProductMoment", () => {
approvalActions={{ approvalActions={{
state: "ready", state: "ready",
canSubmit: true, canSubmit: true,
canCancel: true, canRequestRevision: true,
submit: vi.fn(async () => {}), submit: vi.fn(async () => {}),
cancel: vi.fn(async () => {}), requestRevision: vi.fn(async () => {}),
}} }}
openEvidence={vi.fn()} openEvidence={vi.fn()}
/>, />,
@@ -180,9 +180,9 @@ describe("GuidedProductMoment", () => {
approvalActions={{ approvalActions={{
state: "ready", state: "ready",
canSubmit: true, canSubmit: true,
canCancel: true, canRequestRevision: true,
submit: vi.fn(async () => {}), submit: vi.fn(async () => {}),
cancel: vi.fn(async () => {}), requestRevision: vi.fn(async () => {}),
}} }}
openEvidence={vi.fn()} openEvidence={vi.fn()}
/>, />,
@@ -35,8 +35,8 @@ const statusCopy = (
): string => { ): string => {
if (moment !== "approval") return "Same persisted run; inspect the proof below."; if (moment !== "approval") return "Same persisted run; inspect the proof below.";
if (approvalActions?.state === "submitted") return "Submitted. Same run resumed."; if (approvalActions?.state === "submitted") return "Submitted. Same run resumed.";
if (approvalActions?.state === "cancelled") { if (approvalActions?.state === "revision_requested") {
return "Cancelled in presentation replay. No resume evidence is shown."; return "Revision requested. The same run resumed through its negative outcome branch.";
} }
return "Run is paused. Submit resumes this same run."; return "Run is paused. Submit resumes this same run.";
}; };
@@ -67,6 +67,9 @@ export const GuidedProductMoment = ({
const lens = demoBeatLensForBeat(beat.id); const lens = demoBeatLensForBeat(beat.id);
const facts = projectDemoRunFacts(demo); const facts = projectDemoRunFacts(demo);
const runResume = demo.state.events.find((event) => event.stage === "run_resume"); const runResume = demo.state.events.find((event) => event.stage === "run_resume");
const headline = moment === "resume" && approvalActions?.state === "revision_requested"
? "The revision request continues the persisted run"
: lens.headline;
return ( return (
<section <section
@@ -80,7 +83,7 @@ export const GuidedProductMoment = ({
> >
<header className="guided-product-moment__header"> <header className="guided-product-moment__header">
<span>{lens.eyebrow}</span> <span>{lens.eyebrow}</span>
<strong>{lens.headline}</strong> <strong>{headline}</strong>
<p>{statusCopy(moment, approvalActions)}</p> <p>{statusCopy(moment, approvalActions)}</p>
</header> </header>
@@ -96,9 +99,9 @@ export const GuidedProductMoment = ({
interrupt={facts.interrupt} interrupt={facts.interrupt}
runId={demo.state.events.find((e) => e.stage === "run_start")?.resultingIds.runId ?? "unknown"} runId={demo.state.events.find((e) => e.stage === "run_start")?.resultingIds.runId ?? "unknown"}
onSubmit={approvalActions?.canSubmit ? (ids, comment) => approvalActions.submit(ids, comment) : undefined} onSubmit={approvalActions?.canSubmit ? (ids, comment) => approvalActions.submit(ids, comment) : undefined}
onCancel={approvalActions?.canCancel ? () => approvalActions.cancel() : undefined} onRequestRevision={approvalActions?.canRequestRevision ? () => approvalActions.requestRevision() : undefined}
terminalOutcome={approvalActions?.state === "submitted" ? "submitted" : terminalOutcome={approvalActions?.state === "submitted" ? "submitted" :
approvalActions?.state === "cancelled" ? "cancelled" : undefined} approvalActions?.state === "revision_requested" ? "revision requested" : undefined}
showReportPreview={false} showReportPreview={false}
/> />
</div> </div>
@@ -56,7 +56,7 @@ export const InterruptContractPreview = ({
runId={contract.runId} runId={contract.runId}
state={approvalActions?.state ?? "ready"} state={approvalActions?.state ?? "ready"}
onSubmit={approvalActions?.canSubmit ? () => void approvalActions.submit() : undefined} onSubmit={approvalActions?.canSubmit ? () => void approvalActions.submit() : undefined}
onCancel={approvalActions?.canCancel ? () => void approvalActions.cancel() : undefined} onRequestRevision={approvalActions?.canRequestRevision ? () => void approvalActions.requestRevision() : undefined}
/> />
) : ( ) : (
<div className="interrupt-contract-preview__details"> <div className="interrupt-contract-preview__details">
@@ -34,7 +34,7 @@ describe("InterruptDecisionForm", () => {
interrupt={interrupt} interrupt={interrupt}
runId="run_recorded_lda_report" runId="run_recorded_lda_report"
onSubmit={vi.fn()} onSubmit={vi.fn()}
onCancel={vi.fn()} onRequestRevision={vi.fn()}
/>, />,
); );
@@ -52,7 +52,7 @@ describe("InterruptDecisionForm", () => {
interrupt={interrupt} interrupt={interrupt}
runId="run_recorded_lda_report" runId="run_recorded_lda_report"
onSubmit={vi.fn()} onSubmit={vi.fn()}
onCancel={vi.fn()} onRequestRevision={vi.fn()}
/>, />,
); );
@@ -69,7 +69,7 @@ describe("InterruptDecisionForm", () => {
interrupt={interrupt} interrupt={interrupt}
runId="run_recorded_lda_report" runId="run_recorded_lda_report"
onSubmit={onSubmit} onSubmit={onSubmit}
onCancel={vi.fn()} onRequestRevision={vi.fn()}
/>, />,
); );
@@ -91,8 +91,8 @@ describe("InterruptDecisionForm", () => {
); );
}); });
it("calls cancel callback without submit", async () => { it("requests revision without submit", async () => {
const onCancel = vi.fn(); const onRequestRevision = vi.fn();
const onSubmit = vi.fn(); const onSubmit = vi.fn();
const user = userEvent.setup(); const user = userEvent.setup();
@@ -101,13 +101,13 @@ describe("InterruptDecisionForm", () => {
interrupt={interrupt} interrupt={interrupt}
runId="run_recorded_lda_report" runId="run_recorded_lda_report"
onSubmit={onSubmit} onSubmit={onSubmit}
onCancel={onCancel} onRequestRevision={onRequestRevision}
/>, />,
); );
await user.click(screen.getByRole("button", { name: /cancel/i })); await user.click(screen.getByRole("button", { name: /request revision/i }));
expect(onCancel).toHaveBeenCalledOnce(); expect(onRequestRevision).toHaveBeenCalledOnce();
expect(onSubmit).not.toHaveBeenCalled(); expect(onSubmit).not.toHaveBeenCalled();
}); });
@@ -117,14 +117,14 @@ describe("InterruptDecisionForm", () => {
interrupt={interrupt} interrupt={interrupt}
runId="run_recorded_lda_report" runId="run_recorded_lda_report"
onSubmit={vi.fn()} onSubmit={vi.fn()}
onCancel={vi.fn()} onRequestRevision={vi.fn()}
terminalOutcome="submitted" terminalOutcome="submitted"
/>, />,
); );
expect(screen.getByText(/submitted/i)).toBeDefined(); expect(screen.getByText(/submitted/i)).toBeDefined();
expect(screen.queryByRole("button", { name: /submit/i })).toBeFalsy(); expect(screen.queryByRole("button", { name: /submit/i })).toBeFalsy();
expect(screen.queryByRole("button", { name: /cancel/i })).toBeFalsy(); expect(screen.queryByRole("button", { name: /request revision/i })).toBeFalsy();
}); });
it("can leave report evidence to the surrounding interrupt panel", () => { it("can leave report evidence to the surrounding interrupt panel", () => {
@@ -133,7 +133,7 @@ describe("InterruptDecisionForm", () => {
interrupt={interrupt} interrupt={interrupt}
runId="run_recorded_lda_report" runId="run_recorded_lda_report"
onSubmit={vi.fn()} onSubmit={vi.fn()}
onCancel={vi.fn()} onRequestRevision={vi.fn()}
showReportPreview={false} showReportPreview={false}
/>, />,
); );
@@ -142,19 +142,19 @@ describe("InterruptDecisionForm", () => {
expect(screen.getAllByRole("checkbox")).toHaveLength(2); expect(screen.getAllByRole("checkbox")).toHaveLength(2);
}); });
it("shows terminal outcome label when state is cancelled", () => { it("shows terminal outcome label when revision is requested", () => {
render( render(
<InterruptDecisionForm <InterruptDecisionForm
interrupt={interrupt} interrupt={interrupt}
runId="run_recorded_lda_report" runId="run_recorded_lda_report"
onSubmit={vi.fn()} onSubmit={vi.fn()}
onCancel={vi.fn()} onRequestRevision={vi.fn()}
terminalOutcome="cancelled" terminalOutcome="revision requested"
/>, />,
); );
expect(screen.getByText(/cancelled/i)).toBeDefined(); expect(screen.getByText(/revision requested/i)).toBeDefined();
expect(screen.queryByRole("button", { name: /submit/i })).toBeFalsy(); expect(screen.queryByRole("button", { name: /submit/i })).toBeFalsy();
expect(screen.queryByRole("button", { name: /cancel/i })).toBeFalsy(); expect(screen.queryByRole("button", { name: /request revision/i })).toBeFalsy();
}); });
}); });
@@ -5,8 +5,8 @@ type InterruptDecisionFormProps = {
readonly interrupt: RunFactsInterrupt; readonly interrupt: RunFactsInterrupt;
readonly runId: string; readonly runId: string;
readonly onSubmit?: ((selectedIssueIds: ReadonlyArray<string>, comment: string) => void) | undefined; readonly onSubmit?: ((selectedIssueIds: ReadonlyArray<string>, comment: string) => void) | undefined;
readonly onCancel?: (() => void) | undefined; readonly onRequestRevision?: (() => void) | undefined;
readonly terminalOutcome?: "submitted" | "cancelled" | undefined; readonly terminalOutcome?: "submitted" | "revision requested" | undefined;
readonly showReportPreview?: boolean; readonly showReportPreview?: boolean;
}; };
@@ -14,7 +14,7 @@ export const InterruptDecisionForm = ({
interrupt, interrupt,
runId, runId,
onSubmit, onSubmit,
onCancel, onRequestRevision,
terminalOutcome, terminalOutcome,
showReportPreview = true, showReportPreview = true,
}: InterruptDecisionFormProps) => { }: InterruptDecisionFormProps) => {
@@ -111,10 +111,10 @@ export const InterruptDecisionForm = ({
<button <button
type="button" type="button"
className="interrupt-decision-form__cancel" className="interrupt-decision-form__cancel"
onClick={() => onCancel?.()} onClick={() => onRequestRevision?.()}
disabled={!onCancel} disabled={!onRequestRevision}
> >
Cancel Request revision
</button> </button>
</div> </div>
</form> </form>
@@ -101,7 +101,7 @@ describe("OperatorChat", () => {
it("renders schema approval surface inside chat approval request", async () => { it("renders schema approval surface inside chat approval request", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onApprove = vi.fn(); const onApprove = vi.fn();
const onDeny = vi.fn(); const onRequestRevision = vi.fn();
const messages: ReadonlyArray<AgentMessage> = [ const messages: ReadonlyArray<AgentMessage> = [
{ {
id: "approval", id: "approval",
@@ -124,14 +124,14 @@ describe("OperatorChat", () => {
}, },
]; ];
render(<OperatorChat state={initialPresentationState} messages={messages} onApprove={onApprove} onDeny={onDeny} />); render(<OperatorChat state={initialPresentationState} messages={messages} onApprove={onApprove} onRequestRevision={onRequestRevision} />);
expect(screen.getByRole("group", { name: /issue review resume/i })).toBeInTheDocument(); expect(screen.getByRole("group", { name: /issue review resume/i })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /submit/i })); await user.click(screen.getByRole("button", { name: /submit/i }));
await user.click(screen.getByRole("button", { name: /cancel/i })); await user.click(screen.getByRole("button", { name: /request revision/i }));
expect(onApprove).toHaveBeenCalledTimes(1); expect(onApprove).toHaveBeenCalledTimes(1);
expect(onDeny).toHaveBeenCalledTimes(1); expect(onRequestRevision).toHaveBeenCalledTimes(1);
}); });
it("falls back to tool card display when approval request has no contract", async () => { it("falls back to tool card display when approval request has no contract", async () => {
@@ -194,7 +194,7 @@ describe("OperatorChat", () => {
runLabel: "Run prepared workflow", runLabel: "Run prepared workflow",
runPreparedWorkflow, runPreparedWorkflow,
submitSelectedIssues: vi.fn(async () => {}), submitSelectedIssues: vi.fn(async () => {}),
cancelReview: vi.fn(async () => {}), requestRevision: vi.fn(async () => {}),
}} }}
/>, />,
); );
@@ -203,10 +203,10 @@ describe("OperatorChat", () => {
expect(runPreparedWorkflow).toHaveBeenCalledTimes(1); expect(runPreparedWorkflow).toHaveBeenCalledTimes(1);
}); });
it("routes schema approval submit and cancel through the timeline agent when present", async () => { it("routes schema approval submit and revision request through the timeline agent when present", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const submitSelectedIssues = vi.fn(async () => {}); const submitSelectedIssues = vi.fn(async () => {});
const cancelReview = vi.fn(async () => {}); const requestRevision = vi.fn(async () => {});
const messages: ReadonlyArray<AgentMessage> = [ const messages: ReadonlyArray<AgentMessage> = [
{ {
id: "approval", id: "approval",
@@ -239,15 +239,15 @@ describe("OperatorChat", () => {
runLabel: "Run prepared workflow", runLabel: "Run prepared workflow",
runPreparedWorkflow: vi.fn(async () => {}), runPreparedWorkflow: vi.fn(async () => {}),
submitSelectedIssues, submitSelectedIssues,
cancelReview, requestRevision,
}} }}
/>, />,
); );
await user.click(screen.getByRole("button", { name: /submit/i })); await user.click(screen.getByRole("button", { name: /submit/i }));
await user.click(screen.getByRole("button", { name: /cancel/i })); await user.click(screen.getByRole("button", { name: /request revision/i }));
expect(submitSelectedIssues).toHaveBeenCalledTimes(1); expect(submitSelectedIssues).toHaveBeenCalledTimes(1);
expect(cancelReview).toHaveBeenCalledTimes(1); expect(requestRevision).toHaveBeenCalledTimes(1);
}); });
it("renders prepared run tool calls as workflow handoffs", () => { it("renders prepared run tool calls as workflow handoffs", () => {
@@ -297,18 +297,18 @@ describe("OperatorChat", () => {
state={initialPresentationState} state={initialPresentationState}
messages={messages} messages={messages}
onApprove={undefined} onApprove={undefined}
onDeny={undefined} onRequestRevision={undefined}
/>, />,
); );
expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Request revision" })).toBeDisabled();
}); });
it("routes approval requests through provided approval callbacks", async () => { it("routes approval requests through provided approval callbacks", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const approve = vi.fn(); const approve = vi.fn();
const deny = vi.fn(); const requestRevision = vi.fn();
const messages: ReadonlyArray<AgentMessage> = [ const messages: ReadonlyArray<AgentMessage> = [
{ {
id: "approval", id: "approval",
@@ -336,14 +336,14 @@ describe("OperatorChat", () => {
state={initialPresentationState} state={initialPresentationState}
messages={messages} messages={messages}
onApprove={approve} onApprove={approve}
onDeny={deny} onRequestRevision={requestRevision}
/>, />,
); );
await user.click(screen.getByRole("button", { name: "Submit" })); await user.click(screen.getByRole("button", { name: "Submit" }));
expect(approve).toHaveBeenCalledOnce(); expect(approve).toHaveBeenCalledOnce();
await user.click(screen.getByRole("button", { name: "Cancel" })); await user.click(screen.getByRole("button", { name: "Request revision" }));
expect(deny).toHaveBeenCalledOnce(); expect(requestRevision).toHaveBeenCalledOnce();
}); });
}); });
@@ -10,7 +10,7 @@ type OperatorChatProps = {
readonly messages?: ReadonlyArray<AgentMessage> | undefined; readonly messages?: ReadonlyArray<AgentMessage> | undefined;
readonly timelineAgent?: TimelineAgentController | undefined; readonly timelineAgent?: TimelineAgentController | undefined;
readonly onApprove?: (() => void) | undefined; readonly onApprove?: (() => void) | undefined;
readonly onDeny?: (() => void) | undefined; readonly onRequestRevision?: (() => void) | undefined;
}; };
const fallbackMessages = (state: PresentationState): ReadonlyArray<AgentMessage> => [ const fallbackMessages = (state: PresentationState): ReadonlyArray<AgentMessage> => [
@@ -30,7 +30,7 @@ const fallbackMessages = (state: PresentationState): ReadonlyArray<AgentMessage>
}, },
]; ];
export const OperatorChat = ({ state, messages, timelineAgent, onApprove, onDeny }: OperatorChatProps) => { export const OperatorChat = ({ state, messages, timelineAgent, onApprove, onRequestRevision }: OperatorChatProps) => {
const visibleMessages = messages && messages.length > 0 const visibleMessages = messages && messages.length > 0
? messages ? messages
: timelineAgent && timelineAgent.messages.length > 0 : timelineAgent && timelineAgent.messages.length > 0
@@ -39,9 +39,9 @@ export const OperatorChat = ({ state, messages, timelineAgent, onApprove, onDeny
const submit = timelineAgent const submit = timelineAgent
? () => { timelineAgent.submitSelectedIssues().catch(console.error); } ? () => { timelineAgent.submitSelectedIssues().catch(console.error); }
: onApprove; : onApprove;
const cancel = timelineAgent const requestRevision = timelineAgent
? () => { timelineAgent.cancelReview().catch(console.error); } ? () => { timelineAgent.requestRevision().catch(console.error); }
: onDeny; : onRequestRevision;
const composition = compositionForState(state); const composition = compositionForState(state);
const presentationSurface = composition.chatTheme === "light" ? "editorial" : "night"; const presentationSurface = composition.chatTheme === "light" ? "editorial" : "night";
return ( return (
@@ -61,7 +61,7 @@ export const OperatorChat = ({ state, messages, timelineAgent, onApprove, onDeny
run: () => void timelineAgent.runPreparedWorkflow(), run: () => void timelineAgent.runPreparedWorkflow(),
} : undefined} } : undefined}
submitApproval={submit} submitApproval={submit}
cancelApproval={cancel} requestRevision={requestRevision}
/> />
</aside> </aside>
); );
@@ -338,27 +338,26 @@ describe("PresentationRoute", () => {
window.dispatchEvent(new HashChangeEvent("hashchange")); window.dispatchEvent(new HashChangeEvent("hashchange"));
expect(await screen.findByRole("button", { name: "Submit" })).toBeEnabled(); expect(await screen.findByRole("button", { name: "Submit" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Cancel" })).toBeEnabled(); expect(screen.getByRole("button", { name: "Request revision" })).toBeEnabled();
}); });
it("cancels Scene 10 approval in replay without applying submitted evidence", async () => { it("requests revision and resumes Scene 10 through the negative branch", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
setReplayMode(); setReplayMode();
window.location.hash = "#scene/typed-human-boundary/approval"; window.location.hash = "#scene/typed-human-boundary/approval";
const { PresentationRoute } = await import("./PresentationRoute.js"); const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />); render(<PresentationRoute />);
const cancelButton = await screen.findByRole("button", { name: "Cancel" }); const revisionButton = await screen.findByRole("button", { name: "Request revision" });
await waitFor(() => expect(cancelButton).toBeEnabled(), { timeout: 10000 }); await waitFor(() => expect(revisionButton).toBeEnabled(), { timeout: 10000 });
await act(async () => { await act(async () => {
await user.click(cancelButton); await user.click(revisionButton);
}); });
expect(screen.queryByRole("button", { name: "Submit" })).not.toBeInTheDocument(); expect(window.location.hash).toBe("#scene/resume-output-evidence/resume");
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); expect(screen.getByLabelText("workflow.runs.resume operation")).toBeInTheDocument();
expect(window.location.hash).toBe("#scene/typed-human-boundary/approval"); expect(screen.getByText(/Revision Requested/i)).toBeInTheDocument();
expect(screen.queryByLabelText("workflow.runs.resume operation")).not.toBeInTheDocument();
}); });
it("opens approval with enabled approval controls immediately after priming", async () => { it("opens approval with enabled approval controls immediately after priming", async () => {
@@ -368,7 +367,7 @@ describe("PresentationRoute", () => {
render(<PresentationRoute />); render(<PresentationRoute />);
expect(await screen.findByRole("button", { name: "Submit" })).toBeEnabled(); expect(await screen.findByRole("button", { name: "Submit" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Cancel" })).toBeEnabled(); expect(screen.getByRole("button", { name: "Request revision" })).toBeEnabled();
}); });
it("opens resume with resume operation proof immediately after priming", async () => { it("opens resume with resume operation proof immediately after priming", async () => {
@@ -181,26 +181,30 @@ export const PresentationRoute = () => {
}); });
}, [demo]); }, [demo]);
const handleCancelApproval = useCallback(async () => { const handleRequestRevision = useCallback(async () => {
if (demo.state.phase !== "review") return; if (demo.state.phase !== "review") return;
setApprovalState("cancelled"); setApprovalState("revision_requested");
await demo.cancelReview("Cancelled by operator."); await demo.requestRevision("Request revisions before creating issues.");
if (presentationTarget.mode === "live") await demo.next();
// The canonical replay only records the submitted branch. Do not call dispatch({
// next() in replay, or the UI would falsely show submitted run evidence. type: "jump",
if (demo.state.mode === "live") { location: {
await demo.next(); kind: "main",
} sceneId: "resume-output-evidence",
beatId: "resume",
focusPath: [],
},
});
}, [demo]); }, [demo]);
const approvalActions = useMemo<DemoApprovalActions>(() => ({ const approvalActions = useMemo<DemoApprovalActions>(() => ({
state: approvalState, state: approvalState,
canSubmit: demo.state.phase === "review" && selectedIssueIdsForDemo(demo.interruptPayload).length > 0, canSubmit: demo.state.phase === "review" && selectedIssueIdsForDemo(demo.interruptPayload).length > 0,
canCancel: demo.state.phase === "review", canRequestRevision: demo.state.phase === "review",
submit: handleSubmitApproval, submit: handleSubmitApproval,
cancel: handleCancelApproval, requestRevision: handleRequestRevision,
}), [approvalState, demo.state.phase, demo.interruptPayload, handleSubmitApproval, handleCancelApproval]); }), [approvalState, demo.state.phase, demo.interruptPayload, handleSubmitApproval, handleRequestRevision]);
useEffect(() => { useEffect(() => {
if (!isApprovalBeat || demo.state.phase !== "review") return; if (!isApprovalBeat || demo.state.phase !== "review") return;
@@ -22,7 +22,7 @@ type PresentationStageProps = {
readonly timelineAgent?: TimelineAgentController | undefined; readonly timelineAgent?: TimelineAgentController | undefined;
readonly approvalActions?: DemoApprovalActions | undefined; readonly approvalActions?: DemoApprovalActions | undefined;
readonly onApprove?: (() => void) | undefined; readonly onApprove?: (() => void) | undefined;
readonly onDeny?: (() => void) | undefined; readonly onRequestRevision?: (() => void) | undefined;
readonly targetStatus: PresentationTargetHealth; readonly targetStatus: PresentationTargetHealth;
readonly jump: (location: MainLocation) => void; readonly jump: (location: MainLocation) => void;
readonly selectNode: (nodeId: string | null) => void; readonly selectNode: (nodeId: string | null) => void;
@@ -40,7 +40,7 @@ export const PresentationStage = ({
timelineAgent, timelineAgent,
approvalActions, approvalActions,
onApprove, onApprove,
onDeny, onRequestRevision,
targetStatus, targetStatus,
jump, jump,
selectNode, selectNode,
@@ -66,7 +66,7 @@ export const PresentationStage = ({
data-scene-view={activeSceneView} data-scene-view={activeSceneView}
> >
<aside className="presentation-stage__chat" aria-label="agent chat region"> <aside className="presentation-stage__chat" aria-label="agent chat region">
<OperatorChat state={state} messages={messages} timelineAgent={timelineAgent} onApprove={onApprove} onDeny={onDeny} /> <OperatorChat state={state} messages={messages} timelineAgent={timelineAgent} onApprove={onApprove} onRequestRevision={onRequestRevision} />
</aside> </aside>
<section className="presentation-stage__primary" aria-label="primary presentation region"> <section className="presentation-stage__primary" aria-label="primary presentation region">
{state.location.kind === "discussion" ? ( {state.location.kind === "discussion" ? (
@@ -32,7 +32,7 @@ const demo: DemoTimelineController = {
play: noop, play: noop,
next: noopAsync, next: noopAsync,
submitSelectedIssues: noopAsync, submitSelectedIssues: noopAsync,
cancelReview: noopAsync, requestRevision: noopAsync,
restart: noop, restart: noop,
primeReplayToStage: noop, primeReplayToStage: noop,
}; };
@@ -7,7 +7,7 @@ afterEach(() => cleanup());
describe("SchemaApprovalSurface", () => { describe("SchemaApprovalSurface", () => {
it("renders explicit schema fields and outcome actions", () => { it("renders explicit schema fields and outcome actions", () => {
const onSubmit = vi.fn(); const onSubmit = vi.fn();
const onCancel = vi.fn(); const onRequestRevision = vi.fn();
render( render(
<SchemaApprovalSurface <SchemaApprovalSurface
@@ -24,7 +24,7 @@ describe("SchemaApprovalSurface", () => {
outcomes={["submitted", "cancelled"]} outcomes={["submitted", "cancelled"]}
runId="run_recorded_lda_report" runId="run_recorded_lda_report"
onSubmit={onSubmit} onSubmit={onSubmit}
onCancel={onCancel} onRequestRevision={onRequestRevision}
/>, />,
); );
@@ -35,9 +35,9 @@ describe("SchemaApprovalSurface", () => {
expect(within(surface).getByText("run_recorded_lda_report")).toBeInTheDocument(); expect(within(surface).getByText("run_recorded_lda_report")).toBeInTheDocument();
fireEvent.click(within(surface).getByRole("button", { name: /submit/i })); fireEvent.click(within(surface).getByRole("button", { name: /submit/i }));
fireEvent.click(within(surface).getByRole("button", { name: /cancel/i })); fireEvent.click(within(surface).getByRole("button", { name: /request revision/i }));
expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onCancel).toHaveBeenCalledTimes(1); expect(onRequestRevision).toHaveBeenCalledTimes(1);
}); });
it("renders payload preview for loose object schemas", () => { it("renders payload preview for loose object schemas", () => {
@@ -56,7 +56,7 @@ describe("SchemaApprovalSurface", () => {
expect(screen.getByText("[\"risk-1\"]")).toBeInTheDocument(); expect(screen.getByText("[\"risk-1\"]")).toBeInTheDocument();
}); });
it("shows submitted and cancelled states without active actions", () => { it("shows submitted and revision-requested states without active actions", () => {
const { rerender } = render( const { rerender } = render(
<SchemaApprovalSurface <SchemaApprovalSurface
title="Issue review resume" title="Issue review resume"
@@ -77,10 +77,10 @@ describe("SchemaApprovalSurface", () => {
payload={{}} payload={{}}
outcomes={["submitted", "cancelled"]} outcomes={["submitted", "cancelled"]}
runId={null} runId={null}
state="cancelled" state="revision_requested"
/>, />,
); );
expect(screen.getByText("Outcome: cancelled")).toBeInTheDocument(); expect(screen.getByText("Outcome: revision requested")).toBeInTheDocument();
}); });
}); });
@@ -6,9 +6,9 @@ export type SchemaApprovalSurfaceProps = {
readonly payload: unknown; readonly payload: unknown;
readonly outcomes: ReadonlyArray<string>; readonly outcomes: ReadonlyArray<string>;
readonly runId: string | null; readonly runId: string | null;
readonly state?: "ready" | "submitted" | "cancelled"; readonly state?: "ready" | "submitted" | "revision_requested";
readonly onSubmit?: (() => void) | undefined; readonly onSubmit?: (() => void) | undefined;
readonly onCancel?: (() => void) | undefined; readonly onRequestRevision?: (() => void) | undefined;
}; };
export const SchemaApprovalSurface = ({ export const SchemaApprovalSurface = ({
@@ -19,10 +19,11 @@ export const SchemaApprovalSurface = ({
runId, runId,
state = "ready", state = "ready",
onSubmit, onSubmit,
onCancel, onRequestRevision,
}: SchemaApprovalSurfaceProps) => { }: SchemaApprovalSurfaceProps) => {
const model = buildSchemaApprovalModel({ schema, payload, outcomes }); const model = buildSchemaApprovalModel({ schema, payload, outcomes });
const isResolved = state !== "ready"; const isResolved = state !== "ready";
const stateLabel = state === "revision_requested" ? "revision requested" : state;
return ( return (
<section className="schema-approval-surface" role="group" aria-label={title} data-state={state}> <section className="schema-approval-surface" role="group" aria-label={title} data-state={state}>
@@ -65,14 +66,14 @@ export const SchemaApprovalSurface = ({
<footer className="schema-approval-surface__actions"> <footer className="schema-approval-surface__actions">
{isResolved ? ( {isResolved ? (
<strong>Outcome: {state}</strong> <strong>Outcome: {stateLabel}</strong>
) : ( ) : (
<> <>
<button type="button" onClick={onSubmit} disabled={!onSubmit}> <button type="button" onClick={onSubmit} disabled={!onSubmit}>
Submit Submit
</button> </button>
<button type="button" onClick={onCancel} disabled={!onCancel}> <button type="button" onClick={onRequestRevision} disabled={!onRequestRevision}>
Cancel Request revision
</button> </button>
</> </>
)} )}
@@ -78,7 +78,7 @@ describe("AgentHandoffScene", () => {
runLabel: "Run prepared workflow", runLabel: "Run prepared workflow",
runPreparedWorkflow, runPreparedWorkflow,
submitSelectedIssues: vi.fn(async () => {}), submitSelectedIssues: vi.fn(async () => {}),
cancelReview: vi.fn(async () => {}), requestRevision: vi.fn(async () => {}),
} as unknown as TimelineAgentController; } as unknown as TimelineAgentController;
render( render(
@@ -60,7 +60,7 @@ describe("AssistantOperatorThread", () => {
it("renders schema approval through the existing approval surface", async () => { it("renders schema approval through the existing approval surface", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const submit = vi.fn(); const submit = vi.fn();
const cancel = vi.fn(); const requestRevision = vi.fn();
const messages: ReadonlyArray<AgentMessage> = [ const messages: ReadonlyArray<AgentMessage> = [
{ {
id: "approval", id: "approval",
@@ -88,15 +88,15 @@ describe("AssistantOperatorThread", () => {
mode="dock" mode="dock"
messages={messages} messages={messages}
submitApproval={submit} submitApproval={submit}
cancelApproval={cancel} requestRevision={requestRevision}
/>, />,
); );
expect(screen.getByRole("group", { name: /issue review resume/i })).toBeInTheDocument(); expect(screen.getByRole("group", { name: /issue review resume/i })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Submit" })); await user.click(screen.getByRole("button", { name: "Submit" }));
await user.click(screen.getByRole("button", { name: "Cancel" })); await user.click(screen.getByRole("button", { name: "Request revision" }));
expect(submit).toHaveBeenCalledOnce(); expect(submit).toHaveBeenCalledOnce();
expect(cancel).toHaveBeenCalledOnce(); expect(requestRevision).toHaveBeenCalledOnce();
}); });
it("renders a chat-owned run action", async () => { it("renders a chat-owned run action", async () => {
@@ -25,7 +25,7 @@ type AssistantOperatorThreadProps = {
readonly messages: ReadonlyArray<AgentMessage>; readonly messages: ReadonlyArray<AgentMessage>;
readonly runAction?: { readonly label: string; readonly disabled: boolean; readonly run: () => void } | undefined; readonly runAction?: { readonly label: string; readonly disabled: boolean; readonly run: () => void } | undefined;
readonly submitApproval?: (() => void) | undefined; readonly submitApproval?: (() => void) | undefined;
readonly cancelApproval?: (() => void) | undefined; readonly requestRevision?: (() => void) | undefined;
readonly ariaLabel?: string | undefined; readonly ariaLabel?: string | undefined;
readonly surface?: "stage" | "dock" | undefined; readonly surface?: "stage" | "dock" | undefined;
readonly activeToolGroupId?: string | undefined; readonly activeToolGroupId?: string | undefined;
@@ -87,7 +87,7 @@ const resultForToolPart = (part: ToolRenderPart): unknown =>
const renderContentPart = ( const renderContentPart = (
part: AssistantContentPart, part: AssistantContentPart,
submitApproval?: (() => void) | undefined, submitApproval?: (() => void) | undefined,
cancelApproval?: (() => void) | undefined, requestRevision?: (() => void) | undefined,
defaultOpen?: boolean, defaultOpen?: boolean,
pairedResult?: Extract<ToolRenderPart, { readonly type: "tool-result" }>, pairedResult?: Extract<ToolRenderPart, { readonly type: "tool-result" }>,
): ReactNode => { ): ReactNode => {
@@ -116,7 +116,7 @@ const renderContentPart = (
outcomes={contract.outcomes} outcomes={contract.outcomes}
runId={contract.runId} runId={contract.runId}
onSubmit={submitApproval} onSubmit={submitApproval}
onCancel={cancelApproval} onRequestRevision={requestRevision}
/> />
</div> </div>
</ToolFallbackContent> </ToolFallbackContent>
@@ -141,14 +141,14 @@ const AssistantMessageBody = ({
openToolGroups, openToolGroups,
setToolGroupOpen, setToolGroupOpen,
submitApproval, submitApproval,
cancelApproval, requestRevision,
}: { }: {
readonly messageId: string; readonly messageId: string;
readonly parts: readonly AssistantContentPart[]; readonly parts: readonly AssistantContentPart[];
readonly openToolGroups: ReadonlySet<string>; readonly openToolGroups: ReadonlySet<string>;
readonly setToolGroupOpen: (groupId: string, open: boolean) => void; readonly setToolGroupOpen: (groupId: string, open: boolean) => void;
readonly submitApproval?: (() => void) | undefined; readonly submitApproval?: (() => void) | undefined;
readonly cancelApproval?: (() => void) | undefined; readonly requestRevision?: (() => void) | undefined;
}) => { }) => {
const rendered: ReactNode[] = []; const rendered: ReactNode[] = [];
let index = 0; let index = 0;
@@ -176,7 +176,7 @@ const AssistantMessageBody = ({
if (logicalTools.length === 1 && !messageId.startsWith("authoring-")) { if (logicalTools.length === 1 && !messageId.startsWith("authoring-")) {
rendered.push( rendered.push(
<div key={`tool-${logicalTools[0]!.toolCallId ?? logicalTools[0]!.toolName}`}> <div key={`tool-${logicalTools[0]!.toolCallId ?? logicalTools[0]!.toolName}`}>
{renderContentPart(logicalTools[0]!, submitApproval, cancelApproval)} {renderContentPart(logicalTools[0]!, submitApproval, requestRevision)}
</div>, </div>,
); );
continue; continue;
@@ -209,7 +209,7 @@ const AssistantMessageBody = ({
: undefined; : undefined;
return ( return (
<div key={`${tool.type}-${tool.toolName}-${tool.toolCallId ?? "no-id"}`}> <div key={`${tool.type}-${tool.toolName}-${tool.toolCallId ?? "no-id"}`}>
{renderContentPart(tool, submitApproval, cancelApproval, false, pairedResult)} {renderContentPart(tool, submitApproval, requestRevision, false, pairedResult)}
</div> </div>
); );
})} })}
@@ -246,7 +246,7 @@ export const AssistantOperatorThread = ({
messages, messages,
runAction, runAction,
submitApproval, submitApproval,
cancelApproval, requestRevision,
ariaLabel = "operator conversation", ariaLabel = "operator conversation",
surface, surface,
activeToolGroupId, activeToolGroupId,
@@ -324,7 +324,7 @@ export const AssistantOperatorThread = ({
openToolGroups={openToolGroups} openToolGroups={openToolGroups}
setToolGroupOpen={setToolGroupOpen} setToolGroupOpen={setToolGroupOpen}
submitApproval={submitApproval} submitApproval={submitApproval}
cancelApproval={cancelApproval} requestRevision={requestRevision}
/> />
</MessageBubble> </MessageBubble>
); );
@@ -1,12 +1,12 @@
export type DemoApprovalUiState = "ready" | "submitted" | "cancelled"; export type DemoApprovalUiState = "ready" | "submitted" | "revision_requested";
export type DemoApprovalActions = { export type DemoApprovalActions = {
readonly state: DemoApprovalUiState; readonly state: DemoApprovalUiState;
readonly canSubmit: boolean; readonly canSubmit: boolean;
readonly canCancel: boolean; readonly canRequestRevision: boolean;
readonly submit: ( readonly submit: (
selectedIssueIds?: ReadonlyArray<string>, selectedIssueIds?: ReadonlyArray<string>,
comment?: string, comment?: string,
) => Promise<void>; ) => Promise<void>;
readonly cancel: () => Promise<void>; readonly requestRevision: () => Promise<void>;
}; };
@@ -38,7 +38,7 @@ const controller = (overrides: Partial<DemoTimelineController> = {}): DemoTimeli
play: vi.fn(), play: vi.fn(),
next: vi.fn(async () => {}), next: vi.fn(async () => {}),
submitSelectedIssues: vi.fn(async () => {}), submitSelectedIssues: vi.fn(async () => {}),
cancelReview: vi.fn(async () => {}), requestRevision: vi.fn(async () => {}),
restart: vi.fn(), restart: vi.fn(),
primeReplayToStage: vi.fn(), primeReplayToStage: vi.fn(),
...overrides, ...overrides,