feat: P4 Phase C — Build a flow (decomposition over the untouched pipeline) - #80
Merged
Conversation
…teps, Build-a-flow e2e New assertions, run against main (A+B merged, studio#79) — all fail for the right reasons; NO live gateway call anywhere (the decomposition request is tested shape-only, the plan.test.ts discipline): - packages/composer-core/src/flow-plan.test.ts (new) — buildFlowPlanRequest (goal as the user message; system names the design system, lists intent ids, states the 2–8 ordered ONE-screen-per-step rules, forbids invented steps; schema: steps minItems 2 / maxItems 8, closed objects, intent as a REAL-id enum, optional per step), reconcileFlowPlan (valid plan kept source "model"; invalid/missing step intent -> planDeterministic on THAT step's goal; bounds 8 steps + name/title/goal slices; <2 usable steps or garbage -> deterministic fallback, never an empty plan), and flowPlanDeterministic (a Gateway-#12-shaped numbered lifecycle fixture -> >=4 ordered steps, real intents, labeled scripted with a deterministic reason; sentence clustering; single-sentence goals still outline >=2 steps; rambling goals bounded to 6). - apps/composer/app/flows.test.ts — surfaceId "" is a PENDING step: flowLint warns "not built yet" (code pending-step) instead of the dangling error, one cause one finding (no advanceOn warn on a pending step); parseFlow shape anchor ("" is a state, not malformation). - packages/composer-core/src/flows.test.ts — flowSchema/parseFlows anchor: an empty surfaceId parses (already true; pinned so it stays true). - e2e/composer-prod-smoke.spec.ts — one added spec: default Build render has NO flow-mode elements; toggle -> 3-sentence goal -> deterministic labeled plan -> edit (remove a step, retitle, pin intents) -> "Create flow & build steps" -> flow exists PENDING -> two sequential ordinary scripted builds -> accept turn 1 (pre-targeted by the driver) -> Preview shows the half-built flow with the pending step disabled + the pending body state -> accept turn 2 -> the completed flow walks end to end. Pre-change output: composer-core (npx vitest run src/flow-plan.test.ts src/flows.test.ts): ✓ src/flows.test.ts (7 tests) [pending-shape anchors hold] FAIL src/flow-plan.test.ts [ src/flow-plan.test.ts ] Error: Cannot find module './flow-plan' apps/composer (npx vitest run app/flows.test.ts): 2 failed | 30 passed × an EMPTY surfaceId is a PENDING step (Phase C): warn 'not built yet', never a dangling error → expected 'error' to be 'warn' × a pending step never also warns about its advanceOn (one cause, one finding) → expected 'dangling-surface' to be 'pending-step' e2e smoke (-g "build a flow", against the freshly built static export of main): Error: locator.click: ... waiting for getByTestId('build-mode-flow') 1 failed Note: the Gateway #12 corpus text lives outside this repo; the fixture mirrors its shape (one workflow goal, numbered stations) and is labeled so. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eps, sequential step driver
One workflow goal becomes an EDITABLE plan; every step then builds through
the ordinary governed pipeline — same gates, same repairs, same Accept —
one step at a time. dspack-gen never learns flows exist; the planner is
Composer/composer-core code beside plan.ts, and no test touches a live
gateway (the decomposition request is shape-only, the plan.test.ts rule).
- composer-core flow-plan.ts (new, plan.ts idioms): buildFlowPlanRequest —
system names the design system, lists the contract's intents, states the
2–8 ordered ONE-screen-per-step rules and forbids invented steps; schema
closes every object, bounds steps 2–8, and constrains step intent to a
REAL-id enum (optional; the reconciler fills gaps). reconcileFlowPlan —
clamps each step's intent (invalid/missing -> planDeterministic on THAT
step's goal), bounds name/title/goal with the same slice idiom, bounds to
8 steps, and falls back to the deterministic outline when fewer than 2
usable steps remain; source "model". flowPlanDeterministic — the honest,
never-blocking outline: one step per numbered item (the workflow framing
line is not a step), else per sentence clustered to at most 6, else the
goal plus a review step; per-step intents through the same deterministic
classifier scripted builds use; labeled scripted with a reason that says
it is a deterministic outline.
- Pending steps are first-class: surfaceId "" means PLANNED, NOT YET BUILT.
flowLint warns (pending-step, "not built yet") instead of the dangling
error — one cause, one finding; parseFlow/flowSchema/manifest accept ""
(anchored). Preview renders pending navigator chips visibly disabled
("· pending"), and a pending current step shows a quiet outline state
(flow-step-pending) — a flow of only pending steps previews as its
outline, never a crash.
- Build gains an OPT-IN flow mode (build-mode-flow; the default
single-surface render and behavior are untouched — the e2e pins that no
flow-mode elements exist until the toggle): goal textarea -> "Plan the
flow" (hosted plans via the existing runGatewayRequest seam, any throw ->
the labeled deterministic outline; scripted/local plan deterministically)
-> an editable plan editor (title/goal/intent per step, add/remove/
reorder, Phase A editor idioms) -> "Create flow & build steps": the flow
is created IMMEDIATELY with every step pending through the single
saveFlows funnel, then the driver runs each step as an ordinary runBuild
turn (goal = step.goal, intentOverride = step.intent) STRICTLY
sequentially — never two builds in flight — spaced 8s for model providers
(the P3a burst finding; scripted has no provider to protect and runs
back-to-back). Each result is a normal turn whose Accept is PRE-TARGETED
to its step via the Phase B binding (the header select still wins); a
failing step stops the drive, later steps stay pending, and per-step
"build step" buttons resume or re-run individually.
- runBuild now RESOLVES with the turn's outcome, computed from the turn's
own event stream (never from state) so the driver can sequence and stop;
single-surface callers ignore it. Turns carry an optional flowStepHint;
the accept note names the pre-targeted step.
All suites green: composer-core 77 -> 89, composer 65 -> 68, agent 48
(untouched), typecheck clean, smoke e2e 12 -> 13 (existing specs untouched),
composer-agent e2e 44/44 against the real agent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
dspack-studio-composer | f06b296 | Aug 12 2026, 01:17 PM |
There was a problem hiding this comment.
Pull request overview
Adds P4 Phase C “Build a flow” support by introducing flow decomposition planning (2–8 step editable plans) and a sequential per-step build driver, while keeping the underlying single-surface generation pipeline unchanged until the flow-mode toggle is enabled.
Changes:
- Introduces
flow-planplanning primitives incomposer-core(schema, provider-agnostic request builder, deterministic fallback, reconciliation). - Adds an opt-in “Build a flow…” mode in Build UI with plan editing, immediate creation of PENDING flows, and sequential step builds with provider-aware spacing.
- Updates flow parsing/linting/preview to treat
surfaceId: ""as a first-class “PENDING” step state, plus adds/extends unit + e2e coverage.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/composer-core/src/index.ts | Re-exports new flow-plan APIs/types. |
| packages/composer-core/src/flows.test.ts | Adds a contract-level shape anchor test for empty surfaceId (pending step). |
| packages/composer-core/src/flow-plan.ts | New flow decomposition planner: schema, request builder, deterministic fallback, reconciler. |
| packages/composer-core/src/flow-plan.test.ts | New unit tests for flow-plan request shape, reconciliation, and deterministic behavior. |
| apps/composer/app/views/preview-view.tsx | Disables pending-step chips and renders an outline/pending empty-state instead of treating it as an error. |
| apps/composer/app/views/build-view.tsx | Adds flow-mode UI, plan editor, flow creation with pending steps, and sequential step build driver + accept targeting. |
| apps/composer/app/state.tsx | Extends runBuild to return a turn outcome and tags turns with flowStepHint for accept pre-targeting. |
| apps/composer/app/planning.ts | Adds planFlow() with hosted inference + deterministic fallback. |
| apps/composer/app/flows.ts | Adds pending-step helpers and updates flow lint to warn (not error) on pending steps. |
| apps/composer/app/flows.test.ts | Adds tests for pending-step lint behavior and parse acceptance. |
| e2e/composer-prod-smoke.spec.ts | Adds a full “build a flow” smoke test covering planning/editing/pending preview/sequential builds. |
Suppressed comments (3)
apps/composer/app/views/build-view.tsx:620
- After a flow has been created (flowBuild !== null), the step title/goal/intent inputs remain editable. This can desync the persisted flow (already saved via saveFlows with step titles) from what the Build UI shows/uses for subsequent step builds, and it undermines the intended "edit → approve → build" lifecycle. Consider locking the step fields once the flow is created, not just while it's running.
<input
style={{ ...field, minWidth: 140 }}
value={step.title}
onChange={(e) => patchPlanStep(i, { title: e.target.value })}
disabled={flowBuild?.running === true}
apps/composer/app/views/build-view.tsx:699
- The "Create flow & build steps" button is enabled for 1-step (or >8-step) plans, which contradicts the 2–8 step contract and can lead to out-of-contract flows. Disable this action unless the plan is within bounds.
<button
className="st-btn st-btn--primary"
disabled={flowPlan.steps.length === 0 || planBusy || buildBusy}
onClick={() => void acceptPlan()}
data-testid="flow-plan-accept"
>
Create flow & build steps
apps/composer/app/views/build-view.tsx:485
- acceptPlan() only guards against an empty plan, but it should enforce the same 2–8 step bounds as the flow-plan schema; otherwise a manually edited plan can create an out-of-contract flow and the driver will attempt to build an arbitrary number of steps.
const acceptPlan = async () => {
if (!flowPlan || flowPlan.steps.length === 0 || planBusy || buildBusy || flowBuild?.running) return;
const plan = flowPlan; // frozen for this drive; the editor locks while running
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+456
to
+459
| const removePlanStep = (at: number) => setFlowPlan((p) => (p ? { ...p, steps: p.steps.filter((_, i) => i !== at) } : p)); | ||
|
|
||
| const addPlanStep = () => | ||
| setFlowPlan((p) => (p ? { ...p, steps: [...p.steps, { title: `Step ${p.steps.length + 1}`, goal: "", intent: intents[0] ?? "" }] } : p)); |
Comment on lines
+599
to
+603
| <input | ||
| style={{ ...field, minWidth: 220 }} | ||
| value={flowPlan.name} | ||
| onChange={(e) => setFlowPlan((p) => (p ? { ...p, name: e.target.value } : p))} | ||
| disabled={flowBuild?.running === true} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Flow decomposition per the ratified P4 architecture: one workflow goal → an editable plan of 2–8 steps (composer-core
flow-plan.tson the plan.ts idioms — existing intents only, enum-clamped, honest labeled deterministic fallback that never blocks) → the flow created immediately with every step PENDING (first-class state: warn-level lint, disabled navigator chips, outline preview) → each step driven as an ordinaryrunBuildturn, strictly sequential with provider-aware spacing (the P3a burst finding), Accept pre-targeted to its step through the Phase B binding, per-step resume buttons.The generation pipeline, contracts, emit, A2UI, renderers, intents, and governance are untouched; no test makes a live gateway call; with the flow-mode toggle untouched the single-surface Build path renders byte-identically (pinned by e2e).
runBuildnow resolves the turn's outcome (computed from the turn's own events) so the driver can sequence — void callers unaffected.Fail-first: pre-change failures pasted in 396ed22. composer-core 77→89, composer 65→68, smoke e2e 12→13, composer-agent 44/44 re-run, typecheck clean.
🤖 Generated with Claude Code