fix: resume workflow on revision requests
This commit is contained in:
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user