feat: connect prepared run and refine presenter notes

This commit is contained in:
lda
2026-07-14 08:59:32 +07:00 Verified
parent 5ba9e55f92
commit 184e41858c
26 changed files with 296 additions and 114 deletions
@@ -2,6 +2,7 @@ import { AssistantOperatorThread } from "../chat/AssistantOperatorThread.js";
import {
authoringToolGroupId,
projectPreparedAuthoringThread,
projectPreparedRunRequestExchange,
type AuthoringPhaseId,
} from "./authoring-recording.js";
import {
@@ -17,6 +18,7 @@ type AuthoringConversationProps = {
readonly requestOverride?: string | undefined;
readonly requestOverrides?: PreparedLifecycleSubmittedOverrides | undefined;
readonly scrollMode?: "active" | "start" | undefined;
readonly runRequested?: string | null | undefined;
};
/** Renders the same prepared conversation at full-stage or compact-dock scale. */
@@ -27,17 +29,25 @@ export const AuthoringConversation = ({
requestOverride,
requestOverrides,
scrollMode,
}: AuthoringConversationProps) => (
<AssistantOperatorThread
mode={surface === "stage" ? "full" : "dock"}
surface={surface}
messages={projectPreparedAuthoringThread(
runRequested,
}: AuthoringConversationProps) => {
const messages = [
...projectPreparedAuthoringThread(
recordingPhaseForStep(throughPhase),
requestOverride,
requestOverrides,
)}
activeToolGroupId={authoringToolGroupId(recordingPhaseForStep(activePhase))}
scrollMode={scrollMode}
ariaLabel="prepared authoring conversation"
/>
);
),
...(runRequested ? projectPreparedRunRequestExchange(runRequested) : []),
];
return (
<AssistantOperatorThread
mode={surface === "stage" ? "full" : "dock"}
surface={surface}
messages={messages}
activeToolGroupId={authoringToolGroupId(recordingPhaseForStep(activePhase))}
scrollMode={runRequested ? "end" : scrollMode}
ariaLabel="prepared authoring conversation"
/>
);
};
@@ -7,7 +7,12 @@ import { PreparedAuthoringLifecycleScene } from "./PreparedAuthoringLifecycleSce
afterEach(() => cleanup());
const renderBeat = (beatId: string, onAdvance?: () => void, discussionRail?: ReactNode) => {
const renderBeat = (
beatId: string,
onAdvance?: () => void,
discussionRail?: ReactNode,
onRunPreparedWorkflow?: () => Promise<void>,
) => {
const scene = findScene("prepared-lifecycle");
const beat = findBeat("prepared-lifecycle", beatId);
if (!scene || !beat) throw new Error(`missing prepared-lifecycle/${beatId}`);
@@ -16,6 +21,7 @@ const renderBeat = (beatId: string, onAdvance?: () => void, discussionRail?: Rea
scene={scene}
beat={beat}
onAdvance={onAdvance}
onRunPreparedWorkflow={onRunPreparedWorkflow}
discussionRail={discussionRail}
/>,
);
@@ -173,6 +179,21 @@ describe("PreparedAuthoringLifecycleScene", () => {
expect(onAdvance).toHaveBeenCalledTimes(advances ? 1 : 0);
});
it("answers the terminal run request without advancing the presentation", async () => {
const user = userEvent.setup();
const onAdvance = vi.fn();
const onRunPreparedWorkflow = vi.fn().mockResolvedValue(undefined);
renderBeat("deployment", onAdvance, undefined, onRunPreparedWorkflow);
await user.click(screen.getByRole("button", { name: /send message/i }));
expect(screen.getByRole("textbox", { name: /message to authoring assistant/i })).toHaveValue("");
expect(screen.getByRole("button", { name: /run.*1 tool call/i })).toBeInTheDocument();
expect(screen.getByText(/three proposed issues are ready for review/i)).toBeInTheDocument();
expect(onRunPreparedWorkflow).toHaveBeenCalledOnce();
expect(onAdvance).not.toHaveBeenCalled();
});
it.each([
["discover", "Discover"],
["draft", "Draft"],
@@ -18,6 +18,7 @@ type PreparedAuthoringLifecycleSceneProps = {
readonly scene: SceneDefinition;
readonly beat: SceneBeatDefinition;
readonly onAdvance?: (() => void) | undefined;
readonly onRunPreparedWorkflow?: (() => Promise<void>) | undefined;
readonly discussionRail?: ReactNode;
};
@@ -40,7 +41,7 @@ const steps = [
* Each beat shows a persistent prepared assistant beside one dominant phase
* projection sourced from the prepared authoring recording.
*/
export const PreparedAuthoringLifecycleScene = ({ scene, beat, onAdvance, discussionRail }: PreparedAuthoringLifecycleSceneProps) => {
export const PreparedAuthoringLifecycleScene = ({ scene, beat, onAdvance, onRunPreparedWorkflow, discussionRail }: PreparedAuthoringLifecycleSceneProps) => {
const [messageState, dispatch] = useReducer(
preparedLifecycleMessageReducer,
initialPreparedLifecycleMessageState,
@@ -81,7 +82,12 @@ export const PreparedAuthoringLifecycleScene = ({ scene, beat, onAdvance, discus
dispatch({ type: "artifact_submitted" });
onAdvance?.();
}
if (step === "deployment") dispatch({ type: "run_requested" });
if (step === "deployment") {
dispatch({ type: "run_requested" });
// This is the same mode-aware action as the footer control; the
// presentation remains on this beat so the presenter advances it.
void onRunPreparedWorkflow?.();
}
}}
/>
<div className="prepared-lifecycle-scene__presentation">
@@ -41,7 +41,8 @@ describe("PresentationAssistantPane", () => {
);
expect(screen.getByRole("heading", { name: /authoring assistant/i })).toBeInTheDocument();
expect(screen.getByText(/current phase: validate/i)).toBeInTheDocument();
expect(screen.getByText(/prepared replay only/i)).toBeInTheDocument();
expect(screen.getByText(/prepared assistant transcript/i)).toBeInTheDocument();
expect(screen.getByText(/final run request uses the configured demo target/i)).toBeInTheDocument();
});
it("exposes an explicit support role when composed in the lifecycle scene", () => {
@@ -129,6 +130,15 @@ describe("PresentationAssistantPane", () => {
cleanup();
renderPane("deployment", { runRequested: "Run this deployment" });
expect(screen.getByRole("button", { name: /send message/i })).toBeDisabled();
expect(screen.getByText("Run this deployment")).toBeInTheDocument();
expect(screen.getByText(/starting lda_report_case_study\.default with the prepared input/i))
.toBeInTheDocument();
const runTools = screen.getByRole("button", { name: /run.*1 tool call/i });
expect(runTools).toHaveAttribute("aria-expanded", "false");
await user.click(runTools);
await user.click(screen.getByRole("button", { name: /startRun/i }));
expect(screen.getByText(/run_recorded_lda_report/i)).toBeInTheDocument();
expect(screen.getByText(/paused at the typed issue-review boundary/i)).toBeInTheDocument();
});
it("renders submitted overrides while keeping the replay tools canonical", () => {
@@ -58,7 +58,10 @@ export const PresentationAssistantPane = ({
};
const { submit, handleKeyDown } = usePreparedComposerSubmit(
canSubmit,
() => onSubmit(draft),
() => {
onSubmit(draft);
setDraft("");
},
);
return (
@@ -74,7 +77,7 @@ export const PresentationAssistantPane = ({
<h2>Authoring assistant</h2>
<p>Current phase: {phaseLabels[phase]}</p>
<p className="presentation-assistant-pane__disclosure">
Prepared replay only. No live actions or RPC calls.
Prepared assistant transcript. The final run request uses the configured demo target.
</p>
</header>
<div className="presentation-assistant-pane__conversation">
@@ -83,6 +86,7 @@ export const PresentationAssistantPane = ({
activePhase={phase}
surface="stage"
requestOverrides={submittedOverrides}
runRequested={runRequested}
/>
</div>
<form className="presentation-assistant-pane__composer" onSubmit={submit}>
@@ -106,7 +110,7 @@ export const PresentationAssistantPane = ({
</p>
{runRequested !== null ? (
<p role="status" className="presentation-assistant-pane__run-status">
Run request prepared for the next execution slice.
Prepared run receipt added. Continue when you are ready.
</p>
) : null}
</form>
@@ -4,6 +4,7 @@ import {
authoringToolGroupId,
projectPreparedAuthoring,
projectPreparedAuthoringThread,
projectPreparedRunRequestExchange,
type AuthoringPhaseId,
} from "./authoring-recording.js";
@@ -225,3 +226,27 @@ describe("projectPreparedAuthoringThread", () => {
]);
});
});
describe("projectPreparedRunRequestExchange", () => {
it("projects the prepared start receipt and typed interrupt without navigation actions", () => {
const messages = projectPreparedRunRequestExchange("Run the saved deployment.");
const parts = messages.flatMap((message) => message.parts);
expect(messages[0]).toMatchObject({ role: "user", parts: [{ text: "Run the saved deployment." }] });
expect(parts).toContainEqual(expect.objectContaining({
type: "tool-call",
call: expect.objectContaining({ name: "startRun" }),
}));
expect(parts).toContainEqual(expect.objectContaining({
type: "tool-result",
result: expect.objectContaining({
output: expect.objectContaining({
run_id: "run_recorded_lda_report",
status: "interrupted",
resume_readiness: "ready",
}),
}),
}));
expect(parts.some((part) => part.type === "presentation-action")).toBe(false);
});
});
@@ -354,5 +354,62 @@ export const projectPreparedAuthoringThread = (
});
};
/**
* Projects the terminal prepared request into the same chat vocabulary as a
* live run, while retaining the stable replay run identity used by later scenes.
*/
export const projectPreparedRunRequestExchange = (
request: string,
): readonly AgentMessage[] => {
const callId = "authoring-run-request-call";
return [
agentTextMessage("authoring-run-request-user", "user", request),
agentTextMessage(
"authoring-run-request-starting",
"assistant",
"Starting lda_report_case_study.default with the prepared input.",
),
{
// The authoring prefix reuses the compact phase tool-group treatment;
// presenters can expand the real-shaped receipt only when useful.
id: "authoring-run-tools",
role: "assistant",
parts: [
agentToolCallPart(callId, "startRun", {
operation: "workflow.runs.start",
deployment_id: "lda_report_case_study.default",
input_file: "examples/lda_report_workflow/run-input.json",
equivalent_cli:
"uv run wf --url http://127.0.0.1:8765/rpc run start lda_report_case_study.default --input-file examples/lda_report_workflow/run-input.json",
}),
agentToolResultPart(callId, "startRun", "success", {
artifact_id: "lda_report_case_study",
artifact_version: 1,
deployment_id: "lda_report_case_study.default",
diagnostics: [],
interrupt: {
id: "interrupt:review_issues",
kind: "issue_review",
node_id: "review_issues",
outcomes: ["submitted", "cancelled"],
proposed_issue_count: 3,
resumable: true,
typed: true,
},
resume_readiness: "ready",
run_id: "run_recorded_lda_report",
status: "interrupted",
trace_count: 6,
}),
],
},
agentTextMessage(
"authoring-run-request-paused",
"assistant",
"The run started and paused at the typed issue-review boundary. Three proposed issues are ready for review.",
),
];
};
/** Returns the full prepared authoring recording. */
export const projectPreparedAuthoring = (): readonly PreparedAuthoringPhase[] => recording;
@@ -43,14 +43,14 @@ describe("prepared lifecycle staged message state", () => {
});
});
it("preserves edited draft text exactly when submitting draft", () => {
it("preserves submitted text exactly and clears the composer draft", () => {
const edited = preparedLifecycleMessageReducer(initialPreparedLifecycleMessageState, {
type: "draft_edited",
draft: " Check only the report binding. ",
});
expect(preparedLifecycleMessageReducer(edited, { type: "draft_submitted" })).toEqual({
draft: edited.draft,
draft: "",
submittedOverrides: { validate: edited.draft },
runRequested: null,
});
@@ -63,7 +63,7 @@ describe("prepared lifecycle staged message state", () => {
});
expect(preparedLifecycleMessageReducer(state, { type: "artifact_submitted" })).toEqual({
draft: state.draft,
draft: "",
submittedOverrides: { deployment: state.draft },
runRequested: null,
});
@@ -76,7 +76,7 @@ describe("prepared lifecycle staged message state", () => {
});
expect(preparedLifecycleMessageReducer(state, { type: "discover_submitted" })).toEqual({
draft: state.draft,
draft: "",
submittedOverrides: { discover: state.draft },
runRequested: null,
});
@@ -106,6 +106,7 @@ describe("prepared lifecycle staged message state", () => {
const requested = preparedLifecycleMessageReducer(state, { type: "run_requested" });
expect(requested.runRequested).toBe("Run this deployment");
expect(requested.draft).toBe("");
expect(requested.submittedOverrides).toEqual({});
expect(preparedLifecycleMessageReducer(requested, { type: "run_requested" })).toBe(requested);
});
@@ -57,6 +57,9 @@ const submitOverride = (
return {
...state,
// The submitted text is preserved in the transcript override; leaving it
// in the composer would leak the previous phase's request into the next.
draft: "",
submittedOverrides: {
...state.submittedOverrides,
[destination]: state.draft,
@@ -79,7 +82,7 @@ export const preparedLifecycleMessageReducer = (
return submitOverride(state, "deployment");
case "run_requested":
return state.runRequested === null && hasText(state.draft)
? { ...state, runRequested: state.draft }
? { ...state, draft: "", runRequested: state.draft }
: state;
}
};
@@ -6,7 +6,7 @@ type PreparedComposerSubmitHandlers = {
};
export const PREPARED_COMPOSER_HELP =
"Shift+Enter adds a new line. This is a deterministic prepared replay, not a live model request.";
"Shift+Enter adds a new line. Responses are prepared; the final run request may use the configured workflow target.";
/** Shares the prepared-replay composer keyboard and form submission contract. */
export const usePreparedComposerSubmit = (