feat: finish continuous authoring workspace

This commit is contained in:
lda
2026-07-11 08:35:29 +07:00 Verified
parent c76be621d9
commit ce90b6b8a9
13 changed files with 168 additions and 77 deletions
+7 -5
View File
@@ -1,4 +1,6 @@
import type { AgentToolName } from "./tools.js";
import type { AgentToolName, PreparedAuthoringToolName } from "./tools.js";
export type AgentMessageToolName = AgentToolName | PreparedAuthoringToolName;
export type AgentRole = "user" | "assistant";
@@ -9,13 +11,13 @@ export type PresentationToolAction =
export type AgentToolCall = {
readonly id: string;
readonly name: AgentToolName;
readonly name: AgentMessageToolName;
readonly input: unknown;
};
export type AgentToolResult = {
readonly callId: string;
readonly name: AgentToolName;
readonly name: AgentMessageToolName;
readonly status: "success" | "failure";
readonly output: unknown;
};
@@ -56,7 +58,7 @@ export const agentTextMessage = (id: string, role: AgentRole, text: string): Age
export const agentToolCallPart = (
id: string,
name: AgentToolName,
name: AgentMessageToolName,
input: unknown,
): AgentMessagePart => ({
type: "tool-call",
@@ -65,7 +67,7 @@ export const agentToolCallPart = (
export const agentToolResultPart = (
callId: string,
name: AgentToolName,
name: AgentMessageToolName,
status: "success" | "failure",
output: unknown,
): AgentMessagePart => ({
+17
View File
@@ -5,6 +5,23 @@ export type WorkflowToolName =
| "readRunTrace"
| "runWorkflowCommand";
/** Names emitted by the prepared authoring recording for its operation trace. */
export type PreparedAuthoringToolName =
| "workflow.sources.list"
| "workflow.capabilities.list"
| "workflow.capabilities.inspect"
| "wf schema"
| "workflow.draft_workspaces.create_from_capability"
| "workflow.draft_workspaces.add_step_from_capability"
| "workflow.draft_workspaces.get"
| "workflow.draft_workspaces.validate"
| "workflow.draft_workspaces.set_step_output_map"
| "workflow.draft_workspaces.compile"
| "workflow.draft_workspaces.create_artifact"
| "workflow.artifacts.inspect"
| "workflow.deployments.save"
| "workflow.deployments.validate";
export type PresentationToolName =
| "selectWorkflowNode"
| "focusOperation"
@@ -66,9 +66,9 @@ describe("PresentationRoute", () => {
window.location.hash = "#scene/agent-handoff/request";
const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />);
expect(screen.getByRole("heading", { name: /Agent Handoff/i })).toBeInTheDocument();
expect(screen.getByRole("log", { name: "prepared authoring conversation" })).toBeInTheDocument();
await userEvent.keyboard("{ArrowRight}");
expect(await screen.findByText(/The interface delegates durable work to lda\.chat/i)).toBeInTheDocument();
expect(await screen.findByRole("button", { name: /deployment.*2 tool calls/i })).toBeInTheDocument();
});
it("renders audience progress chrome without rail or mode label", async () => {
@@ -373,8 +373,8 @@ describe("PresentationRoute", () => {
const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />);
expect(await screen.findByRole("heading", { name: "Agent Handoff" })).toBeInTheDocument();
expect(screen.getByText(/A thin agent interface receives the report request/)).toBeInTheDocument();
expect(await screen.findByRole("log", { name: "prepared authoring conversation" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /discover.*4 tool calls/i })).toBeInTheDocument();
});
it("navigates to Scene 8 handoff beat via hash", async () => {
@@ -382,8 +382,8 @@ describe("PresentationRoute", () => {
const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />);
expect(await screen.findByRole("heading", { name: "Agent Handoff" })).toBeInTheDocument();
expect(screen.getByText(/The interface delegates durable work to lda\.chat/)).toBeInTheDocument();
expect(await screen.findByRole("log", { name: "prepared authoring conversation" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /deployment.*2 tool calls/i })).toBeInTheDocument();
});
it.each([
@@ -36,7 +36,7 @@ describe("AgentHandoffScene", () => {
it("interleaves prepared workflow tool groups with the handoff conversation", () => {
renderBeat("handoff");
expect(screen.getAllByText(/runWorkflowCommand/i).length).toBeGreaterThan(0);
expect(screen.getAllByText(/workflow\.deployments\.save/i).length).toBeGreaterThan(0);
expect(screen.getByRole("button", { name: /deployment.*2 tool calls/i }))
.toHaveAttribute("aria-expanded", "true");
});
@@ -16,7 +16,7 @@ describe("AuthoringConversation", () => {
expect(screen.getByRole("log", { name: "prepared authoring conversation" }))
.toHaveAttribute("data-surface", "stage");
expect(screen.getAllByText(/runWorkflowCommand/i).length).toBeGreaterThan(0);
expect(screen.getAllByText(/workflow\.deployments\.save/i).length).toBeGreaterThan(0);
expect(screen.getAllByText(/deployment/i).length).toBeGreaterThan(0);
});
@@ -15,7 +15,7 @@ type AuthoringPhaseVisualProps = {
};
const InventoryVisual = ({ visual }: { visual: Extract<AuthoringPhaseProjection["visual"], { kind: "inventory" }> }) => (
<section className="authoring-visual authoring-visual--inventory" role="region" aria-label="discovery evidence">
<section className="authoring-visual authoring-visual--inventory" aria-label="discovery evidence">
<div className="authoring-inventory__sources">
{visual.sources.map((source) => (
<div key={source}><Database aria-hidden="true" /><code>{source}</code></div>
@@ -31,7 +31,7 @@ const InventoryVisual = ({ visual }: { visual: Extract<AuthoringPhaseProjection[
);
const GraphVisual = ({ visual }: { visual: Extract<AuthoringPhaseProjection["visual"], { kind: "graph" }> }) => (
<section className="authoring-visual authoring-visual--graph" role="region" aria-label="draft graph evidence">
<section className="authoring-visual authoring-visual--graph" aria-label="draft graph evidence">
<div className="authoring-graph__node"><Database aria-hidden="true" /><strong>{visual.nodes[0]}</strong></div>
<div className="authoring-graph__edge"><span>{visual.inputBinding}</span><ArrowRight aria-hidden="true" /></div>
<div className="authoring-graph__node"><Workflow aria-hidden="true" /><strong>{visual.nodes[1]}</strong></div>
@@ -41,7 +41,7 @@ const GraphVisual = ({ visual }: { visual: Extract<AuthoringPhaseProjection["vis
);
const RepairVisual = ({ visual }: { visual: Extract<AuthoringPhaseProjection["visual"], { kind: "repair" }> }) => (
<section className="authoring-visual authoring-visual--repair" role="region" aria-label="validation repair evidence">
<section className="authoring-visual authoring-visual--repair" aria-label="validation repair evidence">
<div className="authoring-repair__diagnostic"><AlertTriangle aria-hidden="true" /><span>Diagnostic</span><strong>{visual.diagnostic}</strong></div>
<ArrowRight aria-hidden="true" />
<div className="authoring-repair__correction"><Link2 aria-hidden="true" /><span>Repair</span><code>{visual.correction}</code></div>
@@ -50,7 +50,7 @@ const RepairVisual = ({ visual }: { visual: Extract<AuthoringPhaseProjection["vi
);
const ArtifactVisual = ({ visual }: { visual: Extract<AuthoringPhaseProjection["visual"], { kind: "artifact" }> }) => (
<section className="authoring-visual authoring-visual--artifact" role="region" aria-label="artifact evidence">
<section className="authoring-visual authoring-visual--artifact" aria-label="artifact evidence">
<LockKeyhole aria-hidden="true" />
<div><span>Immutable workflow artifact</span><strong>{visual.artifactId}</strong></div>
<dl><div><dt>Version</dt><dd>Version {visual.version}</dd></div><div><dt>Requirements</dt><dd>{visual.requiredSources} local sources</dd></div></dl>
@@ -58,7 +58,7 @@ const ArtifactVisual = ({ visual }: { visual: Extract<AuthoringPhaseProjection["
);
const BindingsVisual = ({ visual }: { visual: Extract<AuthoringPhaseProjection["visual"], { kind: "bindings" }> }) => (
<section className="authoring-visual authoring-visual--bindings" role="region" aria-label="deployment binding evidence">
<section className="authoring-visual authoring-visual--bindings" aria-label="deployment binding evidence">
<header><Link2 aria-hidden="true" /><div><span>Deployment</span><strong>{visual.deploymentId}</strong></div><b><CheckCircle2 aria-hidden="true" />{visual.status}</b></header>
<div className="authoring-bindings__rows">
{visual.bindings.map((binding) => (
@@ -67,6 +67,29 @@ describe("projectPreparedAuthoring", () => {
}
});
it("labels each prepared command with its actual operation name", () => {
const titles = projectPreparedAuthoring()
.flatMap((phase) => phase.commands)
.map((command) => command.title);
expect(titles).toEqual([
"workflow.sources.list",
"workflow.capabilities.list",
"workflow.capabilities.inspect",
"wf schema",
"workflow.draft_workspaces.create_from_capability",
"workflow.draft_workspaces.add_step_from_capability",
"workflow.draft_workspaces.get",
"workflow.draft_workspaces.validate",
"workflow.draft_workspaces.set_step_output_map",
"workflow.draft_workspaces.compile",
"workflow.draft_workspaces.create_artifact",
"workflow.artifacts.inspect",
"workflow.deployments.save",
"workflow.deployments.validate",
]);
});
it("starts every command with wf or uv run wf", () => {
const phases = projectPreparedAuthoring();
for (const phase of phases) {
@@ -118,7 +141,12 @@ describe("projectPreparedAuthoringThread", () => {
expect(calls).toHaveLength(projectPreparedAuthoring()[0]!.commands.length);
expect(results).toHaveLength(calls.length);
expect(calls.every((part) => part.call.name === "runWorkflowCommand")).toBe(true);
expect(calls.map((part) => part.call.name)).toEqual([
"workflow.sources.list",
"workflow.capabilities.list",
"workflow.capabilities.inspect",
"wf schema",
]);
});
it("uses a stable phase tool-group id", () => {
@@ -13,6 +13,7 @@ import {
agentToolResultPart,
type AgentMessage,
} from "../../demo/agent/events.js";
import type { PreparedAuthoringToolName } from "../../demo/agent/tools.js";
export type AuthoringPhaseId =
| "discover"
@@ -24,6 +25,8 @@ export type AuthoringPhaseId =
export type CommandResult = "success" | "diagnostic";
export type PreparedAuthoringCommand = {
/** JSON-RPC method, or the explicit local CLI label for non-RPC commands. */
readonly title: PreparedAuthoringToolName;
readonly command: string;
readonly summary: string;
readonly result: CommandResult;
@@ -70,24 +73,28 @@ const recording: readonly PreparedAuthoringPhase[] = [
label: "Discover",
commands: [
{
title: "workflow.sources.list",
command: "wf source list",
summary: "List available capability sources",
result: "success",
detail: "6 sources: local.lda_docs, local.lda_report, local.issue_board, and platform helpers.",
},
{
title: "workflow.capabilities.list",
command: "wf cap list --source local.lda_report --format ids",
summary: "List report workflow capabilities",
result: "success",
detail: "5 capabilities: analyze_documents, build_report, create_issue_drafts, finalise_report, record_revision_request.",
},
{
title: "workflow.capabilities.inspect",
command: "wf cap inspect local.lda_report.analyze_documents",
summary: "Inspect the report-analysis contract",
result: "success",
detail: "Input: documents. Output: analysis. Declared outcome: ok.",
},
{
title: "wf schema",
command: "wf schema",
summary: "List registered workflow schemas",
result: "success",
@@ -116,17 +123,20 @@ const recording: readonly PreparedAuthoringPhase[] = [
label: "Draft",
commands: [
{
title: "workflow.draft_workspaces.create_from_capability",
command: "wf draft create lda_report_workflow --capability local.lda_docs.read_documents",
summary: "Create a new workflow draft",
result: "success",
detail: "Draft 'lda_report_workflow' created with ID draft_a1b2c3",
},
{
title: "workflow.draft_workspaces.add_step_from_capability",
command: "wf draft add-step lda_report_workflow --revision 1 --step analyze --capability local.lda_report.analyze_documents --from-step read_documents --from-outcome ok --route ok=__end__ --input state.documents=documents",
summary: "Add the report-analysis step with a declared route",
result: "success",
},
{
title: "workflow.draft_workspaces.get",
command: "wf draft inspect lda_report_workflow --include-draft",
summary: "Inspect the prepared draft graph",
result: "success",
@@ -151,12 +161,14 @@ const recording: readonly PreparedAuthoringPhase[] = [
label: "Validate",
commands: [
{
title: "workflow.draft_workspaces.validate",
command: "wf draft validate lda_report_workflow",
summary: "Validate the workflow draft",
result: "diagnostic",
detail: "Diagnostic: analyze output 'analysis' has no state projection.",
},
{
title: "workflow.draft_workspaces.set_step_output_map",
command: "wf draft set-output lda_report_workflow --revision 2 --step analyze --map analysis=state.analysis",
summary: "Repair the missing output binding",
result: "success",
@@ -185,18 +197,21 @@ const recording: readonly PreparedAuthoringPhase[] = [
label: "Artifact",
commands: [
{
title: "workflow.draft_workspaces.compile",
command: "wf draft compile lda_report_workflow",
summary: "Compile the validated draft without mutating it",
result: "success",
detail: "Compiled raw plan requires local.lda_docs, local.lda_report, and local.issue_board.",
},
{
title: "workflow.draft_workspaces.create_artifact",
command: "wf draft save lda_report_workflow --artifact lda_report_case_study --version 1 --title \"lda.chat Report Case Study\" --outcome completed --outcome cancelled --binding local.lda_docs=local.lda_docs --binding local.lda_report=local.lda_report --binding local.issue_board=local.issue_board",
summary: "Save immutable artifact version 1",
result: "success",
detail: "Artifact lda_report_case_study v1 saved with report-workflow source requirements.",
},
{
title: "workflow.artifacts.inspect",
command: "wf artifact inspect lda_report_case_study --version 1",
summary: "Inspect the compiled artifact",
result: "success",
@@ -221,12 +236,14 @@ const recording: readonly PreparedAuthoringPhase[] = [
label: "Deployment",
commands: [
{
title: "workflow.deployments.save",
command: "wf deploy save lda_report_case_study.default --artifact lda_report_case_study --version 1 --binding local.lda_docs=local.lda_docs --binding local.lda_report=local.lda_report --binding local.issue_board=local.issue_board",
summary: "Save deployment with source bindings",
result: "success",
detail: "Deployment lda_report_case_study.default saved with all three local source bindings.",
},
{
title: "workflow.deployments.validate",
command: "wf deploy validate lda_report_case_study.default",
summary: "Validate deployment bindings",
result: "success",
@@ -275,12 +292,12 @@ export const projectPreparedAuthoringThread = (
const toolParts = phase.commands.flatMap((command, index) => {
const callId = `${groupId}-command-${index}`;
return [
agentToolCallPart(callId, "runWorkflowCommand", {
agentToolCallPart(callId, command.title, {
phase: phase.phase,
command: command.command,
summary: command.summary,
}),
agentToolResultPart(callId, "runWorkflowCommand", "success", {
agentToolResultPart(callId, command.title, "success", {
status: command.result,
detail: command.detail ?? null,
}),
@@ -298,27 +315,5 @@ export const projectPreparedAuthoringThread = (
});
};
const handoffConversation: readonly AuthoringConversationTurn[] = [
{
role: "user",
text: "We need to prepare a report workflow for the lda_report scenario. What sources and capabilities are available?",
},
{
role: "assistant",
text: "I found the local document, report, and issue-board capabilities. I can author the reusable workflow through the public wf CLI.",
},
{
role: "user",
text: "Keep the workflow inspectable and validate the source bindings before it runs.",
},
{
role: "assistant",
text: "The prepared workflow is ready for deployment: I will create the draft, repair validation diagnostics, save artifact lda_report_case_study v1, then validate its deployment bindings.",
},
];
/** Returns the full prepared authoring recording. */
export const projectPreparedAuthoring = (): readonly PreparedAuthoringPhase[] => recording;
/** Returns the compact Scene 8 handoff derived from the prepared authoring evidence. */
export const projectPreparedAuthoringHandoff = (): readonly AuthoringConversationTurn[] => handoffConversation;
@@ -157,13 +157,12 @@ const AssistantMessageBody = ({
const part = parts[index]!;
if (part.type === "text") {
rendered.push(
<p key={`text-${index}`} style={{ whiteSpace: "pre-line" }}>{part.text}</p>,
<p key={`text-${part.text}`} style={{ whiteSpace: "pre-line" }}>{part.text}</p>,
);
index += 1;
continue;
}
const toolRunStart = index;
const toolRun: ToolRenderPart[] = [];
while (index < parts.length && parts[index]!.type !== "text") {
toolRun.push(parts[index]! as ToolRenderPart);
@@ -176,7 +175,7 @@ const AssistantMessageBody = ({
if (logicalTools.length === 1 && !messageId.startsWith("authoring-")) {
rendered.push(
<div key={`tool-${toolRunStart}`}>
<div key={`tool-${logicalTools[0]!.toolCallId ?? logicalTools[0]!.toolName}`}>
{renderContentPart(logicalTools[0]!, submitApproval, cancelApproval)}
</div>,
);
@@ -189,7 +188,7 @@ const AssistantMessageBody = ({
const count = logicalTools.length;
rendered.push(
<ToolGroupRoot
key={`tool-group-${toolRunStart}`}
key={`tool-group-${groupId}`}
data-tool-group-id={groupId}
{...(phaseLabel
? {
@@ -203,13 +202,13 @@ const AssistantMessageBody = ({
{...(phaseLabel ? { label: `${phaseLabel} · ${count} tool ${count === 1 ? "call" : "calls"}` } : {})}
/>
<ToolGroupContent>
{logicalTools.map((tool, toolIndex) => {
{logicalTools.map((tool) => {
const pairedResult = tool.type === "tool-call"
? toolRun.find((candidate): candidate is Extract<ToolRenderPart, { readonly type: "tool-result" }> =>
candidate.type === "tool-result" && candidate.toolCallId === tool.toolCallId)
: undefined;
return (
<div key={`${tool.type}-${tool.toolName}-${tool.toolCallId ?? "no-id"}-${toolIndex}`}>
<div key={`${tool.type}-${tool.toolName}-${tool.toolCallId ?? "no-id"}`}>
{renderContentPart(tool, submitApproval, cancelApproval, false, pairedResult)}
</div>
);
@@ -254,13 +253,19 @@ export const AssistantOperatorThread = ({
}: AssistantOperatorThreadProps) => {
const projected = useMemo(() => projectAgentMessagesForAssistant(messages), [messages]);
const viewportRef = useRef<HTMLDivElement>(null);
const [openToolGroups, setOpenToolGroups] = useState<ReadonlySet<string>>(
() => new Set(activeToolGroupId ? [activeToolGroupId] : []),
const [toolGroupOverrides, setToolGroupOverrides] = useState<ReadonlyMap<string, boolean>>(
() => new Map(),
);
useEffect(() => {
if (activeToolGroupId) setOpenToolGroups(new Set([activeToolGroupId]));
}, [activeToolGroupId]);
const openToolGroups = useMemo(() => {
const open = new Set<string>();
if (activeToolGroupId && toolGroupOverrides.get(activeToolGroupId) !== false) {
open.add(activeToolGroupId);
}
for (const [groupId, isOpen] of toolGroupOverrides) {
if (isOpen) open.add(groupId);
}
return open;
}, [activeToolGroupId, toolGroupOverrides]);
useEffect(() => {
if (!activeToolGroupId) return;
@@ -278,10 +283,9 @@ export const AssistantOperatorThread = ({
}, [activeToolGroupId, projected]);
const setToolGroupOpen = useCallback((groupId: string, open: boolean) => {
setOpenToolGroups((current) => {
const next = new Set(current);
if (open) next.add(groupId);
else next.delete(groupId);
setToolGroupOverrides((current) => {
const next = new Map(current);
next.set(groupId, open);
return next;
});
}, []);
@@ -305,8 +309,8 @@ export const AssistantOperatorThread = ({
if (message.role === "user") {
return (
<MessageBubble key={message.id} role="user">
{message.content.filter((p) => p.type === "text").map((p, i) => (
<p key={`text-${i}`} style={{ whiteSpace: "pre-line" }}>{p.text}</p>
{message.content.filter((p) => p.type === "text").map((p) => (
<p key={`text-${p.text}`} style={{ whiteSpace: "pre-line" }}>{p.text}</p>
))}
</MessageBubble>
);
@@ -137,4 +137,34 @@ describe("assistantRuntimeProjection", () => {
{ type: "text", text: "provider failed" },
]);
});
it("assigns sequential indexes for same-type presentation actions", () => {
const messages: ReadonlyArray<AgentMessage> = [
{
id: "dual",
role: "assistant",
parts: [
{ type: "presentation-action", action: { type: "selectWorkflowNode", nodeId: "review_issues" } },
{ type: "presentation-action", action: { type: "selectWorkflowNode", nodeId: "start_step" } },
],
},
];
const projected = projectAgentMessagesForAssistant(messages);
expect(projected[0]?.content).toEqual([
{
type: "tool-call",
toolCallId: "presentation-dual-0-selectWorkflowNode",
toolName: "presentation.selectWorkflowNode",
args: { type: "selectWorkflowNode", nodeId: "review_issues" },
},
{
type: "tool-call",
toolCallId: "presentation-dual-1-selectWorkflowNode",
toolName: "presentation.selectWorkflowNode",
args: { type: "selectWorkflowNode", nodeId: "start_step" },
},
]);
});
});