feat: project factual demo run state
This commit is contained in:
@@ -0,0 +1,135 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
||||||
|
import { initialDemoTimelineState } from "../demo/timeline/reducer.js";
|
||||||
|
import { loadCanonicalDemoRecording } from "../demo/timeline/replay.js";
|
||||||
|
import { formatFactValue, projectDemoRunFacts } from "./demo-run-facts.js";
|
||||||
|
|
||||||
|
const controller = (overrides: Partial<DemoTimelineController> = {}): DemoTimelineController => {
|
||||||
|
const recording = loadCanonicalDemoRecording();
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
...initialDemoTimelineState,
|
||||||
|
mode: "replay",
|
||||||
|
phase: "review",
|
||||||
|
events: recording.events,
|
||||||
|
appliedCount: 3,
|
||||||
|
autoplay: false,
|
||||||
|
},
|
||||||
|
inFlight: false,
|
||||||
|
interruptPayload: {
|
||||||
|
report_markdown: "# lda.chat Thesis And Project Readiness Report\n\nThe workflow substrate is ready for the defense demo.",
|
||||||
|
proposed_issues: [
|
||||||
|
{
|
||||||
|
id: "risk-1",
|
||||||
|
title: "Prepare the defense walkthrough",
|
||||||
|
body: "Review the live and replay paths before the defense.",
|
||||||
|
severity: "medium",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
output: null,
|
||||||
|
trace: null,
|
||||||
|
missingDeploymentMessage: null,
|
||||||
|
recordingId: "lda-report-success-v1",
|
||||||
|
canStart: true,
|
||||||
|
setMode: vi.fn(),
|
||||||
|
start: vi.fn(),
|
||||||
|
pause: vi.fn(),
|
||||||
|
play: vi.fn(),
|
||||||
|
next: vi.fn(async () => {}),
|
||||||
|
submitSelectedIssues: vi.fn(async () => {}),
|
||||||
|
cancelReview: vi.fn(async () => {}),
|
||||||
|
restart: vi.fn(),
|
||||||
|
primeReplayToStage: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("demo-run-facts", () => {
|
||||||
|
it("projects workflow input from run_start params", () => {
|
||||||
|
const facts = projectDemoRunFacts(controller());
|
||||||
|
|
||||||
|
expect(facts.input.selectedDocuments).toEqual([
|
||||||
|
"project-brief.md",
|
||||||
|
"architecture-notes.md",
|
||||||
|
"evaluation-findings.md",
|
||||||
|
"risk-register.md",
|
||||||
|
"roadmap.md",
|
||||||
|
]);
|
||||||
|
expect(facts.input.boardPath).toBe("issue-board.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("projects interrupt payload and state", () => {
|
||||||
|
const facts = projectDemoRunFacts(controller());
|
||||||
|
|
||||||
|
expect(facts.interrupt.kind).toBe("issue_review");
|
||||||
|
expect(facts.interrupt.typed).toBe(true);
|
||||||
|
expect(facts.interrupt.outcomes).toEqual(["submitted", "cancelled"]);
|
||||||
|
expect(facts.interrupt.proposedIssues[0]).toMatchObject({
|
||||||
|
id: "risk-1",
|
||||||
|
title: "Prepare the defense walkthrough",
|
||||||
|
severity: "medium",
|
||||||
|
});
|
||||||
|
expect(facts.interrupt.reportMarkdownPreview).toContain("workflow substrate is ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("projects resume payload and output after resume", () => {
|
||||||
|
const recording = loadCanonicalDemoRecording();
|
||||||
|
const facts = projectDemoRunFacts(controller({
|
||||||
|
state: {
|
||||||
|
...initialDemoTimelineState,
|
||||||
|
mode: "replay",
|
||||||
|
phase: "completed",
|
||||||
|
events: recording.events,
|
||||||
|
appliedCount: 6,
|
||||||
|
autoplay: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(facts.resume.outcome).toBe("submitted");
|
||||||
|
expect(facts.resume.payload).toMatchObject({
|
||||||
|
approved: true,
|
||||||
|
selected_issue_ids: ["risk-1"],
|
||||||
|
comment: "Create the selected issue.",
|
||||||
|
});
|
||||||
|
expect(facts.output.state).toBe("created");
|
||||||
|
if (facts.output.state === "created") {
|
||||||
|
expect(facts.output.createdIssues[0]).toMatchObject({ id: "ISSUE-001" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks output as not created before resume", () => {
|
||||||
|
const facts = projectDemoRunFacts(controller());
|
||||||
|
expect(facts.output.state).toBe("not-created");
|
||||||
|
if (facts.output.state === "not-created") {
|
||||||
|
expect(facts.output.message).toBe("Output not created yet");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("projects trace frames and empty object state accurately", () => {
|
||||||
|
const recording = loadCanonicalDemoRecording();
|
||||||
|
const facts = projectDemoRunFacts(controller({
|
||||||
|
state: {
|
||||||
|
...initialDemoTimelineState,
|
||||||
|
mode: "replay",
|
||||||
|
phase: "completed",
|
||||||
|
events: recording.events,
|
||||||
|
appliedCount: 6,
|
||||||
|
autoplay: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(facts.trace.frames).toHaveLength(3);
|
||||||
|
expect(facts.trace.frames[0]).toMatchObject({
|
||||||
|
nodeId: "list_documents",
|
||||||
|
resolvedInputLabel: "captured as empty object",
|
||||||
|
outputLabel: "captured as empty object",
|
||||||
|
stateChangesLabel: "captured as empty object",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats absent and empty values differently", () => {
|
||||||
|
expect(formatFactValue({}, "not captured in this recording")).toBe("captured as empty object");
|
||||||
|
expect(formatFactValue(undefined, "not captured in this recording")).toBe("not captured in this recording");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
||||||
|
import type { LdaReportOutput } from "../demo/ldaReportDemoModels.js";
|
||||||
|
import type { TraceFrame } from "../lifecycle/models.js";
|
||||||
|
|
||||||
|
export type RunFactsInput = {
|
||||||
|
readonly selectedDocuments: ReadonlyArray<string>;
|
||||||
|
readonly boardPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RunFactsInterrupt = {
|
||||||
|
readonly kind: string;
|
||||||
|
readonly typed: boolean;
|
||||||
|
readonly outcomes: ReadonlyArray<string>;
|
||||||
|
readonly proposedIssues: ReadonlyArray<{
|
||||||
|
readonly id: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly body: string;
|
||||||
|
readonly severity: string;
|
||||||
|
}>;
|
||||||
|
readonly reportMarkdownPreview: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RunFactsResume =
|
||||||
|
| { readonly outcome: "submitted" | "cancelled"; readonly payload: Record<string, unknown> }
|
||||||
|
| { readonly outcome: null; readonly payload: null };
|
||||||
|
|
||||||
|
export type RunFactsOutput =
|
||||||
|
| { readonly state: "not-created"; readonly message: string }
|
||||||
|
| {
|
||||||
|
readonly state: "created";
|
||||||
|
readonly output: LdaReportOutput;
|
||||||
|
readonly createdIssues: ReadonlyArray<{ readonly id: string; readonly title: string; readonly url: string }>;
|
||||||
|
readonly markdownPreview: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RunFactsTraceFrame = {
|
||||||
|
readonly nodeId: string;
|
||||||
|
readonly stepType: string;
|
||||||
|
readonly outcome: string;
|
||||||
|
readonly resolvedInputLabel: string;
|
||||||
|
readonly outputLabel: string;
|
||||||
|
readonly stateChangesLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RunFactsTrace = {
|
||||||
|
readonly frames: ReadonlyArray<RunFactsTraceFrame>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DemoRunFacts = {
|
||||||
|
readonly input: RunFactsInput;
|
||||||
|
readonly interrupt: RunFactsInterrupt;
|
||||||
|
readonly resume: RunFactsResume;
|
||||||
|
readonly output: RunFactsOutput;
|
||||||
|
readonly trace: RunFactsTrace;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_OBJECT_LABEL = "captured as empty object";
|
||||||
|
|
||||||
|
export const formatFactValue = (value: unknown, absentLabel: string): string => {
|
||||||
|
if (value === undefined || value === null) return absentLabel;
|
||||||
|
if (typeof value === "object" && Object.keys(value).length === 0) return EMPTY_OBJECT_LABEL;
|
||||||
|
return JSON.stringify(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const findEvent = (
|
||||||
|
events: ReadonlyArray<{ readonly stage: string }>,
|
||||||
|
stage: string,
|
||||||
|
): typeof events[number] | undefined =>
|
||||||
|
events.find((event) => event.stage === stage);
|
||||||
|
|
||||||
|
const readWorkflowInput = (
|
||||||
|
events: ReadonlyArray<{ readonly stage: string; readonly params: unknown }>,
|
||||||
|
): RunFactsInput => {
|
||||||
|
const runStart = findEvent(events, "run_start") as
|
||||||
|
| { params: { workflow_input?: { selected_documents?: unknown; board_path?: unknown } } }
|
||||||
|
| undefined;
|
||||||
|
const wi = runStart?.params?.workflow_input;
|
||||||
|
return {
|
||||||
|
selectedDocuments: Array.isArray(wi?.selected_documents)
|
||||||
|
? (wi.selected_documents as ReadonlyArray<string>)
|
||||||
|
: [],
|
||||||
|
boardPath: typeof wi?.board_path === "string" ? wi.board_path : "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const readInterruptFacts = (
|
||||||
|
events: ReadonlyArray<{ readonly stage: string; readonly params: unknown; readonly interpreted: unknown }>,
|
||||||
|
interruptPayload: DemoTimelineController["interruptPayload"],
|
||||||
|
): RunFactsInterrupt => {
|
||||||
|
const runStart = findEvent(events, "run_start") as
|
||||||
|
| {
|
||||||
|
interpreted: {
|
||||||
|
interrupt?: { kind?: string; typed?: boolean; outcomes?: unknown };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
const interruptEvent = findEvent(events, "interrupt") as
|
||||||
|
| { interpreted: { outcomes?: unknown } }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
const ri = runStart?.interpreted?.interrupt;
|
||||||
|
const outcomes = Array.isArray(interruptEvent?.interpreted?.outcomes)
|
||||||
|
? (interruptEvent!.interpreted.outcomes as ReadonlyArray<string>)
|
||||||
|
: Array.isArray(ri?.outcomes)
|
||||||
|
? (ri.outcomes as ReadonlyArray<string>)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: typeof ri?.kind === "string" ? ri.kind : "unknown",
|
||||||
|
typed: ri?.typed === true,
|
||||||
|
outcomes,
|
||||||
|
proposedIssues: interruptPayload?.proposed_issues ?? [],
|
||||||
|
reportMarkdownPreview: interruptPayload?.report_markdown ?? "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const readResumeFacts = (
|
||||||
|
events: ReadonlyArray<{ readonly stage: string; readonly params: unknown }>,
|
||||||
|
): RunFactsResume => {
|
||||||
|
const runResume = findEvent(events, "run_resume") as
|
||||||
|
| { params: { resume_payload?: unknown; resume_outcome?: string } }
|
||||||
|
| undefined;
|
||||||
|
if (!runResume) return { outcome: null, payload: null };
|
||||||
|
const outcome =
|
||||||
|
runResume.params.resume_outcome === "submitted" ||
|
||||||
|
runResume.params.resume_outcome === "cancelled"
|
||||||
|
? runResume.params.resume_outcome
|
||||||
|
: null;
|
||||||
|
const payload =
|
||||||
|
typeof runResume.params.resume_payload === "object" &&
|
||||||
|
runResume.params.resume_payload !== null
|
||||||
|
? (runResume.params.resume_payload as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
if (outcome === null) return { outcome: null, payload: null };
|
||||||
|
return { outcome, payload } as RunFactsResume;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readOutputFacts = (
|
||||||
|
events: ReadonlyArray<{ readonly stage: string; readonly interpreted: unknown }>,
|
||||||
|
): RunFactsOutput => {
|
||||||
|
const resumeEvent = findEvent(events, "run_resume") as
|
||||||
|
| { interpreted: { output?: unknown } }
|
||||||
|
| undefined;
|
||||||
|
const completedEvent = findEvent(events, "completed") as
|
||||||
|
| { interpreted: { output?: unknown } }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
const raw = resumeEvent?.interpreted?.output ?? completedEvent?.interpreted?.output;
|
||||||
|
if (!raw || typeof raw !== "object") {
|
||||||
|
return { state: "not-created", message: "Output not created yet" };
|
||||||
|
}
|
||||||
|
const output = raw as LdaReportOutput;
|
||||||
|
return {
|
||||||
|
state: "created",
|
||||||
|
output,
|
||||||
|
createdIssues: output.created_issues ?? [],
|
||||||
|
markdownPreview: output.markdown ?? "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRecord = (record: Record<string, unknown>, absentLabel: string): string =>
|
||||||
|
formatFactValue(record, absentLabel);
|
||||||
|
|
||||||
|
const readTraceFacts = (
|
||||||
|
events: ReadonlyArray<{ readonly stage: string; readonly interpreted: unknown }>,
|
||||||
|
): RunFactsTrace => {
|
||||||
|
const traceEvent = findEvent(events, "trace_read") as
|
||||||
|
| { interpreted: { frames?: unknown } }
|
||||||
|
| undefined;
|
||||||
|
const completedEvent = findEvent(events, "completed") as
|
||||||
|
| { interpreted: { trace?: { frames?: unknown } } }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
const rawFrames =
|
||||||
|
traceEvent?.interpreted?.frames ??
|
||||||
|
completedEvent?.interpreted?.trace?.frames;
|
||||||
|
|
||||||
|
if (!Array.isArray(rawFrames)) return { frames: [] };
|
||||||
|
|
||||||
|
return {
|
||||||
|
frames: rawFrames.map((frame: TraceFrame) => ({
|
||||||
|
nodeId: frame.nodeId,
|
||||||
|
stepType: frame.stepType,
|
||||||
|
outcome: frame.outcome,
|
||||||
|
resolvedInputLabel: formatRecord(frame.resolvedInput, "not captured in this recording"),
|
||||||
|
outputLabel: formatRecord(frame.output, "not captured in this recording"),
|
||||||
|
stateChangesLabel: formatRecord(frame.stateChanges, "not captured in this recording"),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const projectDemoRunFacts = (demo: DemoTimelineController): DemoRunFacts => {
|
||||||
|
const appliedEvents = demo.state.events.slice(0, demo.state.appliedCount);
|
||||||
|
|
||||||
|
return {
|
||||||
|
input: readWorkflowInput(appliedEvents),
|
||||||
|
interrupt: readInterruptFacts(appliedEvents, demo.interruptPayload),
|
||||||
|
resume: readResumeFacts(appliedEvents),
|
||||||
|
output: readOutputFacts(appliedEvents),
|
||||||
|
trace: readTraceFacts(appliedEvents),
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user