diff --git a/.changeset/compound-engineering-plugin-scaffold.md b/.changeset/compound-engineering-plugin-scaffold.md new file mode 100644 index 0000000000..a41e934342 --- /dev/null +++ b/.changeset/compound-engineering-plugin-scaffold.md @@ -0,0 +1,12 @@ +--- +"@runfusion/fusion": minor +--- + +Add the Compound Engineering bundled plugin: a dedicated dashboard surface for compound-engineering artifacts and interactive `ce-*` sessions, a work→board bridge, and bidirectional board↔pipeline sync. Sessions are fully multi-session: a Sessions panel lists every run with stage/status/last-activity, lets you open and switch between concurrent sessions (each keeps running server-side), resume interrupted ones, and discard settled ones (`DELETE /sessions/:id` disposes the live handle before deleting the row). + +Sessions show the agent's full working output live (streamed thinking/tool activity with an inactivity-based stall timeout instead of a fixed turn timeout), the user can steer mid-stage with free-text guidance (attached to an answer or sent on its own), and the transcript renders past questions/answers/working traces as a proper chat surface. + +This also adds two reusable host capabilities that any plugin benefits from: + +- **Interactive agent sessions for plugin routes** (`ctx.createInteractiveAiSession`), with skill-discovery forwarding (`requestedSkillNames` / `additionalSkillPaths`) and live mid-turn progress streaming (`onProgress`: thinking/text deltas + tool markers) so a plugin can load a bundled skill into a live session and surface its work in real time. +- **Real plugin event push over SSE**: a plugin's `ctx.emitEvent` calls are forwarded to connected `/api/events` clients as project-scoped `plugin:custom` events, and dashboard views can consume them via the new `subscribePluginEvents` view-context capability. diff --git a/CONCEPTS.md b/CONCEPTS.md index 3f856d5b4c..9cd9facbf5 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -62,6 +62,26 @@ A recurring background scan that detects and repairs stuck Task states — stall ### Shared branch group A set of Tasks integrating into a common shared branch instead of each merging straight to the project's default branch. Member integration (task branch → shared branch) is a soft pre-integration step exempt from the global auto-merge gate; promotion (shared branch → default branch) is gated separately. +## Compound Engineering sessions + +### CE Stage +A registered step of the compound-engineering pipeline (e.g. brainstorm, plan, work, compound), each mapped to a bundled skill and a conventional artifact location. Adding a stage is a registry data entry, not new code surface. + +### CE Session +A single interactive run of a CE Stage: an agent drives a question/answer flow with the user and produces the stage's artifact on completion. Sessions are independent pipeline runs — many can exist concurrently, each with its own lifecycle (launching, active, awaiting-input, completed, error, interrupted) and conversation history. A completed work-stage CE Session lands derived Tasks on the board, linked back to the session for provenance. + +### Detached turn +The execution posture for CE Session agent turns: the request that triggers a turn returns as soon as the session reflects it, and the turn runs in the background while clients converge through push events and polling. A detached turn never rejects — every failure persists into session state and emits an observable event, so progress is never silently lost. + +### Live activity +The transient working output of an in-flight agent turn — accumulated thinking, streamed text, and tool execution markers. It is observable while the turn runs but is not session state; when the turn settles or is interrupted, a condensed trace is folded into the conversation history so the transcript keeps the story. + +### Steering +The user's mid-stage feedback channel: free-text guidance attached to an answer, or sent on its own without answering the pending question. Agents treat steering as first-class input — incorporate it, adjust course, and either re-ask or proceed. + +### Rehydration +Re-establishing a live agent handle for a paused CE Session by replaying its recorded conversation against the model. Replay is side-effect-suppressed: it reconstructs the agent's context without re-emitting events, re-streaming Live activity, or re-writing artifacts. + ## Flagged ambiguities - "Merging" a shared-branch-group Task had been used for both member integration and group promotion — these are distinct steps with independent gating and must not be conflated. diff --git a/docs/plans/2026-06-02-001-feat-compound-engineering-plugin-plan.md b/docs/plans/2026-06-02-001-feat-compound-engineering-plugin-plan.md index 73183f739d..cb3c544a1c 100644 --- a/docs/plans/2026-06-02-001-feat-compound-engineering-plugin-plan.md +++ b/docs/plans/2026-06-02-001-feat-compound-engineering-plugin-plan.md @@ -1,6 +1,6 @@ --- title: "feat: Compound Engineering plugin with end-to-end UI" -status: active +status: completed type: feat date: 2026-06-02 origin: docs/brainstorms/2026-06-02-compound-engineering-plugin-requirements.md diff --git a/docs/plugins/compound-engineering.md b/docs/plugins/compound-engineering.md new file mode 100644 index 0000000000..2447daae98 --- /dev/null +++ b/docs/plugins/compound-engineering.md @@ -0,0 +1,114 @@ +# Compound Engineering Plugin + +A dedicated dashboard surface for the compound-engineering (CE) workflow — an +artifact hub, interactive `ce-*` skill sessions, a work→board bridge, and +event-driven bidirectional sync. It runs alongside Fusion's native pipeline. + +## Install + +1. Open **Settings → Plugins → Fusion Plugins**. +2. In **Bundled Plugins**, click **Install** for **Compound Engineering**. +3. Enable the plugin if it is not already started. + +When installed and enabled, the plugin registers the **Compound Engineering** +dashboard view destination and installs its bundled `ce-*` skills into a +plugin-local, discoverable directory (never a global `~/.claude/skills` path). + +## Dashboard view + +The Compound Engineering view is registered as a primary plugin destination +(`viewId: "compound-engineering"`). + +It provides: +- An **artifact hub** that discovers CE artifacts from conventional locations + (`STRATEGY.md`, `docs/ideation/`, `docs/brainstorms/`, plan docs, `docs/work/`, + `CONCEPTS.md`, `docs/solutions/`) grouped by stage, with explicit + empty / partial / error states. +- Self-contained artifact previews read through plugin routes under + `/api/plugins/fusion-plugin-compound-engineering/`. +- A **stage launcher** listing the registered, operator-enabled stages. + +## Sessions + +Each stage maps to a bundled skill via the stage registry +(`{ stageId, skillId, artifactLocation, icon, label }`). Launching a stage starts +an interactive agent session on the host's `createInteractiveAiSession` seam. + +The orchestrator streams `thinking`/`text` turns, surfaces a structured +`question` (pausing in `awaiting_input`), accepts a structured answer, and on +`complete` writes the artifact to the stage's conventional location. Lifecycle: +`launching → active → awaiting_input → completed`, plus `error` and +`interrupted`. Interrupt/error auto-saves progress and emits an observable event; +sessions resume/retry back to their current question. + +Turn execution is **detached**: start/answer/resume return as soon as the +session row reflects the request, with the agent turn running in the background +(failures persist into session state — never an unhandled rejection). While a +turn runs, the engine streams mid-turn progress (thinking/text deltas + tool +markers) through the seam's `onProgress` option; the orchestrator buffers it +and `GET /sessions/:id` attaches it as transient `liveActivity`. The per-turn +timeout is **inactivity-based** (progress re-arms it), so long actively-working +turns are never killed; on settle/interrupt the working trace is condensed into +the conversation history. Users can also **steer** mid-stage: answers may carry +free-text guidance (`{value, comment}`) or be guidance-only (`{feedback}`). + +Updates are **pushed** over the shared `/api/events` SSE stream: the orchestrator +emits via `ctx.emitEvent`, the host forwards them as project-scoped +`plugin:custom` events, and the view subscribes through the host +`subscribePluginEvents` capability (no raw `EventSource`). Polling +`GET /sessions/:id` remains a fallback. The `projectId` from `start` is threaded +through every answer/resume/poll so they resolve the session's owning store. + +HTTP endpoints (under `/api/plugins/fusion-plugin-compound-engineering/`): +- `POST /sessions` → start a stage session +- `POST /sessions/:id/answer` → answer the awaiting question (send `projectId`) +- `POST /sessions/:id/resume` → resume an awaiting/interrupted session (send `projectId`) +- `GET /sessions/:id` → current persisted session state (push + poll fallback) +- `GET /sessions` → list sessions (filter by status/stage) +- `GET /sessions/:id/links` → the work→board pipeline-link records for a session + +## Sync model + +Two separate state machines are kept in sync, never merged: + +- **Board-task ownership** → the task `column`. The **board is authoritative for + task state**. +- **CE-pipeline ownership** → `ce_pipeline_state.{currentStage, status}`. The + **CE flow is authoritative for artifact/pipeline content**. + +**Inbound:** `onTaskMoved` / `onTaskCompleted` hooks resolve the link and enqueue +a sync signal under the 5s hook budget — no inline advancement. + +**Reconcile:** `reconcileCePipelines(ctx)` is a single on-demand sweep (not a +poll loop). It drains the queue and independently re-derives transitions from +live board state, so a dropped or never-enqueued event still converges. + +**Outbound:** when a pipeline advances to a stage that produces board work, the +reconciler creates the next-stage board task and links it. + +**Conflict policy:** the reconciler only reads already-terminal board columns and +only writes CE-owned fields plus a new board task, so the two writers never +contend over the same cell. + +The work bridge tags every CE-originated board task (source `workflow_step` with +CE markers in `sourceMetadata`) and records an authoritative pipeline-link row; +created tasks then run the normal lifecycle untouched. + +## Settings + +Settings render under **Settings → Plugins → Compound Engineering**. + +**Sessions** +- `defaultProvider` (string) — provider for CE interactive sessions; blank uses + the host default. Consumed by the orchestrator's factory call. +- `defaultModelId` (string) — model within the provider; blank uses the host + default. Consumed by the orchestrator's factory call. +- `enabledStages` (string[], default = full registry) — only these stage IDs may + be launched; the orchestrator rejects others. + +**Sync** +- `reconcileOnHooks` (boolean, default `true`) — auto-fire the reconcile sweep + after task move/complete hooks. When off, the hook still enqueues so an + on-demand sweep converges later. +- `reconcileIntervalMinutes` (number, default `15`) — cadence hint for an + on-demand refresh surface; not a continuous poll loop. diff --git a/docs/solutions/architecture-patterns/observable-long-running-agent-turns-through-blocking-plugin-route-seam.md b/docs/solutions/architecture-patterns/observable-long-running-agent-turns-through-blocking-plugin-route-seam.md new file mode 100644 index 0000000000..4390553f27 --- /dev/null +++ b/docs/solutions/architecture-patterns/observable-long-running-agent-turns-through-blocking-plugin-route-seam.md @@ -0,0 +1,166 @@ +--- +title: "Observable long-running agent turns through a blocking plugin-route seam" +date: 2026-06-03 +category: architecture-patterns +module: fusion-plugin-compound-engineering +problem_type: architecture_pattern +component: service_object +severity: high +applies_when: + - "A plugin/HTTP route drives a long-running interactive agent turn behind a blocking request/response seam" + - "Mid-turn agent output (thinking, tool calls, streamed text) is swallowed by a pull-based settle-only event API" + - "A fixed per-turn timeout risks killing legitimately long, tool-heavy turns that are still actively working" + - "Clients need live visibility into agent work without persisting transient activity into durable state" + - "A paused, stateful agent session must be resumable across process restarts without re-emitting prior output" +symptoms: + - "Client blocks on a single POST for minutes with zero visibility into agent progress" + - "All mid-turn thinking, tool calls, and streamed text are swallowed; only question/complete/error surface" + - "A fixed 120s per-turn timeout interrupts legitimately long tool-heavy turns while the agent is still working" +root_cause: async_timing +resolution_type: code_fix +related_components: + - tooling + - frontend_stimulus +tags: + - agent-observability + - sse + - streaming + - detached-execution + - plugin-routes + - interactive-session + - inactivity-timeout + - live-activity + - compound-engineering +--- + +# Observable long-running agent turns through a blocking plugin-route seam + +## Context + +The compound-engineering bundled plugin runs interactive CE-stage agent sessions through plugin routes. The host exposes a deliberately minimal **pull-based** interactive seam (`packages/core/src/plugin-types.ts`): the caller drives one `prompt`/`answer` per turn and awaits `nextEvent()`, which resolves only when the turn *settles* (`question` | `complete` | `error`). That contract is simple to drive deterministically from a route or a scripted test — but it had three structural consequences that surfaced as user-visible failures: + +1. **All mid-turn output was swallowed.** `nextEvent()` does not resolve on intermediate thinking/text/tool activity, so a multi-minute tool-heavy turn produced *nothing* observable until it finished. +2. **Routes blocked blind.** The POST handler ran the whole turn synchronously inside the request, so clients waited minutes with no feedback (and, for the opening turn, no session id to poll). +3. **A fixed 120s turn timeout killed turns that were actively working** — long, legitimately-busy turns hit the wall and died. + +The fix made the agent's work live-streamable, made routes non-blocking (detached turns), made the timeout inactivity-based, and persisted the working trace into the transcript across settle/interrupt and process restarts. + +## Guidance + +### 1. Keep the pull-based settle contract; add a SEPARATE push channel + +Don't convert `nextEvent()` into a stream. Live visibility is a *new, optional, additive* callback (`onProgress`) on the session options — the terminal-only pull semantics are untouched. Scripted test fakes that drive `prompt`/`nextEvent` are completely unaffected, and factories that can't stream simply ignore the option. + +```ts +// packages/core/src/plugin-types.ts +export interface CreateInteractiveAiSessionOptions { + // ... + /** Live progress callback, invoked WHILE a turn runs (the pull-based + * nextEvent() only resolves once the turn settles). Must not throw — + * implementations should swallow callback errors. */ + onProgress?: (event: InteractiveAiSessionProgressEvent) => void; +} +``` + +### 2. Deltas, not snapshots; the consumer accumulates + +Progress events carry incremental *deltas*. The consumer owns accumulation, merge-by-kind, and capping — the protocol stays tiny and the producer holds no buffer state. + +```ts +export type InteractiveAiSessionProgressEvent = + | { type: "thinking"; delta: string } // incremental DELTA, not a snapshot + | { type: "text"; delta: string } + | { type: "tool"; name: string; phase: "start" | "end"; isError?: boolean }; +``` + +The engine adapter (`packages/engine/src/index.ts`) maps the underlying agent hooks (`onText`/`onThinking`/`onToolStart`/`onToolEnd`) into these deltas, and **every callback is wrapped in try/catch so a consumer error can never break the agent turn**. The consumer (`orchestrator.handleProgress`) merges consecutive same-kind deltas into one activity turn, opens/closes discrete tool turns, and caps both per-turn chars and turn count, dropping the oldest (the tail is what the user is watching). + +### 3. Detached turns must NEVER reject + +Routes return immediately after the session row exists; the turn runs as a floating background promise (`void turn`). For that to be void-safe, the background promise can have *no* rejection path: factory-create failure, driver throw, and timeout all resolve into a persisted state transition (`failSession` / `interruptSession` / `applyEvent`) plus an emitted observable event. No unhandled rejections, no silent loss. + +```ts +// orchestrator.start +const turn = this.runOpeningTurn(session.id, stage, opts.openingMessage); +if (opts.detach) { + void turn; // never rejects (failures persist into state) + return { session: this.requireSession(session.id) }; +} +``` + +### 4. Inactivity watchdog, not a fixed turn timeout + +The watchdog rejects only after `turnTimeoutMs` of *no progress*; each progress event stamps `lastProgressAt` and re-arms it, so an actively-working turn survives indefinitely. With a non-streaming factory (no progress ever arrives), it degrades cleanly to the old fixed per-turn timeout. + +```ts +const check = () => { + if (cancelled) return; // cancel() stops the loop when the turn settles + const elapsed = Date.now() - (this.lastProgressAt.get(sessionId) ?? 0); + if (elapsed >= this.turnTimeoutMs) { reject(new CeTurnTimeoutError(this.turnTimeoutMs)); return; } + timer = setTimeout(check, this.turnTimeoutMs - elapsed); // re-arm to the remaining window + timer.unref?.(); +}; +``` + +A watchdog rejection is caught and becomes a preserved-progress `interrupted` state — never silent. + +### 5. Live activity is transient; flush a condensed trace into history on settle/interrupt + +The mid-turn buffer lives only in memory; the GET route reads it from the orchestrator (`getLiveActivity(id)`) and attaches it as a transient `liveActivity` field on the response — never written as session state during the turn. + +On settle (`question`/`complete`/`error`) or interrupt, `flushActivity` writes a condensed copy into conversation history **before** the settling record, so the transcript retains the working trace across restarts. + +### 6. Suppress progress (and all side effects) during rehydration replay + +Resume re-creates a live handle by replaying recorded user turns against the model. That replay re-streams old output — which must not be re-emitted as new work. A `replaying` set gates `handleProgress`, and the replay drains one event per drive but **discards** it (no persist/emit/artifact-write). + +### 7. Throttle push emits and bump the staleness anchor on the same beat + +Progress is high-frequency; per-delta SSE emits would flood clients. Throttle to one emit per interval (500ms here), and on that same beat bump the persisted liveness anchor (`lastActivityAt`) so the stale-session recovery rubric sees an actively-working turn as alive rather than abandoned. The client converges via **push + poll**: an SSE event triggers an immediate refetch (low latency), and a poll interval runs while the turn is mid-flight as a fallback — stopping the moment the session settles. + +## Why This Matters + +- **Observability without protocol churn.** A push side-channel gives live visibility while preserving a settle contract that's trivial to drive deterministically from routes and tests. Converting `nextEvent()` into a stream would have rewritten every consumer and every scripted fake for a purely additive feature. +- **Non-blocking routes need void-safe background work.** Detaching a turn is only safe if the background promise has no rejection path. Routing *every* failure into persisted state + an emitted event is what makes `void turn` correct rather than a latent unhandled-rejection bug. +- **Activity is the liveness signal.** An inactivity watchdog encodes the real intent ("is it still working?") instead of a proxy ("has it taken too long?"), and folding the same signal into the staleness anchor keeps two independent health rubrics coherent. +- **Resilience across restarts.** Persisting a condensed trace on settle/interrupt, plus side-effect-suppressed rehydration, means a paused session resumes in a fresh process with its history intact and without double-streaming. + +## When to Apply + +- Surfacing live agent (or any long-running job) work through a request/response or pull-based seam that only resolves on terminal events +- A route runs a multi-minute operation and clients currently block with no progress and no handle to poll +- A fixed timeout is killing work that is legitimately still active +- Resuming a paused, stateful session across process restarts without re-emitting prior output + +Apply the *push-channel-alongside-pull-contract* and *void-safe-detached-turn* patterns together; they're complementary. Don't reach for this when the operation is short and synchronous — the transient buffer, watchdog, and rehydration machinery are overhead you don't need. + +## Examples + +Before/after, distilled: + +- **Before:** route `await`s the entire turn inside POST; client gets nothing for minutes; mid-turn output is dropped because `nextEvent()` only resolves on settle; a fixed 120s timeout kills busy turns. +- **After:** POST returns `201 {session}` immediately with `detach: true`; `onProgress` deltas accumulate into a transient buffer attached at GET; an inactivity watchdog re-armed by progress lets busy turns run; failures persist into state + emit; resume rehydrates with replay suppressed. + +The regression tests (`plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-live-output.test.ts`) lock the load-bearing behaviors: + +- A busy turn pumped with `thinking` deltas at ~45ms intervals for ~3× the 120ms test timeout stays `active`; once quiet, it flips to `interrupted` with the activity trace present in history. +- `answer(detach)` returns immediately (`status: active`, `currentQuestion: null`), then the background turn converges to the next question. +- `start(detach)` with an exploding factory converges to `status: error` with the message preserved and an observable error event emitted — never silent. + +Failure modes this prevents: + +1. Silent mid-turn blackout (pull-only API resolves nothing until terminal) +2. Blocked-blind routes (request held open for the full turn, no handle to poll) +3. Killed-while-working timeouts (fixed timeout vs. activity-based liveness) +4. Unhandled rejection / silent loss from floating detached turns +5. Replay double-streaming during rehydration +6. SSE flooding from per-delta emits +7. Stale-rubric false positives on busy sessions (liveness not bumped with activity) +8. Lost transcript on interrupt/settle (transient buffer never condensed into history) +9. Consumer `onProgress` errors breaking the agent turn (guarded at the adapter) + +## Related + +- [Plugin-bundled skills silently fail to load in interactive sessions](../integration-issues/plugin-bundled-skills-not-loading-in-interactive-sessions.md) — sibling learning on the same `CreateInteractiveAiSessionOptions` seam (it added `requestedSkillNames`/`additionalSkillPaths`; this one adds `onProgress`) +- `docs/plugins/compound-engineering.md` §Sessions — the reference doc for the CE session transport (push + poll) +- Key files: `packages/core/src/plugin-types.ts`, `packages/engine/src/index.ts` (interactive adapter), `plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts`, `src/routes/session-routes.ts`, `src/dashboard/hooks/useCeSession.ts` diff --git a/docs/solutions/integration-issues/plugin-bundled-skills-not-loading-in-interactive-sessions.md b/docs/solutions/integration-issues/plugin-bundled-skills-not-loading-in-interactive-sessions.md new file mode 100644 index 0000000000..6265456976 --- /dev/null +++ b/docs/solutions/integration-issues/plugin-bundled-skills-not-loading-in-interactive-sessions.md @@ -0,0 +1,124 @@ +--- +title: "Plugin-bundled skills silently fail to load in interactive sessions" +date: 2026-06-03 +category: integration-issues +module: packages/engine +problem_type: integration_issue +component: tooling +severity: high +symptoms: + - "Bundled `ce-*` skills declared via `PluginSkillContribution.skillFiles` never load into live interactive agent sessions" + - "No error is raised — the requested skill name silently matches nothing and is dropped" + - "The `SKILL.md` files are physically bundled in the plugin yet remain undiscoverable to the session" + - "Interim workaround (set session cwd to the install root + name the skill in the system prompt) does not make the skill discoverable" +root_cause: incomplete_setup +resolution_type: code_fix +related_components: + - assistant + - development_workflow +tags: + - skills + - plugin + - skill-resolver + - additional-skill-paths + - resource-loader + - interactive-session + - compound-engineering +--- + +# Plugin-bundled skills silently fail to load in interactive sessions + +## Problem + +Bundled `ce-*` skills declared via `PluginSkillContribution.skillFiles` never loaded into live interactive agent sessions. The engine's skill resolver only *filters* skills it already discovered on disk and never ingests a contribution's `skillFiles`, so a name-only contribution produced no loadable skill — silently. + +## Symptoms + +- A stage session runs, but the agent behaves as if the `ce-*` skill is absent — its instructions are never applied. +- The resolver returns an empty/unchanged skill set for the requested name; the filter has nothing matching to keep. +- **No error is raised.** A `PluginSkillContribution` is name-only (`{ skillId, name, skillFiles }`), so declaring it is structurally valid; the session just starts without the skill. +- Tests using a scripted/fake session pass, hiding the gap — only a *real* resource loader surfaces it. + +## What Didn't Work + +**1. Declaring `skills: PluginSkillContribution[]` alone.** The engine resolver (`skill-resolver.ts`) computes an allow/exclude *filter* over skills the loader already discovered on disk. `createSkillsOverrideFromSelection` returns a callback that only ever runs `base.skills.filter(...)` — it never *adds* skills. If the bundled `SKILL.md` was never physically on a discoverable path, it isn't in `base.skills`, so filtering by its name yields `[]`. The contribution's `skillFiles` are never read for live sessions. + +**2. Setting the session `cwd` to the install root + naming the skill in the system prompt.** `DefaultResourceLoader` discovers skills by scanning *standard skill roots* (e.g. `/.claude/skills//SKILL.md`), not by treating an arbitrary `cwd` as a skills directory. Pointing `cwd` at `` (which holds `/SKILL.md` directly) does not match the layout the loader scans, so the skill still isn't discovered — and it relocates the session away from the project root where it must read context and write artifacts. A prompt mention cannot inject skill content the loader never loaded. + +## Solution + +Two parts: **physically install** the bundled skill to a discoverable plugin-local dir, and **forward both the requested name and the install dir** through a new seam option, end to end. + +**Physical install** (`skill-installation.ts`) — copy each bundled `/SKILL.md` into a plugin-local target, with a hard isolation guard (never a global `~/.claude|.codex|.gemini/skills`), idempotently: + +```ts +assertPluginLocalTarget(targetRoot); // isolation invariant: never a global skills dir +if (!existsSync(join(targetRoot, skillId))) { // skip-if-exists + mkdirSync(targetRoot, { recursive: true }); + cpSync(join(sourceRoot, skillId), join(targetRoot, skillId), { recursive: true }); +} +``` + +**Layer 1 — engine loader seam (`pi.ts`).** A new `AgentOptions.additionalSkillPaths`, forwarded into `DefaultResourceLoader` as a real *discovery* path (distinct from the filtering `skillsOverride`): + +```ts +// AgentOptions +skills?: string[]; // convenience → auto-builds a SkillSelectionContext (requestedSkillNames) +additionalSkillPaths?: string[]; // extra dirs (each holding /SKILL.md) for the loader to SCAN + +const resourceLoader = new DefaultResourceLoader({ + cwd: resolvedProjectRoot, + ...(options.additionalSkillPaths?.length + ? { additionalSkillPaths: [...options.additionalSkillPaths] } : {}), + ...(skillsOverrideFn ? { skillsOverride: skillsOverrideFn } : {}), +}); +``` + +**Layer 2 — core seam type (`plugin-types.ts`).** `CreateInteractiveAiSessionOptions` gains the matching fields so a plugin route can request them: + +```ts +requestedSkillNames?: string[]; // names the session should load +additionalSkillPaths?: string[]; // dirs to scan so requestedSkillNames are discoverable +``` + +**Layer 3 — engine adapter (`index.ts`).** Forwards both into `createFnAgent`, mapping `requestedSkillNames` → the convenience `skills` param: + +```ts +...(opts.requestedSkillNames?.length ? { skills: opts.requestedSkillNames } : {}), +...(opts.additionalSkillPaths?.length ? { additionalSkillPaths: opts.additionalSkillPaths } : {}), +``` + +**Caller — orchestrator (`orchestrator.ts`).** Passes the stage's skill id as the requested name AND the plugin-local install root as a discovery path, while keeping `cwd` on the project root: + +```ts +private buildSessionOptions(stage: CeStageDefinition) { + return { + cwd: this.projectRoot, // project root — NOT the skills dir + requestedSkillNames: [stage.skillId], + additionalSkillPaths: resolveStageSkillPaths(), // [resolveDefaultInstallTargetRoot()] + // ...systemPrompt, tools, model + }; +} +``` + +## Why This Works + +- `skillsOverride` (built by `createSkillsOverrideFromSelection`) is purely a **filter** over `base.skills`. To make a new skill *exist* in `base.skills`, **discovery** must be fed the path — exactly what `additionalSkillPaths` does on `DefaultResourceLoader`: it scans those dirs for the `/SKILL.md` layout, so the physically-installed skill now appears in `base.skills`. +- The convenience `skills` / `requestedSkillNames` param auto-builds a `SkillSelectionContext`, which makes the filter *include* that name instead of passing everything or nothing. +- Discovery (add via `additionalSkillPaths`) and selection (keep via `requestedSkillNames`) are now both satisfied, so the skill is loaded **and** retained — with `cwd` still on the project root, so context reads and artifact writes are unaffected. + +## Prevention + +A plugin author shipping a bundled skill should: + +1. **Physically install** the `SKILL.md` to a **plugin-local, discoverable** dir laid out as `//SKILL.md` (use `cpSync` + skip-if-exists). Never install into a global `~/.claude|.codex|.gemini/skills`; keep an explicit `assertPluginLocalTarget()` guard so a global install is never clobbered. +2. **Forward both** seam options when starting the session: `requestedSkillNames: [skillId]` (so the resolver keeps it) **and** `additionalSkillPaths: [installRoot]` (so the loader discovers it). One without the other silently no-ops — a name with no discovered file filters to `[]`; a discovered file with no requested name can be filtered out. +3. Remember `skillsOverride` only filters — declaring a `PluginSkillContribution` is **name-only** and never injects skill content into a live session. +4. **Prove it with a real `DefaultResourceLoader`** (see `packages/engine/src/__tests__/compound-engineering-skill-resolution.test.ts`) that asserts the skill actually appears in the resolved session skills — a scripted/fake session cannot catch a discovery gap. + +## Related Issues + +- `docs/PLUGIN_AUTHORING.md` (§skills) presents `skillFiles` as sufficient for surfacing bundled skills in sessions — now misleading; warrants a note that plugins must physically install + forward `additionalSkillPaths`. +- `docs/plans/2026-06-02-001-feat-compound-engineering-plugin-plan.md` assumed "`PluginSkillContribution.skillFiles` covers bundled skills" (KTD5) — corrected by this learning. +- `docs/brainstorms/2026-06-02-compound-engineering-plugin-requirements.md` (R11–R13) defines the plugin-local, never-global install rules this fix implements. +- No related GitHub issue exists (searched `plugin skill discovery`, `compound engineering skill` — zero matches). diff --git a/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts b/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts index 9b6dbc0de2..90887da9af 100644 --- a/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts +++ b/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts @@ -44,6 +44,7 @@ const CURSOR_PLUGIN_ID = "fusion-plugin-cursor-runtime"; const ROADMAP_PLUGIN_ID = "fusion-plugin-roadmap"; const REPORTS_PLUGIN_ID = "fusion-plugin-reports"; const CLI_PRINTING_PRESS_PLUGIN_ID = "fusion-plugin-cli-printing-press"; +const COMPOUND_ENGINEERING_PLUGIN_ID = "fusion-plugin-compound-engineering"; function makeManifest(overrides?: Partial<{ id: string; version: string; name: string }>) { return { @@ -315,6 +316,10 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => { it("includes reports plugin in bundled plugin ids", () => { expect(BUNDLED_PLUGIN_IDS).toContain(REPORTS_PLUGIN_ID); }); + + it("includes compound engineering plugin in bundled plugin ids", () => { + expect(BUNDLED_PLUGIN_IDS).toContain(COMPOUND_ENGINEERING_PLUGIN_ID); + }); it("fresh install: registers and loads the plugin when not in DB", async () => { setupBundleExists(); const store = makePluginStore(); diff --git a/packages/cli/src/plugins/bundled-plugin-install.ts b/packages/cli/src/plugins/bundled-plugin-install.ts index 506235bc9f..7ddf24a102 100644 --- a/packages/cli/src/plugins/bundled-plugin-install.ts +++ b/packages/cli/src/plugins/bundled-plugin-install.ts @@ -17,6 +17,7 @@ export const BUNDLED_PLUGIN_IDS = [ "fusion-plugin-paperclip-runtime", "fusion-plugin-cursor-runtime", "fusion-plugin-cli-printing-press", + "fusion-plugin-compound-engineering", ] as const; export type BundledPluginId = (typeof BUNDLED_PLUGIN_IDS)[number]; diff --git a/packages/core/src/__tests__/interactive-ai-session-seam.test.ts b/packages/core/src/__tests__/interactive-ai-session-seam.test.ts new file mode 100644 index 0000000000..3fe017a9b5 --- /dev/null +++ b/packages/core/src/__tests__/interactive-ai-session-seam.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getCreateInteractiveAiSessionFactory, + setCreateInteractiveAiSessionFactory, +} from "../ai-engine-loader.js"; +import { PluginLoader } from "../plugin-loader.js"; +import type { + CreateInteractiveAiSessionFactory, + InteractiveAiSession, + InteractiveAiSessionEvent, +} from "../plugin-types.js"; +import type { PlanningQuestion } from "../types.js"; + +/** + * A scripted fake interactive session: drives question → answer → complete + * deterministically so the route-context seam can be integration-tested + * without a live engine/model. + */ +function makeScriptedSession(script: InteractiveAiSessionEvent[]): InteractiveAiSession { + let cursor = -1; + return { + prompt: vi.fn(async () => { + cursor++; + }), + answer: vi.fn(async () => { + cursor++; + }), + nextEvent: vi.fn(async () => script[Math.min(cursor, script.length - 1)]), + dispose: vi.fn(), + } as InteractiveAiSession; +} + +afterEach(() => { + setCreateInteractiveAiSessionFactory(undefined); +}); + +describe("ai-engine-loader: interactive factory DI", () => { + it("returns undefined before registration", async () => { + await expect(getCreateInteractiveAiSessionFactory()).resolves.toBeUndefined(); + }); + + it("stores, returns, and clears the factory", async () => { + const factory: CreateInteractiveAiSessionFactory = vi.fn(async () => ({ + session: makeScriptedSession([{ type: "complete", data: {} }]), + })); + setCreateInteractiveAiSessionFactory(factory); + await expect(getCreateInteractiveAiSessionFactory()).resolves.toBe(factory); + setCreateInteractiveAiSessionFactory(undefined); + await expect(getCreateInteractiveAiSessionFactory()).resolves.toBeUndefined(); + }); +}); + +describe("interactive session injection boundary", () => { + function makeLoader() { + const pluginStore = { + getPlugin: vi.fn().mockResolvedValue({ settings: {} }), + } as never; + const taskStore = { getRootDir: () => "/tmp" } as never; + return new PluginLoader({ pluginStore, taskStore }); + } + + it("route context exposes createInteractiveAiSession when engine registered it; absent otherwise", async () => { + const loader = makeLoader(); + + // Not registered → undefined on route context. + const before = await loader.createRouteContext("fusion-plugin-x"); + expect(before.createInteractiveAiSession).toBeUndefined(); + + const factory: CreateInteractiveAiSessionFactory = vi.fn(async () => ({ + session: makeScriptedSession([{ type: "complete", data: {} }]), + })); + setCreateInteractiveAiSessionFactory(factory); + + const after = await loader.createRouteContext("fusion-plugin-x"); + expect(after.createInteractiveAiSession).toBe(factory); + }); + + it("drives a full question → answer → complete round trip from a route context", async () => { + const question: PlanningQuestion = { id: "q1", type: "single_select", question: "Pick", options: [{ id: "a", label: "A" }] }; + const session = makeScriptedSession([ + { type: "question", data: question }, + { type: "complete", data: { title: "ok" } }, + ]); + const factory: CreateInteractiveAiSessionFactory = vi.fn(async () => ({ session, sessionFile: "/tmp/s.json" })); + setCreateInteractiveAiSessionFactory(factory); + + const loader = makeLoader(); + const ctx = await loader.createRouteContext("fusion-plugin-x"); + expect(ctx.createInteractiveAiSession).toBeDefined(); + + const { session: s } = await ctx.createInteractiveAiSession!({ cwd: "/tmp", systemPrompt: "protocol" }); + + await s.prompt("start"); + const ev1 = await s.nextEvent(); + expect(ev1.type).toBe("question"); + expect(ev1.type === "question" && ev1.data.id).toBe("q1"); + + await s.answer("q1", "a"); + const ev2 = await s.nextEvent(); + expect(ev2.type).toBe("complete"); + + s.dispose(); + expect(s.dispose).toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/ai-engine-loader.ts b/packages/core/src/ai-engine-loader.ts index 182947cc13..3ea21c614d 100644 --- a/packages/core/src/ai-engine-loader.ts +++ b/packages/core/src/ai-engine-loader.ts @@ -10,7 +10,7 @@ * returns `undefined` and callers degrade gracefully. */ -import type { CreateAiSessionFactory } from "./plugin-types.js"; +import type { CreateAiSessionFactory, CreateInteractiveAiSessionFactory } from "./plugin-types.js"; // Engine exports a function type we intentionally don't pull in here — importing // the type would reintroduce the cycle this module is designed to avoid. @@ -19,6 +19,7 @@ type CreateFnAgent = any; let createFnAgent: CreateFnAgent | undefined; let createAiSessionFactory: CreateAiSessionFactory | undefined; +let createInteractiveAiSessionFactory: CreateInteractiveAiSessionFactory | undefined; /** Shape of a message in an agent session's state. */ export interface AgentMessage { @@ -57,3 +58,23 @@ export function setCreateAiSessionFactory(fn: CreateAiSessionFactory | undefined export async function getCreateAiSessionFactory(): Promise { return createAiSessionFactory; } + +/** + * Wire engine's plugin-facing interactive AI session factory into core. + * Called by `@fusion/engine` at module load; tests may register stubs. + */ +export function setCreateInteractiveAiSessionFactory( + fn: CreateInteractiveAiSessionFactory | undefined, +): void { + createInteractiveAiSessionFactory = fn; +} + +/** + * Returns engine-registered plugin interactive AI session factory, or + * `undefined` when engine hasn't registered it (common in isolated core tests). + */ +export async function getCreateInteractiveAiSessionFactory(): Promise< + CreateInteractiveAiSessionFactory | undefined +> { + return createInteractiveAiSessionFactory; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3c708e318e..2b7ebeeb3e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -61,6 +61,8 @@ export { getFnAgent, setCreateAiSessionFactory, getCreateAiSessionFactory, + setCreateInteractiveAiSessionFactory, + getCreateInteractiveAiSessionFactory, type AgentMessage, } from "./ai-engine-loader.js"; export { @@ -529,6 +531,12 @@ export type { CreateAiSessionOptions, AiSessionResult, CreateAiSessionFactory, + CreateInteractiveAiSessionOptions, + InteractiveAiSessionProgressEvent, + InteractiveAiSessionEvent, + InteractiveAiSession, + CreateInteractiveAiSessionResult, + CreateInteractiveAiSessionFactory, PluginLogger, PluginSkillContribution, PluginWorkflowStepContribution, diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index c2c3b1436d..4d06bb6532 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -40,7 +40,7 @@ import type { } from "./plugin-types.js"; import { normalizePluginUiContributionDefinition, validatePluginManifest } from "./plugin-types.js"; import { createLogger } from "./logger.js"; -import { getCreateAiSessionFactory } from "./ai-engine-loader.js"; +import { getCreateAiSessionFactory, getCreateInteractiveAiSessionFactory } from "./ai-engine-loader.js"; import { scanPluginSecurity } from "./plugin-security-scan.js"; // Minimum Fusion version for plugin compatibility checks (can be expanded later) @@ -120,9 +120,10 @@ export class PluginLoader extends EventEmitter<{ async createRouteContext( pluginId: string, - overrides?: Partial>, + overrides?: Partial>, ): Promise { const createAiSession = await getCreateAiSessionFactory(); + const createInteractiveAiSession = await getCreateInteractiveAiSessionFactory(); if (process.env.DEBUG?.includes("plugins")) { this.log.log( createAiSession @@ -137,11 +138,15 @@ export class PluginLoader extends EventEmitter<{ settings: overrides?.settings ?? await this.getPluginSettings(pluginId), logger: this.createLogger(pluginId), createAiSession, + createInteractiveAiSession, resolveProjectTaskStore: overrides?.resolveProjectTaskStore, - emitEvent: (event: string, data: unknown) => { - this.emit("plugin:error", { pluginId, error: new Error(`Custom event: ${event}`) }); + // The host (dashboard) may supply a real publisher that forwards custom + // plugin events to connected SSE clients. Absent an override, fall back to + // logging (the historical no-op behavior) so non-dashboard hosts and tests + // keep working. + emitEvent: overrides?.emitEvent ?? ((event: string, data: unknown) => { this.log.log(`[plugin:${pluginId}] Custom event: ${event}`, data); - }, + }), }; } diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts index b9665f27d4..2af8d98957 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -13,7 +13,7 @@ import type { Database } from "./db.js"; import type { TaskStore } from "./store.js"; -import type { Task, WorkflowStepMode, WorkflowStepToolMode } from "./types.js"; +import type { PlanningQuestion, Task, WorkflowStepMode, WorkflowStepToolMode } from "./types.js"; const SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; const PROMPT_CONTRIBUTION_SURFACES = ["executor-system", "executor-task", "triage", "reviewer", "heartbeat"] as const; @@ -121,6 +121,134 @@ export interface AiSessionResult { */ export type CreateAiSessionFactory = (options: CreateAiSessionOptions) => Promise; +// ── Interactive AI Sessions ─────────────────────────────────────────── +// +// A generic interactive (multi-turn, await-input) AI session capability. +// Unlike the one-shot `createAiSession` above, an interactive session can +// pause mid-agent-turn on a structured question and resume when the caller +// supplies an answer. The host (engine) builds the prompt → parse → retry → +// pause → resume loop; the caller drives it by pulling events. +// +// The protocol is deliberately generic: the caller supplies a `systemPrompt` +// that instructs the agent to emit the JSON question/complete contract +// (the same shape used by `PlanningResponse`). The seam hardcodes no +// application-specific (e.g. compound-engineering) prompts or concepts. + +/** + * Options for creating an interactive AI session. + * Mirrors {@link CreateAiSessionOptions}; the caller-supplied `systemPrompt` + * is responsible for instructing the agent to emit the question/complete + * JSON protocol that the seam parses. + */ +export interface CreateInteractiveAiSessionOptions { + /** Working directory for the agent session */ + cwd: string; + /** System prompt for the agent (must instruct it to emit the JSON protocol) */ + systemPrompt: string; + /** Tool mode: "coding" for full tools, "readonly" for read-only */ + tools?: "coding" | "readonly"; + /** Default model provider (e.g., "anthropic") */ + defaultProvider?: string; + /** Default model ID within the provider */ + defaultModelId?: string; + /** + * Skill names the session should load (matched against discovered skills). + * Lets a plugin point a session at a specific bundled skill rather than + * relying on cwd-only discovery. Forwarded to the engine's skill selection. + */ + requestedSkillNames?: string[]; + /** + * Extra directories to scan for skills (each holding `/SKILL.md`), in + * addition to the default cwd/agent-dir roots. A plugin that installs its + * skills to a plugin-local directory passes that directory here so its + * `requestedSkillNames` are actually discoverable in the live session. + */ + additionalSkillPaths?: string[]; + /** + * Live progress callback, invoked WHILE a turn runs (the pull-based + * `nextEvent()` only resolves once the turn settles). Receives streaming + * thinking/text deltas and tool start/end markers so a caller can surface + * the agent's work in real time. Optional; ignored by factories that cannot + * stream. Must not throw — implementations should swallow callback errors. + */ + onProgress?: (event: InteractiveAiSessionProgressEvent) => void; +} + +/** + * A live progress event emitted mid-turn via + * {@link CreateInteractiveAiSessionOptions.onProgress}. + * + * - `thinking` / `text`: an incremental output DELTA (not a snapshot) — the + * consumer accumulates. + * - `tool`: a discrete tool execution start/end marker. + */ +export type InteractiveAiSessionProgressEvent = + | { type: "thinking"; delta: string } + | { type: "text"; delta: string } + | { type: "tool"; name: string; phase: "start" | "end"; isError?: boolean }; + +/** + * A single event pulled from an interactive AI session. + * + * Discriminated union on `type`: + * - `thinking` / `text`: incremental agent output (data is a string). + * - `question`: the agent paused awaiting structured input; the session is + * now in awaiting-input until {@link InteractiveAiSession.answer} is called. + * `data` is a {@link PlanningQuestion} (reused for protocol parity). + * - `complete`: the agent finished; `data` is the final payload (shape is + * defined by the caller's protocol — opaque to the seam). + * - `error`: an agent/session/parse error; `data` carries a human-readable + * message and optional error detail. The caller is never left hanging. + */ +export type InteractiveAiSessionEvent = + | { type: "thinking"; data: string } + | { type: "text"; data: string } + | { type: "question"; data: PlanningQuestion } + | { type: "complete"; data: unknown } + | { type: "error"; data: { message: string; cause?: unknown } }; + +/** + * An interactive, multi-turn AI session. + * + * Event delivery is **pull-based**: the caller awaits {@link nextEvent} to get + * the next event. `nextEvent()` resolves once the session has produced an + * event for the most recent `prompt`/`answer`. A `question` event leaves the + * session in awaiting-input; the caller must call {@link answer} (not + * {@link prompt}) to resume. After a `complete` or `error` event the session + * is terminal and `nextEvent()` will keep returning that terminal event. + * + * (Pull-based `nextEvent()` is chosen over an async iterator because it is the + * simpler shape to drive deterministically from a route/test: each turn is one + * `prompt`/`answer` followed by one awaited `nextEvent`.) + */ +export interface InteractiveAiSession { + /** Send a free-text turn to the agent (the opening turn, or follow-up text). */ + prompt(text: string): Promise; + /** Pull the next event produced by the most recent prompt/answer. */ + nextEvent(): Promise; + /** Answer the currently-awaiting question, resuming the agent. */ + answer(questionId: string, response: unknown): Promise; + /** Release the underlying agent/session handles. Safe to call repeatedly. */ + dispose(): void; +} + +/** + * Result returned from creating an interactive AI session. + */ +export interface CreateInteractiveAiSessionResult { + /** The interactive session handle. */ + session: InteractiveAiSession; + /** Path to persisted session file, if any. */ + sessionFile?: string; +} + +/** + * Engine-injected factory for plugin interactive AI sessions. + */ +export type CreateInteractiveAiSessionFactory = ( + options: CreateInteractiveAiSessionOptions, +) => Promise; + /** * Context object passed to plugins at runtime. * Contains task store access, settings, logging, and event emission. @@ -137,6 +265,12 @@ export interface PluginContext { emitEvent: (event: string, data: unknown) => void; /** Engine-injected AI session factory (undefined when engine is not loaded) */ createAiSession?: CreateAiSessionFactory; + /** + * Engine-injected interactive (multi-turn, await-input) AI session factory. + * Undefined when the engine is not loaded or on non-route contexts (parity + * with `createAiSession`). + */ + createInteractiveAiSession?: CreateInteractiveAiSessionFactory; /** Optional host capability to resolve a project-scoped TaskStore by projectId. */ resolveProjectTaskStore?: (projectId: string) => Promise; } diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 715fe9994c..3bcf1d8d9f 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -311,7 +311,34 @@ function AppInner() { setBaseBranchFilter(value); setScopedItem(BASE_BRANCH_FILTER_STORAGE_KEY, value, currentProject?.id); }, [currentProject?.id]); - + + // Host capability handed to plugin dashboard views: subscribe to a plugin's + // custom SSE events (forwarded by the server as `plugin:custom`, scoped to the + // current project) over the shared bus — so plugins push live updates without + // deep-importing the dashboard's sse-bus or opening their own EventSource. + const subscribePluginEvents = useCallback( + (pluginId: string, onEvent: (e: { event: string; payload: unknown }) => void) => { + const params = new URLSearchParams(); + if (currentProject?.id) params.set("projectId", currentProject.id); + const query = params.size > 0 ? `?${params.toString()}` : ""; + return subscribeSse(`/api/events${query}`, { + events: { + "plugin:custom": (event: MessageEvent) => { + try { + const d = JSON.parse(event.data) as { pluginId?: string; event?: string; payload?: unknown }; + if (d.pluginId === pluginId && typeof d.event === "string") { + onEvent({ event: d.event, payload: d.payload }); + } + } catch { + // Ignore malformed plugin:custom payloads. + } + }, + }, + }); + }, + [currentProject?.id], + ); + // Remote node data and events when in remote mode (pass searchQuery for server-side filtering) const remoteData = useRemoteNodeData(currentNodeId, { projectId: currentProject?.id, searchQuery: searchQuery || undefined }); useRemoteNodeEvents(currentNodeId); @@ -1385,6 +1412,7 @@ function AppInner() { projectId: currentProject?.id, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, + subscribePluginEvents, openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => openDetailTask(task, initialTab), renderTaskCard: (task: Task | TaskDetail) => ( { return { default: component as PluginViewComponent }; } +async function loadCompoundEngineeringView(): Promise<{ default: PluginViewComponent }> { + // @vite-ignore + moduleId variable so tsc does NOT statically resolve/compile + // the plugin's source here. The plugin must not depend on @fusion/dashboard + // (workspace-acyclicity invariant), so its type-only dashboard import only + // resolves in the plugin's own build; a literal import would make the + // dashboard typecheck the plugin file and fail to resolve that import. + const moduleId = "@fusion-plugin-examples/compound-engineering/dashboard-view"; + const exportName = "CompoundEngineeringDashboardView"; + try { + const mod = await import(/* @vite-ignore */ moduleId) as unknown as Record>; + const component = mod[exportName]; + if (!component) { + console.warn(`[plugin-views] Missing export ${exportName} from ${moduleId}`); + return { default: createMissingPluginView(moduleId, exportName) }; + } + return { default: component as PluginViewComponent }; + } catch { + return { default: createMissingPluginView(moduleId, exportName) }; + } +} + async function loadCliPrintingPressWizardView(): Promise<{ default: PluginViewComponent }> { const moduleId = "@fusion-plugin-examples/cli-printing-press/dashboard-view"; const exportName = "CliPrintingPressWizardView"; @@ -85,6 +106,12 @@ export function registerBundledPluginViews(): void { lazy(loadRoadmapView), ); + registerPluginView( + "fusion-plugin-compound-engineering", + "compound-engineering", + lazy(loadCompoundEngineeringView), + ); + registerPluginView( "fusion-plugin-cli-printing-press", "wizard", diff --git a/packages/dashboard/app/plugins/types.ts b/packages/dashboard/app/plugins/types.ts index b8ff4b17c3..8bf3ab756f 100644 --- a/packages/dashboard/app/plugins/types.ts +++ b/packages/dashboard/app/plugins/types.ts @@ -16,6 +16,14 @@ export type DetailTaskTab = "definition" | "logs" | "changes" | "comments" | "mo export type PluginToastType = "success" | "error" | "warning" | "info"; +/** A custom event a plugin pushed via `ctx.emitEvent`, delivered over SSE. */ +export interface PluginCustomEvent { + /** The event name the plugin emitted (e.g. "myplugin:thing-happened"). */ + event: string; + /** The event payload the plugin emitted. */ + payload: unknown; +} + /** Runtime context passed to a plugin dashboard view component. */ export interface PluginDashboardViewContext { projectId?: string; @@ -24,6 +32,16 @@ export interface PluginDashboardViewContext { openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void; renderTaskCard?: (task: Task | TaskDetail) => ReactNode; addToast?: (message: string, type?: PluginToastType) => void; + /** + * Subscribe to this plugin's custom SSE events (the host forwards + * `plugin:custom` events a plugin pushed via `ctx.emitEvent`, scoped to the + * current project). Returns an unsubscribe function. Absent when the host + * doesn't provide a realtime stream; consumers should fall back to polling. + */ + subscribePluginEvents?: ( + pluginId: string, + onEvent: (event: PluginCustomEvent) => void, + ) => () => void; } /** Composite view ID format: `plugin:{pluginId}:{viewId}`. */ diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 436fd0fb7a..34a2610455 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -90,6 +90,7 @@ "@codemirror/state": "^6.5.2", "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "^6.36.4", + "@fusion-plugin-examples/compound-engineering": "workspace:*", "@fusion-plugin-examples/dependency-graph": "workspace:*", "@fusion-plugin-examples/roadmap": "workspace:*", "@fusion-plugin-examples/hermes-runtime": "workspace:*", diff --git a/packages/dashboard/src/__tests__/sse.test.ts b/packages/dashboard/src/__tests__/sse.test.ts index b17b8c7bc7..b10499aceb 100644 --- a/packages/dashboard/src/__tests__/sse.test.ts +++ b/packages/dashboard/src/__tests__/sse.test.ts @@ -6,6 +6,7 @@ import { createSSE, disconnectSSEClient, emitApprovalSseEvent, + emitPluginCustomSseEvent, getActiveSSEConnections, markSSEClientAlive, } from "../sse.js"; @@ -146,6 +147,45 @@ describe("approval SSE events", () => { }); }); +describe("plugin custom SSE events", () => { + it("relays a plugin's custom event to connected clients as plugin:custom", () => { + const connection = openSseConnection("plugin-custom-relay"); + + emitPluginCustomSseEvent("fusion-plugin-compound-engineering", "ce:session-question", { + sessionId: "s1", + questionId: "q1", + }); + + expect(connection.res.write).toHaveBeenCalledWith( + `event: plugin:custom\ndata: ${JSON.stringify({ + pluginId: "fusion-plugin-compound-engineering", + event: "ce:session-question", + payload: { sessionId: "s1", questionId: "q1" }, + })}\n\n`, + ); + + connection.req.emit("close"); + }); + + it("scopes project-tagged plugin events to the matching project connection", () => { + const projectA = openSseConnection("plugin-custom-project", "project-a"); + const projectB = openSseConnection("plugin-custom-project", "project-b"); + + emitPluginCustomSseEvent("p", "evt", { sessionId: "s1" }, "project-a"); + + const expected = `event: plugin:custom\ndata: ${JSON.stringify({ + pluginId: "p", + event: "evt", + payload: { sessionId: "s1" }, + })}\n\n`; + expect(projectA.res.write).toHaveBeenCalledWith(expected); + expect(projectB.res.write).not.toHaveBeenCalledWith(expected); + + projectA.req.emit("close"); + projectB.req.emit("close"); + }); +}); + describe("automation store SSE events", () => { it("subscribes to all automation store events", () => { const connection = openSseConnectionWithAutomation("automation-subscribe"); diff --git a/packages/dashboard/src/plugin-routes.ts b/packages/dashboard/src/plugin-routes.ts index 5c34b1e2a9..d12cf471d8 100644 --- a/packages/dashboard/src/plugin-routes.ts +++ b/packages/dashboard/src/plugin-routes.ts @@ -18,6 +18,7 @@ import { Router, type Request, type Response } from "express"; import { access, stat, readFile } from "node:fs/promises"; import { join, isAbsolute, dirname, basename } from "node:path"; +import { emitPluginCustomSseEvent } from "./sse.js"; import type { PluginLoader, PluginStore, @@ -573,6 +574,12 @@ export function createPluginRouter( taskStore, settings, resolveProjectTaskStore: getOrCreateProjectStore, + // Real publish-to-/api/events seam: forward custom plugin events to + // connected SSE clients, scoped to the request's project so a + // project stream only sees its own events. + emitEvent: (event: string, data: unknown) => { + emitPluginCustomSseEvent(pluginId, event, data, projectId); + }, }); // Call the route handler with Express Request cast to unknown diff --git a/packages/dashboard/src/sse.ts b/packages/dashboard/src/sse.ts index 6cc76eb21d..3d5cf4a20b 100644 --- a/packages/dashboard/src/sse.ts +++ b/packages/dashboard/src/sse.ts @@ -234,6 +234,34 @@ export function emitApprovalSseEvent(event: ApprovalSseEventType, payload: unkno } } +/** + * Custom plugin events forwarded to connected SSE clients. This is the real + * publish-to-`/api/events` seam plugins reach through `ctx.emitEvent`: the + * dashboard wires a plugin route context's `emitEvent` to call this, and each + * open SSE stream forwards matching (project-scoped) events to the browser as a + * single `plugin:custom` event. Lets a plugin push live updates (e.g. CE session + * turns) instead of relying on client polling. + */ +export type PluginCustomSseListener = ( + pluginId: string, + event: string, + payload: unknown, + projectId?: string, +) => void; + +const pluginCustomSseListeners = new Set(); + +export function emitPluginCustomSseEvent( + pluginId: string, + event: string, + payload: unknown, + projectId?: string, +): void { + for (const listener of pluginCustomSseListeners) { + listener(pluginId, event, payload, projectId); + } +} + /** * Normalized plugin lifecycle payload emitted via SSE. * This is the stable contract the UI can reconcile. @@ -591,6 +619,13 @@ export function createSSE( send(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`); }; + const onPluginCustomEvent: PluginCustomSseListener = (pluginId, event, payload, eventProjectId) => { + // Scope match mirrors approvals: a project-scoped stream only forwards + // events for its own project; the default stream forwards unscoped events. + if (projectId && eventProjectId && eventProjectId !== projectId) return; + send(`event: plugin:custom\ndata: ${JSON.stringify({ pluginId, event, payload })}\n\n`); + }; + // --- Chat store event handlers --- const onChatSessionCreated = (session: unknown) => { send(`event: chat:session:created\ndata: ${JSON.stringify(session)}\n\n`); @@ -740,6 +775,7 @@ export function createSSE( messageStore.off("message:deleted", onMessageDeleted); } approvalSseListeners.delete(onApprovalEvent); + pluginCustomSseListeners.delete(onPluginCustomEvent); if (chatStore) { chatStore.off("chat:session:created", onChatSessionCreated); chatStore.off("chat:session:updated", onChatSessionUpdated); @@ -886,6 +922,7 @@ export function createSSE( // (SSE comments starting with ":" are silently consumed and never // fire event listeners in the browser). approvalSseListeners.add(onApprovalEvent); + pluginCustomSseListeners.add(onPluginCustomEvent); registerManagedConnection({ id: connectionId, diff --git a/packages/engine/src/__tests__/compound-engineering-skill-resolution.test.ts b/packages/engine/src/__tests__/compound-engineering-skill-resolution.test.ts new file mode 100644 index 0000000000..81bf1c3690 --- /dev/null +++ b/packages/engine/src/__tests__/compound-engineering-skill-resolution.test.ts @@ -0,0 +1,194 @@ +/** + * U2 — Empirical proof of how Compound Engineering bundled skills become + * resolvable in an agent session. + * + * This drives the REAL engine skill pipeline: + * pi-coding-agent `loadSkills` (disk discovery) → + * `resolveSessionSkills` + `createSkillsOverrideFromSelection` (the same + * path `createFnAgent` uses in pi.ts via DefaultResourceLoader.skillsOverride). + * + * THE QUESTION: does declaring `skills: PluginSkillContribution[]` (whose + * contribution surfaces only as a *name* in `requestedSkillNames`) make a + * bundled SKILL.md resolvable, OR is a physical install into a discoverable + * directory also required? + * + * ANSWER (asserted below): the contribution alone is NOT enough. The engine + * never ingests `PluginSkillContribution.skillFiles` into the discovered set; + * the requested name has nothing on disk to match. A physical, plugin-local + * install (so the SKILL.md lives on a path `loadSkills` scans) is REQUIRED. + * + * The test is self-contained on the engine side: it models "a physical install" + * by materializing a `ce-plan/SKILL.md` on disk and pointing disk discovery at + * its parent dir — exactly what the plugin's `installBundledCeSkills` does into + * a plugin-local directory wired through `additionalSkillPaths`. (The plugin's + * own cpSync + isolation behavior is verified in the plugin package's + * skill-installation.test.ts; the engine package cannot import plugin source + * without violating its tsc rootDir.) + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DefaultResourceLoader, loadSkills, type Skill } from "@earendil-works/pi-coding-agent"; +import { + createSkillsOverrideFromSelection, + resolveSessionSkills, +} from "../skill-resolver.js"; + +vi.mock("../logger.js", () => ({ + piLog: { log: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +const CE_STAGES = [ + "ce-strategy", + "ce-ideate", + "ce-brainstorm", + "ce-plan", + "ce-work", + "ce-code-review", + "ce-compound", +] as const; + +/** Model the plugin-local physical install: write each stage's SKILL.md to disk. */ +function materializeInstalledSkills(root: string, stages: readonly string[]): void { + for (const id of stages) { + const dir = join(root, id); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "SKILL.md"), + `---\nname: ${id}\ndescription: ${id} pipeline stage\n---\n\n# ${id}\n`, + ); + } +} + +/** + * Run the exact engine resolution path for a session that requests CE skill + * names (as if a plugin contributed them via getPluginSkills -> + * requestedSkillNames), over whatever skills `loadSkills` discovers from + * `discoveredSkillPaths`. Returns the resolved skill names visible to the + * session. + */ +function resolveSessionFor(opts: { + projectRootDir: string; + agentDir: string; + discoveredSkillPaths: string[]; + requestedSkillNames: string[]; +}): string[] { + // 1. Disk discovery — exactly what DefaultResourceLoader feeds to its override. + const discovered = loadSkills({ + cwd: opts.projectRootDir, + agentDir: opts.agentDir, + skillPaths: opts.discoveredSkillPaths, + includeDefaults: false, + }); + + // 2. Engine resolver (project settings + requested names). + const selection = resolveSessionSkills({ + projectRootDir: opts.projectRootDir, + requestedSkillNames: opts.requestedSkillNames, + sessionPurpose: "executor", + }); + const override = createSkillsOverrideFromSelection(selection, { + requestedSkillNames: opts.requestedSkillNames, + sessionPurpose: "executor", + }); + + const result = override({ skills: discovered.skills, diagnostics: discovered.diagnostics }); + return result.skills.map((s) => s.name); +} + +describe("U2: CE bundled skill session-resolution (empirical)", () => { + let tmp: string; + let projectRootDir: string; + let agentDir: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "ce-resolve-")); + // An empty project + empty agent dir: NOTHING ce-* is discoverable yet. + projectRootDir = join(tmp, "project"); + agentDir = join(tmp, "agent"); + }); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it("FAILING-FIRST: contribution name alone (no physical install) does NOT resolve ce-plan", () => { + // Simulate: plugin declared skills -> requestedSkillNames includes ce-plan, + // but no SKILL.md was installed anywhere discoverable. + const resolved = resolveSessionFor({ + projectRootDir, + agentDir, + discoveredSkillPaths: [], // nothing on disk + requestedSkillNames: ["ce-plan"], + }); + // Proves the contribution alone is insufficient: ce-plan is NOT resolvable. + expect(resolved).not.toContain("ce-plan"); + expect(resolved).toEqual([]); + }); + + it("PASSING: after a plugin-local physical install, ce-plan IS resolvable for the session", () => { + const installRoot = join(tmp, "plugin-local", ".fusion-ce-skills"); + materializeInstalledSkills(installRoot, ["ce-plan"]); + + const resolved = resolveSessionFor({ + projectRootDir, + agentDir, + discoveredSkillPaths: [installRoot], // installed dir is now discoverable + requestedSkillNames: ["ce-plan"], + }); + + expect(resolved).toContain("ce-plan"); + }); + + it("PASSING: all seven CE stages resolve when requested after install", () => { + const installRoot = join(tmp, ".fusion-ce-skills"); + materializeInstalledSkills(installRoot, CE_STAGES); + + const resolved = resolveSessionFor({ + projectRootDir, + agentDir, + discoveredSkillPaths: [installRoot], + requestedSkillNames: [...CE_STAGES], + }); + for (const s of CE_STAGES) { + expect(resolved).toContain(s); + } + }); + + it("PASSING (real loader): DefaultResourceLoader with additionalSkillPaths + skillsOverride discovers ce-plan — the exact path createFnAgent now feeds", async () => { + const installRoot = join(tmp, ".fusion-ce-skills"); + materializeInstalledSkills(installRoot, ["ce-plan", "ce-work"]); + mkdirSync(projectRootDir, { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + + // Build the same skillsOverride createFnAgent builds from `skills: ["ce-plan"]`. + const selection = resolveSessionSkills({ + projectRootDir, + requestedSkillNames: ["ce-plan"], + sessionPurpose: "executor", + }); + const skillsOverride = createSkillsOverrideFromSelection(selection, { + requestedSkillNames: ["ce-plan"], + sessionPurpose: "executor", + }); + + // Construct the loader exactly as pi.ts createFnAgent now does: cwd on the + // project root, the install dir passed via additionalSkillPaths, and the + // requested-name filter as skillsOverride. + const loader = new DefaultResourceLoader({ + cwd: projectRootDir, + agentDir, + additionalSkillPaths: [installRoot], + skillsOverride, + }); + await loader.reload(); + + const names = loader.getSkills().skills.map((s: Skill) => s.name); + // ce-plan is discoverable (via additionalSkillPaths) AND survives the filter; + // ce-work is discovered but filtered out by the requested-name override. + expect(names).toContain("ce-plan"); + expect(names).not.toContain("ce-work"); + }); +}); diff --git a/packages/engine/src/__tests__/interactive-ai-session.test.ts b/packages/engine/src/__tests__/interactive-ai-session.test.ts new file mode 100644 index 0000000000..af876cfba7 --- /dev/null +++ b/packages/engine/src/__tests__/interactive-ai-session.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PlanningQuestion, PlanningResponse } from "@fusion/core"; +import { + createInteractiveAiSessionWith, + type InteractiveAgentResult, + type InteractiveAgentSession, +} from "../interactive-ai-session.js"; + +/** + * A scripted fake agent: each `prompt()` advances through a queue of canned + * assistant responses, which are exposed via `state.messages` exactly like the + * real one-shot agent. This deterministically drives the seam's turn loop + * without a live model (the accepted integration approach per the plan). + */ +function makeScriptedAgent(responses: string[]): { + session: InteractiveAgentSession; + disposed: () => boolean; + promptCalls: () => string[]; +} { + let index = 0; + let wasDisposed = false; + const prompts: string[] = []; + const messages: InteractiveAgentSession["state"]["messages"] = []; + + const session: InteractiveAgentSession = { + prompt: vi.fn(async (text: string) => { + prompts.push(text); + const reply = responses[index] ?? responses[responses.length - 1]; + index++; + messages.push({ role: "assistant", content: reply }); + }), + state: { messages }, + dispose: vi.fn(() => { + wasDisposed = true; + }), + }; + + return { session, disposed: () => wasDisposed, promptCalls: () => prompts }; +} + +function factoryFor(agent: InteractiveAgentSession): () => Promise { + return async () => ({ session: agent, sessionFile: "/tmp/fake-session.json" }); +} + +const q = (data: PlanningQuestion): string => JSON.stringify({ type: "question", data } satisfies PlanningResponse); +const complete = (data: unknown): string => JSON.stringify({ type: "complete", data }); + +describe("interactive-ai-session seam", () => { + it("round-trips question → answer → complete (happy path)", async () => { + const question: PlanningQuestion = { + id: "q1", + type: "text", + question: "What is the goal?", + }; + const scripted = makeScriptedAgent([ + q(question), + complete({ title: "Done", summary: "ok" }), + ]); + + const { session } = await createInteractiveAiSessionWith(factoryFor(scripted.session), { + cwd: "/tmp", + systemPrompt: "emit json protocol", + }); + + await session.prompt("start"); + const ev1 = await session.nextEvent(); + expect(ev1.type).toBe("question"); + expect(ev1.type === "question" && ev1.data.id).toBe("q1"); + + await session.answer("q1", "ship the thing"); + const ev2 = await session.nextEvent(); + expect(ev2.type).toBe("complete"); + expect(ev2.type === "complete" && ev2.data).toEqual({ title: "Done", summary: "ok" }); + + // nextEvent stays terminal after complete. + expect((await session.nextEvent()).type).toBe("complete"); + + session.dispose(); + expect(scripted.disposed()).toBe(true); + }); + + it.each([ + ["text", { id: "t", type: "text", question: "Free text?" } as PlanningQuestion, "a free answer"], + [ + "single_select", + { + id: "s", + type: "single_select", + question: "Pick one", + options: [{ id: "a", label: "A" }, { id: "b", label: "B" }], + } as PlanningQuestion, + "a", + ], + [ + "multi_select", + { + id: "m", + type: "multi_select", + question: "Pick many", + options: [{ id: "x", label: "X" }, { id: "y", label: "Y" }], + } as PlanningQuestion, + ["x", "y"], + ], + ["confirm", { id: "c", type: "confirm", question: "Sure?" } as PlanningQuestion, true], + ])("round-trips %s question type", async (_name, question, answer) => { + const scripted = makeScriptedAgent([q(question), complete({ ok: true })]); + const { session } = await createInteractiveAiSessionWith(factoryFor(scripted.session), { + cwd: "/tmp", + systemPrompt: "protocol", + }); + + await session.prompt("start"); + const ev = await session.nextEvent(); + expect(ev.type).toBe("question"); + expect(ev.type === "question" && ev.data.type).toBe(question.type); + + await session.answer(question.id, answer); + const done = await session.nextEvent(); + expect(done.type).toBe("complete"); + + // The structured answer is forwarded to the agent as JSON. + const lastPrompt = scripted.promptCalls().at(-1)!; + expect(JSON.parse(lastPrompt)).toMatchObject({ type: "answer", questionId: question.id, response: answer }); + }); + + it("retries once on unparseable output then surfaces an error event (no hang)", async () => { + // First turn: garbage. Reformat retry: still garbage. → error. + const scripted = makeScriptedAgent(["not json at all", "still not json"]); + const { session } = await createInteractiveAiSessionWith(factoryFor(scripted.session), { + cwd: "/tmp", + systemPrompt: "protocol", + }); + + await session.prompt("start"); + const ev = await session.nextEvent(); + expect(ev.type).toBe("error"); + expect(ev.type === "error" && ev.data.message).toMatch(/parse/i); + + // The reformat-retry prompt was actually sent (2 prompts: initial + retry). + expect(scripted.promptCalls().length).toBe(2); + + // Terminal: nextEvent keeps returning the error, never hangs. + expect((await session.nextEvent()).type).toBe("error"); + }); + + it("recovers when the reformat retry produces valid JSON", async () => { + const question: PlanningQuestion = { id: "q1", type: "text", question: "?" }; + const scripted = makeScriptedAgent(["garbage", q(question)]); + const { session } = await createInteractiveAiSessionWith(factoryFor(scripted.session), { + cwd: "/tmp", + systemPrompt: "protocol", + }); + + await session.prompt("start"); + const ev = await session.nextEvent(); + expect(ev.type).toBe("question"); + }); + + it("surfaces agent prompt errors as an error event without throwing", async () => { + const throwing: InteractiveAgentSession = { + prompt: vi.fn(async () => { + throw new Error("transport exploded"); + }), + state: { messages: [] }, + dispose: vi.fn(), + }; + const { session } = await createInteractiveAiSessionWith(factoryFor(throwing), { + cwd: "/tmp", + systemPrompt: "protocol", + }); + + await expect(session.prompt("start")).resolves.toBeUndefined(); + const ev = await session.nextEvent(); + expect(ev.type).toBe("error"); + expect(ev.type === "error" && ev.data.message).toMatch(/transport exploded/); + }); + + it("ignores answer() when not awaiting input", async () => { + const scripted = makeScriptedAgent([complete({ ok: true })]); + const { session } = await createInteractiveAiSessionWith(factoryFor(scripted.session), { + cwd: "/tmp", + systemPrompt: "protocol", + }); + + await session.prompt("start"); + expect((await session.nextEvent()).type).toBe("complete"); + + // answer() after terminal is a no-op; nextEvent stays complete. + await session.answer("whatever", "x"); + expect((await session.nextEvent()).type).toBe("complete"); + }); +}); diff --git a/packages/engine/src/__tests__/plugin-runner.test.ts b/packages/engine/src/__tests__/plugin-runner.test.ts index e23b0aff3e..9b45922a8c 100644 --- a/packages/engine/src/__tests__/plugin-runner.test.ts +++ b/packages/engine/src/__tests__/plugin-runner.test.ts @@ -1603,4 +1603,25 @@ describe("PluginRunner", () => { expect(true).toBe(true); // Handler exists and doesn't throw }); }); + + describe("interactive AI session injection boundary", () => { + it("does NOT expose createInteractiveAiSession on runtime contexts (parity with createAiSession)", async () => { + // Register a factory the way the engine module-load block would. + const core = await import("@fusion/core"); + core.setCreateInteractiveAiSessionFactory( + async () => ({ session: {} as never }), + ); + try { + mockPluginLoader.getPlugin.mockReturnValue(createMockPlugin({ state: "started" })); + const ctx = await pluginRunner.createRuntimeContext("test-plugin"); + expect(ctx).not.toBeNull(); + // Tool/runtime contexts must not receive the interactive factory, + // exactly as they do not receive createAiSession. + expect(ctx?.createAiSession).toBeUndefined(); + expect(ctx?.createInteractiveAiSession).toBeUndefined(); + } finally { + core.setCreateInteractiveAiSessionFactory(undefined); + } + }); + }); }); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 71bed8fbb2..863a212463 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -112,12 +112,26 @@ export { } from "./merger-squash-audit.js"; export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js"; export { createFnAgent, promptWithFallback, describeModel, setHostExtensionPaths, getHostExtensionPaths, type AgentOptions, type AgentResult } from "./pi.js"; +export { + createInteractiveAiSessionWith, + parseAgentResponse as parseInteractiveAgentResponse, + type InteractiveAgentSession, + type InteractiveAgentResult, + type InteractiveAgentFactory, +} from "./interactive-ai-session.js"; // Register createFnAgent into core's loader so consumers in @fusion/core // (e.g. ai-summarize, memory-compaction) can resolve it without a circular // static import. Runs once at engine module load. -import type { AiSessionResult, CreateAiSessionFactory, CreateAiSessionOptions } from "@fusion/core"; +import type { + AiSessionResult, + CreateAiSessionFactory, + CreateAiSessionOptions, + CreateInteractiveAiSessionFactory, + CreateInteractiveAiSessionOptions, +} from "@fusion/core"; import { createFnAgent as _createFnAgentForCore } from "./pi.js"; +import { createInteractiveAiSessionWith } from "./interactive-ai-session.js"; const _createAiSessionAdapter: CreateAiSessionFactory = async (options: CreateAiSessionOptions): Promise => { return _createFnAgentForCore({ @@ -129,6 +143,56 @@ const _createAiSessionAdapter: CreateAiSessionFactory = async (options: CreateAi }); }; +// Interactive (multi-turn, await-input) adapter: builds the prompt→parse→ +// retry→pause→resume loop on top of the one-shot createFnAgent. +const _createInteractiveAiSessionAdapter: CreateInteractiveAiSessionFactory = ( + options: CreateInteractiveAiSessionOptions, +) => + createInteractiveAiSessionWith( + (opts) => + _createFnAgentForCore({ + cwd: opts.cwd, + systemPrompt: opts.systemPrompt, + tools: opts.tools, + defaultProvider: opts.defaultProvider, + defaultModelId: opts.defaultModelId, + // Forward skill selection so a plugin can load a specific bundled skill. + // `skills` (convenience) auto-builds a SkillSelectionContext; the extra + // discovery dirs make those skills actually visible to the loader. + ...(opts.requestedSkillNames?.length ? { skills: opts.requestedSkillNames } : {}), + ...(opts.additionalSkillPaths?.length ? { additionalSkillPaths: opts.additionalSkillPaths } : {}), + // Live mid-turn visibility: stream thinking/text deltas and tool + // start/end markers to the caller's onProgress while the pull-based + // nextEvent() is still pending. Callback errors must never break the + // agent turn. + ...(opts.onProgress + ? { + onThinking: (delta: string) => { + try { + opts.onProgress!({ type: "thinking", delta }); + } catch { /* consumer error must not break the turn */ } + }, + onText: (delta: string) => { + try { + opts.onProgress!({ type: "text", delta }); + } catch { /* consumer error must not break the turn */ } + }, + onToolStart: (name: string) => { + try { + opts.onProgress!({ type: "tool", name, phase: "start" }); + } catch { /* consumer error must not break the turn */ } + }, + onToolEnd: (name: string, isError: boolean) => { + try { + opts.onProgress!({ type: "tool", name, phase: "end", isError }); + } catch { /* consumer error must not break the turn */ } + }, + } + : {}), + }), + options, + ); + void import("@fusion/core") .then((core) => { if ("setCreateFnAgent" in core && typeof core.setCreateFnAgent === "function") { @@ -137,6 +201,9 @@ void import("@fusion/core") if ("setCreateAiSessionFactory" in core && typeof core.setCreateAiSessionFactory === "function") { core.setCreateAiSessionFactory(_createAiSessionAdapter); } + if ("setCreateInteractiveAiSessionFactory" in core && typeof core.setCreateInteractiveAiSessionFactory === "function") { + core.setCreateInteractiveAiSessionFactory(_createInteractiveAiSessionAdapter); + } }) .catch(() => { // Ignore loader registration failures in constrained test/mocked environments. diff --git a/packages/engine/src/interactive-ai-session.ts b/packages/engine/src/interactive-ai-session.ts new file mode 100644 index 0000000000..f175309b76 --- /dev/null +++ b/packages/engine/src/interactive-ai-session.ts @@ -0,0 +1,349 @@ +/** + * Interactive AI session adapter (the U4 host seam). + * + * Builds a generic prompt → parse → retry → pause → resume loop on top of the + * one-shot `createFnAgent`, modeled on `packages/dashboard/src/planning.ts`. + * There is NO engine await-input primitive to call — this module IS that loop. + * + * Kept deliberately generic: it knows nothing about compound-engineering (or + * any other application). The caller supplies a system prompt instructing the + * agent to emit the JSON question/complete protocol; this module parses it and + * surfaces structured events. To avoid leaking dashboard types into the seam, + * the JSON parse/extract/repair helpers are reimplemented locally here rather + * than imported from `@fusion/dashboard`. + */ + +import type { + CreateInteractiveAiSessionOptions, + CreateInteractiveAiSessionResult, + InteractiveAiSession, + InteractiveAiSessionEvent, + PlanningQuestion, + PlanningResponse, +} from "@fusion/core"; + +/** Minimal shape of an agent session we depend on (subset of pi's AgentSession). */ +export interface InteractiveAgentSession { + prompt(text: string): Promise; + state: { + messages: Array<{ + role: string; + content?: string | Array<{ type: string; text?: string; thinking?: string }>; + }>; + }; + dispose?: () => void | Promise; +} + +/** Minimal shape of an agent factory result. */ +export interface InteractiveAgentResult { + session: InteractiveAgentSession; + sessionFile?: string; +} + +/** Factory that creates the underlying one-shot agent (injectable for tests). */ +export type InteractiveAgentFactory = ( + options: CreateInteractiveAiSessionOptions, +) => Promise; + +/** One bounded reformat retry, matching planning.ts's MAX_PARSE_RETRIES. */ +const MAX_PARSE_RETRIES = 1; + +const REFORMAT_PROMPT = + "Your previous response could not be parsed as JSON. " + + 'Please respond with ONLY a valid JSON object: {"type":"question","data":{...}} ' + + 'or {"type":"complete","data":{...}}. No markdown, no explanation, just the JSON.'; + +// ── Local JSON extraction/repair (reimplemented to keep core generic) ────── + +function extractJsonCandidate(text: string): string | null { + if (!text || !text.trim()) return null; + + // 1. Markdown code blocks first (most reliable). + const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/); + if (codeBlockMatch?.[1]) { + const candidate = codeBlockMatch[1].trim(); + if (candidate.startsWith("{")) return candidate; + } + + // 2. Balanced top-level brace objects. + const candidates: Array<{ text: string }> = []; + for (let i = 0; i < text.length; i++) { + if (text[i] !== "{") continue; + let depth = 0; + let inString = false; + let escape = false; + for (let j = i; j < text.length; j++) { + const ch = text[j]; + if (escape) { + escape = false; + continue; + } + if (ch === "\\") { + escape = true; + continue; + } + if (ch === '"') { + inString = !inString; + continue; + } + if (inString) continue; + if (ch === "{") depth++; + if (ch === "}") depth--; + if (depth === 0) { + const candidate = text.slice(i, j + 1).trim(); + try { + JSON.parse(candidate); + candidates.push({ text: candidate }); + } catch { + // not valid JSON, skip + } + break; + } + } + } + if (candidates.length > 0) { + candidates.sort((a, b) => b.text.length - a.text.length); + return candidates[0].text; + } + + // 3. Last resort: full trimmed text. + const trimmed = text.trim(); + if (trimmed.startsWith("{")) return trimmed; + return null; +} + +function repairJson(text: string): string { + let repaired = text.replace(/,\s*([}\]])/g, "$1"); + + const count = (s: string): { braces: number; brackets: number; inString: boolean } => { + let braces = 0; + let brackets = 0; + let inString = false; + let escape = false; + for (const ch of s) { + if (escape) { + escape = false; + continue; + } + if (ch === "\\") { + escape = true; + continue; + } + if (ch === '"') { + inString = !inString; + continue; + } + if (inString) continue; + if (ch === "{") braces++; + if (ch === "}") braces--; + if (ch === "[") brackets++; + if (ch === "]") brackets--; + } + return { braces, brackets, inString }; + }; + + if (count(repaired).inString) repaired += '"'; + const { braces, brackets } = count(repaired); + repaired += "]".repeat(Math.max(0, brackets)); + repaired += "}".repeat(Math.max(0, braces)); + return repaired; +} + +/** Parse agent output into a PlanningResponse; throws on unparseable/invalid. */ +export function parseAgentResponse(text: string): PlanningResponse { + const candidate = extractJsonCandidate(text); + if (!candidate) { + throw new Error("AI returned no valid JSON."); + } + + let parsed: unknown; + try { + parsed = JSON.parse(candidate); + } catch { + try { + parsed = JSON.parse(repairJson(candidate)); + } catch (repairErr) { + throw new Error( + `Failed to parse AI response: ${repairErr instanceof Error ? repairErr.message : "Unknown error"}.`, + ); + } + } + + if ( + typeof parsed === "object" && + parsed !== null && + "type" in parsed && + "data" in parsed + ) { + const typed = parsed as { type: string; data: unknown }; + if ( + (typed.type === "question" || typed.type === "complete") && + typed.data !== null && + typed.data !== undefined + ) { + return parsed as PlanningResponse; + } + } + throw new Error("AI returned an invalid response structure."); +} + +/** Extract text from the last assistant message (string | text blocks | thinking fallback). */ +function extractLastAssistantText(session: InteractiveAgentSession): string { + const lastMessage = session.state.messages.filter((m) => m.role === "assistant").pop(); + if (!lastMessage?.content) return ""; + if (typeof lastMessage.content === "string") return lastMessage.content; + if (Array.isArray(lastMessage.content)) { + const textContent = lastMessage.content + .filter((c): c is { type: "text"; text: string } => c.type === "text" && typeof c.text === "string") + .map((c) => c.text) + .join(""); + if (textContent) return textContent; + // Fallback: thinking blocks when no text blocks present. + return lastMessage.content + .filter((c): c is { type: "thinking"; thinking: string } => c.type === "thinking" && typeof c.thinking === "string") + .map((c) => c.thinking) + .join(""); + } + return ""; +} + +type LoopState = "idle" | "awaiting_input" | "complete" | "error"; + +/** + * Build the interactive session over an injected agent factory. + * Exported for direct (deterministic, fake-agent) testing. + */ +export async function createInteractiveAiSessionWith( + agentFactory: InteractiveAgentFactory, + options: CreateInteractiveAiSessionOptions, +): Promise { + const agentResult = await agentFactory(options); + const agent = agentResult.session; + + let state: LoopState = "idle"; + let pendingEvent: Promise | undefined; + let terminalEvent: InteractiveAiSessionEvent | undefined; + let currentQuestion: PlanningQuestion | undefined; + let disposed = false; + + /** + * Prompt the agent, read the last assistant message, parse it, and run one + * bounded reformat retry. Returns the structured event for this turn. + */ + async function runTurn(text: string): Promise { + if (disposed) { + return { type: "error", data: { message: "Session disposed." } }; + } + try { + await agent.prompt(text); + } catch (err) { + state = "error"; + const ev: InteractiveAiSessionEvent = { + type: "error", + data: { message: err instanceof Error ? err.message : String(err), cause: err }, + }; + terminalEvent = ev; + return ev; + } + + let responseText = extractLastAssistantText(agent); + let parsed: PlanningResponse | undefined; + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) { + try { + parsed = parseAgentResponse(responseText); + break; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (attempt < MAX_PARSE_RETRIES) { + try { + await agent.prompt(REFORMAT_PROMPT); + responseText = extractLastAssistantText(agent); + } catch (promptErr) { + lastError = promptErr instanceof Error ? promptErr : new Error(String(promptErr)); + break; + } + } + } + } + + if (!parsed) { + state = "error"; + const ev: InteractiveAiSessionEvent = { + type: "error", + data: { message: `Failed to parse agent response: ${lastError?.message ?? "Unknown error"}`, cause: lastError }, + }; + terminalEvent = ev; + return ev; + } + + if (parsed.type === "question") { + currentQuestion = parsed.data; + state = "awaiting_input"; + return { type: "question", data: parsed.data }; + } + + // complete + state = "complete"; + const ev: InteractiveAiSessionEvent = { type: "complete", data: parsed.data }; + terminalEvent = ev; + return ev; + } + + const session: InteractiveAiSession = { + async prompt(text: string): Promise { + if (terminalEvent) return; // terminal: ignore further input + pendingEvent = runTurn(text); + // Surface prompt-time errors only via nextEvent(); never throw to caller. + await pendingEvent.catch(() => undefined); + }, + + async nextEvent(): Promise { + if (terminalEvent) return terminalEvent; + if (!pendingEvent) { + return { type: "error", data: { message: "No turn in progress. Call prompt() or answer() first." } }; + } + return pendingEvent; + }, + + async answer(questionId: string, response: unknown): Promise { + if (terminalEvent) return; + if (state !== "awaiting_input") { + pendingEvent = Promise.resolve({ + type: "error", + data: { message: "answer() called while not awaiting input." }, + }); + return; + } + if (currentQuestion && questionId !== currentQuestion.id) { + pendingEvent = Promise.resolve({ + type: "error", + data: { message: `answer() questionId "${questionId}" does not match current question "${currentQuestion.id}".` }, + }); + return; + } + const answerMessage = JSON.stringify({ + type: "answer", + questionId, + response, + }); + currentQuestion = undefined; + state = "idle"; + pendingEvent = runTurn(answerMessage); + await pendingEvent.catch(() => undefined); + }, + + dispose(): void { + if (disposed) return; + disposed = true; + try { + void agent.dispose?.(); + } catch { + // Best-effort cleanup; never throw from dispose. + } + }, + }; + + return { session, sessionFile: agentResult.sessionFile }; +} diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index 7d79e0c244..39aaf0d8b2 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -964,6 +964,11 @@ export interface AgentOptions { * (and `skillSelection` is not), auto-constructs a SkillSelectionContext * from the cwd and these names. Ignored when `skillSelection` is set. */ skills?: string[]; + /** Extra directories to scan for skills (each holding `/SKILL.md`), in + * addition to the default cwd/agent-dir roots. Forwarded to the resource + * loader so callers (e.g. plugins that install skills to a private dir) can + * make `skills`/`skillSelection` names discoverable in the live session. */ + additionalSkillPaths?: string[]; /** Optional task-scoped env injected into this session's subprocess tools only. */ taskEnv?: NodeJS.ProcessEnv; /** Last-chance abort hook fired immediately before `createAgentSession`. @@ -1987,6 +1992,9 @@ export async function createFnAgent(options: AgentOptions): Promise ? [options.systemPromptLayers.dynamic] : [], ...(effectiveExtensionPaths.length > 0 ? { additionalExtensionPaths: [...effectiveExtensionPaths] } : {}), + ...(options.additionalSkillPaths && options.additionalSkillPaths.length > 0 + ? { additionalSkillPaths: [...options.additionalSkillPaths] } + : {}), ...(skillsOverrideFn ? { skillsOverride: skillsOverrideFn } : {}), }); await resourceLoader.reload(); diff --git a/plugins/fusion-plugin-compound-engineering/.gitignore b/plugins/fusion-plugin-compound-engineering/.gitignore new file mode 100644 index 0000000000..c4379f32ff --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/.gitignore @@ -0,0 +1,3 @@ +# Runtime, plugin-local install target for bundled ce-* skills (U2). +# Populated by installBundledCeSkills() on plugin load; never committed. +.fusion-ce-skills/ diff --git a/plugins/fusion-plugin-compound-engineering/README.md b/plugins/fusion-plugin-compound-engineering/README.md new file mode 100644 index 0000000000..9881c2fa66 --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/README.md @@ -0,0 +1,191 @@ +# Compound Engineering Plugin for Fusion + +A dedicated dashboard surface for the compound-engineering (CE) workflow: an +artifact hub, interactive in-dashboard `ce-*` skill sessions, a work→board +bridge, and event-driven bidirectional sync between the Fusion board and a +plugin-local CE-pipeline state model. It runs **alongside** Fusion's native +pipeline — it does not replace or bypass it. + +## Install (one-click) + +1. Open **Settings → Plugins → Fusion Plugins**. +2. In **Bundled Plugins**, click **Install** on **Compound Engineering**. +3. Enable the plugin if prompted. + +Once installed and enabled, Fusion registers the **Compound Engineering** +dashboard destination automatically and installs the bundled `ce-*` skills into a +plugin-local, discoverable directory. + +## What it does + +Compound engineering normally runs as terminal slash-commands whose artifacts +scatter across `docs/`, with no unified surface and no link between a finished +plan and the board work that follows. This plugin surfaces the whole flow inside +Fusion while **reusing the real skills** so the plugin improves as they do. + +## Artifact hub + +The primary dashboard view (`viewId: "compound-engineering"`) discovers and +renders CE artifacts from their conventional locations (`STRATEGY.md`, +`docs/ideation/`, `docs/brainstorms/`, plan docs, `docs/work/`, `CONCEPTS.md`, +`docs/solutions/`) and groups them by stage. Artifacts are read through a plugin +route and rendered self-contained (sandboxed preview). The hub renders explicit +empty / partial / error states rather than crashing or silently dropping an +unreadable artifact. + +Artifact HTTP endpoints live under +`/api/plugins/fusion-plugin-compound-engineering/` and back the hub list/read. + +## Interactive `ce-*` sessions + +Each pipeline stage maps to a bundled skill via the **stage registry** +(`src/session/stage-registry.ts`): `{ stageId, skillId, artifactLocation, icon, +label }`. Adding a stage is a data entry — no new route, store, or screen. + +The launcher lists the registered (and operator-enabled) stages. Launching a +stage starts an **interactive** agent session driven by the host's +`createInteractiveAiSession` seam (a foundational extension added by this plan, +because the existing `createAiSession` is one-shot and cannot pause on a +mid-agent question). The session orchestrator (`src/session/orchestrator.ts`): + +- streams `thinking` / `text` turns, +- surfaces a structured `question` and pauses in `awaiting_input`, +- accepts a structured answer and continues, +- on `complete`, writes the artifact to the stage's conventional location. + +Lifecycle states are `launching → active → awaiting_input → completed`, plus +`error` and `interrupted`. On interrupt or error the orchestrator **auto-saves +progress and emits an observable event — never silent loss** — and an +`interrupted`/`error` session can be resumed/retried back to its current +question. + +### Multiple sessions + +Sessions are independent pipeline runs — the store, routes, and orchestrator +all hold many at once (each with its own live agent handle). The dashboard's +**Sessions panel** lists every session with its stage, status, and last +activity; from there you can: + +- **open** any session and keep working on it (an `awaiting_input` session is + flagged "needs your input"), +- **switch** between sessions — the panel stays visible while a flow is open, + and a session you switch away from keeps running server-side, +- **resume** an `interrupted`/`error` session from where it stopped, +- **discard** a settled (completed/error/interrupted) session via + `DELETE /sessions/:id`, which disposes any live handle before deleting the + row (pipeline-link rows are kept — board-task provenance survives). + +The list refreshes on any CE push event and falls back to polling +`GET /sessions` while any session has a turn in flight. + +### Live working output, steering, and the Q&A surface + +Turn execution is **detached**: `POST /sessions`, `/answer`, and `/resume` +return as soon as the session row reflects the request, with the agent turn +running in the background. While it runs: + +- The engine streams **live progress** through the seam's `onProgress` option + (thinking/text deltas + tool start/end markers — a host capability any + plugin can use). The orchestrator accumulates it per session and + `GET /sessions/:id` attaches it as `liveActivity`, so the flow renders a + live working pane (pulsing indicator, muted thinking, per-tool ✓/✗ lines). +- The per-turn timeout is **inactivity-based**: a long but actively-working + turn is never killed; only a turn with no progress for `turnIntervalMs` is + interrupted (its working trace is preserved in the transcript). +- On settle, the working trace is persisted into the conversation history as a + condensed collapsible "Agent work" block — the transcript keeps the full + story: opening message, every past question and answer (option ids rendered + as labels), steering turns, working traces, and completion. + +**Steering**: alongside any selectable question the user can type free-text +guidance — attached to their answer as `{value, comment}`, or sent WITHOUT +answering as `{feedback}`. The stage system prompt instructs the agent to +treat both as first-class input (incorporate, adjust course, re-ask or +proceed). + +### Transport + +Session updates are **pushed** over the shared `/api/events` SSE stream. The +orchestrator emits observable events via `ctx.emitEvent` (turn / question / +completed / error / interrupted, plus throttled mid-turn progress); the host +forwards them to connected clients as project-scoped `plugin:custom` events, +and the view subscribes through the `subscribePluginEvents` context capability +— refetching the session on each event (no raw `EventSource`; no deep +dashboard import). Client **polling of `GET /sessions/:id` remains as a +fallback** while a turn is mid-flight, so a missed event still converges. +Session identity is project-scoped: the `projectId` used at `start` is +threaded through every later answer/resume/poll so they resolve the same store +and live handle. + +## Work → board bridge + +When a stage reaches its work phase (`ce-work`, stage id `work`), its `complete` +payload may carry a derived task list. The orchestrator creates each as a Fusion +board task via `ctx.taskStore.createTask`, tagged CE-originated (source +`workflow_step` with CE markers in `sourceMetadata`) and recorded as a +**pipeline-link** row. The link row — not task-row JSON — is the authoritative +back-reference from a board task to its originating pipeline/stage/artifact +(per the FN-5719 pattern). Created tasks then run the **normal** lifecycle with +no plugin interference. Zero derived tasks is a clean no-op. + +## Bidirectional sync model + +Two **separate** state machines are kept in sync, never merged: + +- **Board-task ownership** → the task's `column`. **The board is authoritative + for task state.** +- **CE-pipeline ownership** → `ce_pipeline_state.{currentStage, status}`. **The + CE flow is authoritative for artifact/pipeline content.** + +**Inbound (board → pipeline).** The `onTaskMoved` / `onTaskCompleted` lifecycle +hooks do the minimum under the 5s hook budget: resolve the link and +`enqueueSync(...)`, then return. Heavy advancement is **not** done inline. + +**Reconcile (the convergence guarantee).** `reconcileCePipelines(ctx)` is a +single on-demand sweep — **not** a tight interval poll. It (1) drains the queue +and (2) independently re-derives transitions by comparing live board state +against pipeline state. Step (2) is why a dropped or never-enqueued hook event +still converges: the queue is an optimization; the board↔state comparison is the +source of truth. + +**Outbound (pipeline → board).** When a pipeline advances to a stage that +produces board work, the reconciler creates the next-stage board task via +`ctx.taskStore.createTask` and links it. + +**Conflict policy.** The reconciler only reads the already-terminal board task +columns (board-authoritative) and only writes CE-owned fields plus a brand-new +board task — the two writers never contend over the same cell. + +## Bundled-skills isolation model + +The `ce-*` skills are **bundled and pinned** inside the plugin +(`src/skills//SKILL.md`), declared via `PluginSkillContribution` with +plugin-root-relative `skillFiles`. On load they are physically installed +(`cpSync`, idempotent skip-if-exists) into a **plugin-local, discoverable** +directory so an agent session can resolve them. The install is guarded to **never +touch a global `~/.claude/skills` path** an operator's own compound-engineering +install owns — registering the bundled copy can never clobber a global install. + +## Settings + +Operator-facing settings render in **Settings → Plugins → Compound Engineering**, +grouped as follows. Every setting has a real consumption point in the plugin. + +### Sessions + +| Setting | Type | Default | Effect | +|---|---|---|---| +| **Default Session Provider** (`defaultProvider`) | string | _(host default)_ | Passed to the interactive-session factory as `defaultProvider`. Blank → host picks. | +| **Default Session Model** (`defaultModelId`) | string | _(host default)_ | Passed to the factory as `defaultModelId`. Blank → host picks. | +| **Enabled Stages** (`enabledStages`) | string[] | full registry | Only these stage IDs may be launched; the orchestrator rejects others. | + +### Sync + +| Setting | Type | Default | Effect | +|---|---|---|---| +| **Reconcile on Board Changes** (`reconcileOnHooks`) | boolean | `true` | When on, the reconcile sweep auto-fires after task move/complete hooks. When off, the hook still enqueues so an on-demand sweep converges later. | +| **Reconcile Cadence (minutes)** (`reconcileIntervalMinutes`) | number | `15` | Cadence hint for an on-demand refresh surface. Not a continuous poll loop. | + +Getters live in `src/settings.ts` (`getDefaultProvider`, `getDefaultModelId`, +`getEnabledStages`, `getReconcileOnHooks`, `getReconcileIntervalMinutes`), each +returning its default when the setting is absent. diff --git a/plugins/fusion-plugin-compound-engineering/manifest.json b/plugins/fusion-plugin-compound-engineering/manifest.json new file mode 100644 index 0000000000..c84cf13210 --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/manifest.json @@ -0,0 +1,56 @@ +{ + "id": "fusion-plugin-compound-engineering", + "name": "Compound Engineering", + "version": "0.1.0", + "description": "A dedicated dashboard surface for compound-engineering artifacts and interactive ce-* sessions.", + "author": "Fusion Team", + "fusionVersion": ">=0.1.0", + "dashboardViews": [ + { + "viewId": "compound-engineering", + "label": "Compound Engineering", + "componentPath": "./dashboard-view", + "icon": "Sparkles", + "placement": "primary", + "order": 36 + } + ], + "settingsSchema": { + "defaultProvider": { + "type": "string", + "label": "Default Session Provider", + "description": "Model provider used for CE interactive sessions (for example anthropic). Leave blank to use the host default.", + "group": "Sessions", + "defaultValue": "" + }, + "defaultModelId": { + "type": "string", + "label": "Default Session Model", + "description": "Model ID within the provider used for CE interactive sessions. Leave blank to use the host default.", + "group": "Sessions", + "defaultValue": "" + }, + "enabledStages": { + "type": "array", + "itemType": "string", + "label": "Enabled Stages", + "description": "Stage IDs that may be launched from the Compound Engineering view (for example strategy, ideate, brainstorm, plan, work).", + "group": "Sessions", + "defaultValue": ["strategy", "ideate", "brainstorm", "plan", "work"] + }, + "reconcileOnHooks": { + "type": "boolean", + "label": "Reconcile on Board Changes", + "description": "Run the board→pipeline reconcile sweep automatically after task move/complete hooks. Disable to only reconcile on demand.", + "group": "Sync", + "defaultValue": true + }, + "reconcileIntervalMinutes": { + "type": "number", + "label": "Reconcile Cadence (minutes)", + "description": "Cadence hint for how often an on-demand refresh surface sweeps the reconciler. Not a continuous poll loop.", + "group": "Sync", + "defaultValue": 15 + } + } +} diff --git a/plugins/fusion-plugin-compound-engineering/package.json b/plugins/fusion-plugin-compound-engineering/package.json new file mode 100644 index 0000000000..ddc60d7f07 --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/package.json @@ -0,0 +1,37 @@ +{ + "name": "@fusion-plugin-examples/compound-engineering", + "version": "0.1.0", + "type": "module", + "description": "Compound Engineering plugin for Fusion", + "private": true, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./dashboard-view": { + "types": "./src/dashboard-view.tsx", + "import": "./src/dashboard-view.tsx" + } + }, + "scripts": { + "build": "tsc", + "test": "vitest run --silent=passed-only --reporter=dot" + }, + "dependencies": { + "@fusion/core": "workspace:*", + "@fusion/plugin-sdk": "workspace:*", + "lucide-react": "^0.542.0", + "react": "^19.0.0", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^19.0.0", + "@types/node": "^25.5.2", + "typescript": "^5.7.0", + "vitest": "^3.2.4" + } +} diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/_harness.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/_harness.ts new file mode 100644 index 0000000000..0aacbd045e --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/_harness.ts @@ -0,0 +1,96 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { vi } from "vitest"; +import { Database } from "@fusion/core"; +import type { + CreateInteractiveAiSessionFactory, + InteractiveAiSession, + InteractiveAiSessionEvent, + PluginContext, +} from "@fusion/core"; + +export interface TestHarness { + db: Database; + projectRoot: string; + ctx: PluginContext; + emitted: Array<{ event: string; data: unknown }>; + close(): void; +} + +/** + * In-memory DB + a minimal route-style PluginContext whose `taskStore` exposes + * `getDatabase()` / `getRootDir()` (the only surfaces the orchestrator uses) and + * a recording `emitEvent` so tests can assert observable events. + */ +export function makeHarness(): TestHarness { + const projectRoot = mkdtempSync(join(tmpdir(), "ce-session-test-")); + const db = new Database(join(projectRoot, ".fusion"), { inMemory: true }); + db.init(); + + const emitted: Array<{ event: string; data: unknown }> = []; + + const taskStore = { + getDatabase: () => db, + getRootDir: () => projectRoot, + } as unknown as PluginContext["taskStore"]; + + const ctx: PluginContext = { + pluginId: "fusion-plugin-compound-engineering", + taskStore, + settings: {}, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + emitEvent: (event: string, data: unknown) => { + emitted.push({ event, data }); + }, + }; + + return { + db, + projectRoot, + ctx, + emitted, + close: () => db.close(), + }; +} + +/** + * A scripted fake interactive session: each prompt/answer advances a cursor and + * the next `nextEvent()` yields the scripted event for that turn. Mirrors the + * U4 seam tests' scripted fake. + */ +export function makeScriptedSession(script: InteractiveAiSessionEvent[]): InteractiveAiSession { + let cursor = -1; + return { + prompt: vi.fn(async () => { + cursor++; + }), + answer: vi.fn(async () => { + cursor++; + }), + nextEvent: vi.fn(async () => { + if (script.length === 0) { + // An empty script is a test bug — surface it loudly rather than + // silently returning undefined (which masks the mistake downstream). + throw new Error("makeScriptedSession: empty script has no events to yield"); + } + return script[Math.min(Math.max(cursor, 0), script.length - 1)]; + }), + dispose: vi.fn(), + }; +} + +/** A factory that returns the given scripted session. */ +export function scriptedFactory(session: InteractiveAiSession): CreateInteractiveAiSessionFactory { + return vi.fn(async () => ({ session, sessionFile: "/tmp/ce.json" })); +} + +/** A session whose first turn never produces an event (forces a turn timeout). */ +export function hangingSession(): InteractiveAiSession { + return { + prompt: vi.fn(async () => undefined), + answer: vi.fn(async () => undefined), + nextEvent: vi.fn(() => new Promise(() => undefined)), + dispose: vi.fn(), + }; +} diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/manifest.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/manifest.test.ts new file mode 100644 index 0000000000..19c6a87890 --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/manifest.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import manifest from "../../manifest.json"; +import plugin from "../index.js"; +import { COMPOUND_ENGINEERING_SKILLS } from "../skills.js"; +import { settingsSchema } from "../settings.js"; + +describe("compound engineering plugin manifest", () => { + it("exports expected plugin id", () => { + expect(plugin.manifest.id).toBe("fusion-plugin-compound-engineering"); + }); + + it("keeps runtime manifest metadata aligned with manifest.json", () => { + expect(plugin.manifest.id).toBe(manifest.id); + expect(plugin.manifest.name).toBe(manifest.name); + expect(plugin.manifest.version).toBe(manifest.version); + expect(plugin.manifest.description).toBe(manifest.description); + expect(plugin.manifest.author).toBe(manifest.author); + expect(plugin.manifest.fusionVersion).toBe(manifest.fusionVersion); + }); + + it("registers a single dashboard view", () => { + expect(plugin.dashboardViews).toEqual([ + { + viewId: "compound-engineering", + label: "Compound Engineering", + componentPath: "./dashboard-view", + icon: "Sparkles", + placement: "primary", + order: 36, + }, + ]); + expect(manifest.dashboardViews).toEqual(plugin.dashboardViews); + }); + + it("registers the session orchestration routes (U5)", () => { + const paths = (plugin.routes ?? []).map((r) => `${r.method} ${r.path}`); + expect(paths).toEqual( + expect.arrayContaining([ + "POST /sessions", + "POST /sessions/:id/answer", + "POST /sessions/:id/resume", + "GET /sessions/:id", + "GET /sessions", + ]), + ); + }); + + it("wires onSchemaInit for the plugin-local CE tables (U5)", () => { + expect(typeof plugin.hooks.onSchemaInit).toBe("function"); + }); + + it("registers the bundled CE pipeline-stage skills on plugin and manifest (U2)", () => { + const expectedIds = [ + "ce-strategy", + "ce-ideate", + "ce-brainstorm", + "ce-plan", + "ce-work", + "ce-code-review", + "ce-compound", + ]; + expect(COMPOUND_ENGINEERING_SKILLS.map((s) => s.skillId)).toEqual(expectedIds); + expect(plugin.skills).toBe(COMPOUND_ENGINEERING_SKILLS); + // Manifest mirrors agent-browser: { skillId, name } projection. + expect(plugin.manifest.skills).toEqual( + COMPOUND_ENGINEERING_SKILLS.map((s) => ({ skillId: s.skillId, name: s.name })), + ); + // Each contribution points at a plugin-root-relative bundled SKILL.md. + for (const s of COMPOUND_ENGINEERING_SKILLS) { + expect(s.skillFiles).toEqual([`skills/${s.skillId}/SKILL.md`]); + } + }); + + it("registers an onLoad hook that installs bundled skills (U2)", () => { + expect(typeof plugin.hooks?.onLoad).toBe("function"); + }); + + it("wires the settings schema onto the runtime manifest and manifest.json (U9)", () => { + const expectedKeys = [ + "defaultProvider", + "defaultModelId", + "enabledStages", + "reconcileOnHooks", + "reconcileIntervalMinutes", + ].sort(); + expect(plugin.manifest.settingsSchema).toBe(settingsSchema); + expect(Object.keys(settingsSchema).sort()).toEqual(expectedKeys); + // manifest.json mirrors the same keys (runtime/JSON alignment). + expect(Object.keys(manifest.settingsSchema).sort()).toEqual(expectedKeys); + // Spot-check one entry stays aligned between JSON and runtime. + expect(manifest.settingsSchema.reconcileIntervalMinutes.defaultValue).toBe( + settingsSchema.reconcileIntervalMinutes.defaultValue, + ); + }); +}); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-flow.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-flow.test.ts new file mode 100644 index 0000000000..4ca7cdaf7a --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-flow.test.ts @@ -0,0 +1,156 @@ +import { existsSync, readFileSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { InteractiveAiSessionEvent, PlanningQuestion } from "@fusion/core"; +import { CeOrchestrator, CE_EVENTS } from "../session/orchestrator.js"; +import { registerStage, getStage } from "../session/stage-registry.js"; +import { makeHarness, makeScriptedSession, type TestHarness } from "./_harness.js"; + +const QUESTION: PlanningQuestion = { + id: "q1", + type: "text", + question: "What is the topic?", +}; + +let h: TestHarness; +beforeEach(() => { + h = makeHarness(); +}); +afterEach(() => { + h.close(); +}); + +function makeOrch(script: InteractiveAiSessionEvent[]) { + const session = makeScriptedSession(script); + return new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: vi.fn(async () => ({ session })), + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); +} + +describe("orchestrator happy path", () => { + it("start → question → answer → complete writes the artifact to the conventional location", async () => { + const orch = makeOrch([ + { type: "question", data: QUESTION }, + { type: "complete", data: { artifact: "# Brainstorm\n\nThe plan.\n" } }, + ]); + + const started = await orch.start("brainstorm", { openingMessage: "let's brainstorm widgets" }); + expect(started.session.status).toBe("awaiting_input"); + expect(started.session.currentQuestion?.id).toBe("q1"); + + const done = await orch.answer(started.session.id, "q1", "widgets"); + expect(done.event?.type).toBe("complete"); + expect(done.session.status).toBe("completed"); + + // Artifact written to docs/brainstorms/ (the stage's conventional location). + const artifactPath = done.session.artifactPath!; + expect(artifactPath).toContain("docs/brainstorms/"); + expect(existsSync(artifactPath)).toBe(true); + expect(readFileSync(artifactPath, "utf-8")).toContain("# Brainstorm"); + + // Observable completion event emitted. + expect(h.emitted.map((e) => e.event)).toContain(CE_EVENTS.completed); + }); + + it("runs a SECOND stage through the SAME orchestrator with only a registry-data entry (no new route/store code)", async () => { + // Adding a stage = data only. + registerStage({ + stageId: "compound", + order: 600, + skillId: "ce-compound", + artifactLocation: "docs/solutions/", + icon: "BookOpen", + label: "Compound", + }); + expect(getStage("compound")?.skillId).toBe("ce-compound"); + + const orch = makeOrch([{ type: "complete", data: { artifact: "# Learning\n" } }]); + const started = await orch.start("compound", { openingMessage: "document this" }); + expect(started.event?.type).toBe("complete"); + expect(started.session.stage).toBe("compound"); + expect(started.session.status).toBe("completed"); + expect(started.session.artifactPath).toContain("docs/solutions/"); + expect(readFileSync(started.session.artifactPath!, "utf-8")).toContain("# Learning"); + }); +}); + +describe("multiple concurrent sessions", () => { + it("drives two independent sessions through the SAME orchestrator without cross-talk", async () => { + // Two scripted live sessions; the factory hands them out in creation order. + const liveA = makeScriptedSession([ + { type: "question", data: QUESTION }, + { type: "complete", data: { artifact: "# A\n" } }, + ]); + const liveB = makeScriptedSession([ + { type: "question", data: { ...QUESTION, id: "q-b" } }, + { type: "complete", data: { artifact: "# B\n" } }, + ]); + const handles = [liveA, liveB]; + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: vi.fn(async () => ({ session: handles.shift()! })), + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); + + const a = await orch.start("brainstorm", { openingMessage: "topic A" }); + const b = await orch.start("brainstorm", { openingMessage: "topic B" }); + expect(a.session.id).not.toBe(b.session.id); + expect(a.session.status).toBe("awaiting_input"); + expect(b.session.status).toBe("awaiting_input"); + + // Answer B first — A must stay awaiting, untouched. + const doneB = await orch.answer(b.session.id, "q-b", "bee"); + expect(doneB.session.status).toBe("completed"); + expect(orch.getState(a.session.id)?.status).toBe("awaiting_input"); + + // A is still answerable on ITS live handle (not B's). + const doneA = await orch.answer(a.session.id, "q1", "ay"); + expect(doneA.session.status).toBe("completed"); + expect(liveA.answer).toHaveBeenCalledTimes(1); + expect(liveB.answer).toHaveBeenCalledTimes(1); + }); + + it("discard disposes the live handle and deletes only that session", async () => { + const live = makeScriptedSession([{ type: "question", data: QUESTION }]); + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: vi.fn(async () => ({ session: live })), + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); + const started = await orch.start("brainstorm", { openingMessage: "topic" }); + + expect(orch.discard(started.session.id)).toBe(true); + expect(live.dispose).toHaveBeenCalled(); + expect(orch.getState(started.session.id)).toBeUndefined(); + // Idempotent-ish: a second discard reports false, no throw. + expect(orch.discard(started.session.id)).toBe(false); + }); +}); + +describe("orchestrator error + retry", () => { + it("agent error → status error, progress preserved, observable event; retry resumes to the question", async () => { + const orch = makeOrch([ + { type: "question", data: QUESTION }, + { type: "error", data: { message: "model overloaded" } }, + ]); + + const started = await orch.start("brainstorm", { openingMessage: "topic" }); + expect(started.session.currentQuestion?.id).toBe("q1"); + + const errored = await orch.answer(started.session.id, "q1", "answer-text"); + expect(errored.session.status).toBe("error"); + expect(errored.session.error).toContain("model overloaded"); + // Progress preserved: history retained. + expect(errored.session.conversationHistory.length).toBeGreaterThan(0); + expect(h.emitted.map((e) => e.event)).toContain(CE_EVENTS.error); + + // Retry: resume() moves an errored session forward. (Error keeps it + // resumable; resume reads persisted state — the no-loss anchor.) + const state = orch.getState(errored.session.id)!; + expect(state.conversationHistory.length).toBeGreaterThan(0); + }); +}); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-interrupt-resume.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-interrupt-resume.test.ts new file mode 100644 index 0000000000..c1d9edafa4 --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-interrupt-resume.test.ts @@ -0,0 +1,212 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { InteractiveAiSession, InteractiveAiSessionEvent, PlanningQuestion } from "@fusion/core"; +import { vi } from "vitest"; +import { CeOrchestrator, CE_EVENTS } from "../session/orchestrator.js"; +import { CeSessionStore, getCeSessionStore } from "../session/session-store.js"; +import { makeHarness, makeScriptedSession, type TestHarness } from "./_harness.js"; + +/** + * CHARACTERIZATION TEST — written first (U5 execution note: cover the + * no-silent-loss invariant before the happy path). Asserts that an interrupted + * mid-question session auto-saves progress, lands in `interrupted`, emits an + * observable event, and resumes to the SAME question with full history. + */ + +const QUESTION: PlanningQuestion = { + id: "q1", + type: "single_select", + question: "Which direction?", + options: [ + { id: "a", label: "A" }, + { id: "b", label: "B" }, + ], +}; + +let h: TestHarness; + +beforeEach(() => { + h = makeHarness(); +}); + +afterEach(() => { + h.close(); +}); + +/** + * Session that yields a question on turn 1, then HANGS on the next turn + * (the answer turn never produces an event) — forcing a turn timeout. + */ +function questionThenHangSession(): InteractiveAiSession { + let cursor = -1; + return { + prompt: vi.fn(async () => { + cursor++; + }), + answer: vi.fn(async () => { + cursor++; + }), + nextEvent: vi.fn(async (): Promise => { + if (cursor === 0) return { type: "question", data: QUESTION }; + // turn 2+ hangs forever + return new Promise(() => undefined); + }), + dispose: vi.fn(), + }; +} + +describe("interrupt + resume (no silent loss)", () => { + it("auto-saves progress on a turn timeout, marks interrupted, emits an event", async () => { + const session = questionThenHangSession(); + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: vi.fn(async () => ({ session })), + projectRoot: h.projectRoot, + turnTimeoutMs: 20, + }); + + const started = await orch.start("brainstorm", { openingMessage: "kick off" }); + expect(started.event?.type).toBe("question"); + expect(started.session.status).toBe("awaiting_input"); + expect(started.session.currentQuestion?.id).toBe("q1"); + + // Answering triggers the next turn, which hangs → timeout → interrupted. + const interrupted = await orch.answer(started.session.id, "q1", "a"); + expect(interrupted.session.status).toBe("interrupted"); + // Progress preserved: full history including the question and the answer. + const history = interrupted.session.conversationHistory; + expect(history.some((t) => t.text.includes("kick off"))).toBe(true); + expect(history.some((t) => t.text.includes("question"))).toBe(true); + expect(history.some((t) => t.text.includes("\"answer\""))).toBe(true); + + // Observable event emitted — never silent loss. + expect(h.emitted.map((e) => e.event)).toContain(CE_EVENTS.interrupted); + }); + + it("leaves an awaiting_input session (waiting on a human) untouched even far past the stale band, and resume returns the same question with full history", async () => { + // A session legitimately paused on a human question: status awaiting_input + // with currentQuestion set, lastActivity well past the interval stale band. + // Human response time is unbounded, so this is NOT a crashed turn — the + // interval rubric must not misclassify it as stale. + const store = new CeSessionStore(h.db); + const created = store.create({ stage: "brainstorm", turnIntervalMs: 1000 }); + store.appendHistory(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); + store.appendHistory(created.id, { role: "agent", text: JSON.stringify({ question: QUESTION }), at: new Date().toISOString() }); + store.update(created.id, { + status: "awaiting_input", + currentQuestion: QUESTION, + // 10× interval old → far past the band, yet legitimately awaiting a human. + lastActivityAt: Date.now() - 10_000, + }); + + const recovered = store.recoverStaleSessions(); + // Not flagged stale / not recovered — a human wait is not a crashed turn. + expect(recovered).not.toContain(created.id); + + const after = store.get(created.id)!; + // Awaiting-input session with a question stays resumable, unchanged. + expect(after.status).toBe("awaiting_input"); + expect(after.currentQuestion?.id).toBe("q1"); + + // Resume via the orchestrator returns to the same question + full history. + // Rehydration re-creates a live session and replays the opening message, + // draining the agent's response (the question) during replay. + const replaySession = makeScriptedSession([{ type: "question", data: QUESTION }]); + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: vi.fn(async () => ({ session: replaySession })), + projectRoot: h.projectRoot, + }); + const resumed = await orch.resume(created.id); + expect(resumed.session.status).toBe("awaiting_input"); + expect(resumed.session.currentQuestion?.id).toBe("q1"); + expect(resumed.session.conversationHistory).toHaveLength(2); + }); + + it("Bug 5: an interrupted/awaiting session with a currentQuestion + history can be resumed (rehydrated) and then ANSWERED to continue to completion", async () => { + // Simulate the post-interrupt / post-restart state: a session persisted + // mid-question (awaiting_input, currentQuestion set, full history) whose live + // handle was disposed and removed from this.live. This is exactly the state + // resume() must be able to back with a real live handle. + const store = getCeSessionStore(h.ctx); + const created = store.create({ stage: "brainstorm", turnIntervalMs: 5000 }); + store.appendHistory(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); + store.appendHistory(created.id, { + role: "agent", + text: JSON.stringify({ question: QUESTION }), + at: new Date().toISOString(), + }); + store.update(created.id, { status: "awaiting_input", currentQuestion: QUESTION }); + const sessionId = created.id; + + // The rehydration factory: replays the opening prompt (yields the question, + // which replay discards), then on the real answer turn completes the stage. + const rehydrated = makeScriptedSession([ + { type: "question", data: QUESTION }, + { type: "complete", data: { artifact: "# Done\n" } }, + ]); + const factory = vi.fn(async () => ({ session: rehydrated })); + + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: factory, + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); + + // Pre-fix: resume() flips status to awaiting_input but never re-establishes a + // live handle, so the subsequent answer() throws "no live handle; call + // resume() first" — a dead-end loop. Post-fix: resume rehydrates a live one. + const resumed = await orch.resume(sessionId); + expect(resumed.session.status).toBe("awaiting_input"); + expect(resumed.session.currentQuestion?.id).toBe("q1"); + expect(factory).toHaveBeenCalledTimes(1); // rehydration created a live session. + + // The resumed session is genuinely answerable now — drive it to completion. + const done = await orch.answer(sessionId, "q1", "a"); + expect(done.event?.type).toBe("complete"); + expect(done.session.status).toBe("completed"); + }); + + it("Bug 4: answering with a wrong questionId throws and leaves the session awaiting_input with its currentQuestion preserved", async () => { + const session = questionThenHangSession(); + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: vi.fn(async () => ({ session })), + projectRoot: h.projectRoot, + turnTimeoutMs: 20, + }); + + const started = await orch.start("brainstorm", { openingMessage: "kick off" }); + expect(started.session.status).toBe("awaiting_input"); + expect(started.session.currentQuestion?.id).toBe("q1"); + + // Answer with the WRONG questionId → must reject without mutating state. + await expect(orch.answer(started.session.id, "WRONG-ID", "a")).rejects.toThrow(/q1|WRONG-ID/); + + // The recovery anchor is intact: still awaiting_input with currentQuestion. + const after = orch.getState(started.session.id)!; + expect(after.status).toBe("awaiting_input"); + expect(after.currentQuestion?.id).toBe("q1"); + // No spurious answer turn was appended to history. + expect(after.conversationHistory.some((t) => t.text.includes("WRONG-ID"))).toBe(false); + + // The correct questionId is still accepted (the live handle wasn't disturbed). + // The session hangs on the answer turn → it interrupts, but it DID accept the + // answer, proving the rejection above didn't break the seam. + const accepted = await orch.answer(started.session.id, "q1", "a"); + expect(accepted.session.status).toBe("interrupted"); + }); + + it("a crash with no pending question is marked interrupted (progress preserved), not silently dropped", () => { + const store = getCeSessionStore(h.ctx); + const created = store.create({ stage: "brainstorm", turnIntervalMs: 1000 }); + store.appendHistory(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); + store.update(created.id, { status: "active", lastActivityAt: Date.now() - 10_000 }); + + store.recoverStaleSessions(); + const after = store.get(created.id)!; + expect(after.status).toBe("interrupted"); + expect(after.error).toMatch(/progress preserved/i); + expect(after.conversationHistory).toHaveLength(1); + }); +}); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-live-output.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-live-output.test.ts new file mode 100644 index 0000000000..4bfbac7b83 --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-live-output.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + CreateInteractiveAiSessionFactory, + InteractiveAiSessionEvent, + InteractiveAiSessionProgressEvent, + PlanningQuestion, +} from "@fusion/core"; +import { buildStageSystemPrompt, CeOrchestrator, CE_EVENTS } from "../session/orchestrator.js"; +import { getStage } from "../session/stage-registry.js"; +import { makeHarness, makeScriptedSession, type TestHarness } from "./_harness.js"; + +/** + * Live working-output + steering-protocol coverage: + * - mid-turn progress (thinking/text deltas, tool markers) is visible via + * getLiveActivity while the turn runs, emitted as observable events, and + * persisted into history as a condensed trace when the turn settles; + * - the turn timeout is INACTIVITY-based — an actively-working long turn is + * never killed, a quiet one is interrupted with its trace preserved; + * - detached start/answer return immediately and converge via persisted state; + * - the stage system prompt documents the steering response shapes. + */ + +const QUESTION: PlanningQuestion = { id: "q1", type: "text", question: "Topic?" }; + +let h: TestHarness; +beforeEach(() => { + h = makeHarness(); +}); +afterEach(() => { + h.close(); + vi.restoreAllMocks(); +}); + +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** A factory exposing the onProgress hook and a controllable nextEvent. */ +function progressFactory(nextEvent: () => Promise) { + const captured: { progress?: (e: InteractiveAiSessionProgressEvent) => void; dispose: ReturnType } = { + dispose: vi.fn(), + }; + const factory: CreateInteractiveAiSessionFactory = vi.fn(async (opts) => { + captured.progress = opts.onProgress; + return { + session: { + prompt: vi.fn(async () => undefined), + answer: vi.fn(async () => undefined), + nextEvent, + dispose: captured.dispose, + }, + }; + }); + return { factory, captured }; +} + +describe("live working output", () => { + it("buffers mid-turn progress, emits observable events, and persists the trace on settle (before the question)", async () => { + const evt = deferred(); + const { factory, captured } = progressFactory(() => evt.promise); + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: factory, + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); + + const started = await orch.start("brainstorm", { openingMessage: "go", detach: true }); + expect(["launching", "active"]).toContain(started.session.status); + await vi.waitFor(() => expect(captured.progress).toBeDefined()); + + // Stream: consecutive deltas of one kind merge; tool start/end are discrete. + captured.progress!({ type: "thinking", delta: "Let me " }); + captured.progress!({ type: "thinking", delta: "look around." }); + captured.progress!({ type: "tool", name: "Read", phase: "start" }); + captured.progress!({ type: "tool", name: "Read", phase: "end", isError: false }); + captured.progress!({ type: "text", delta: "Drafting…" }); + + const live = orch.getLiveActivity(started.session.id); + expect(live.map((t) => t.kind)).toEqual(["thinking", "tool", "text"]); + expect(live[0].text).toBe("Let me look around."); + expect(live[1].done).toBe(true); + expect(live[1].isError).toBeUndefined(); + + // Observable progress event emitted (throttled; the first one is immediate). + expect( + h.emitted.some((e) => e.event === CE_EVENTS.turn && (e.data as { kind?: string }).kind === "progress"), + ).toBe(true); + + // Settle the turn → buffer flushed into history BEFORE the question record. + evt.resolve({ type: "question", data: QUESTION }); + await vi.waitFor(() => expect(orch.getState(started.session.id)?.status).toBe("awaiting_input")); + expect(orch.getLiveActivity(started.session.id)).toHaveLength(0); + + const history = orch.getState(started.session.id)!.conversationHistory; + const activityIdx = history.findIndex((t) => t.role === "agent" && t.text.startsWith('{"activity"')); + const questionIdx = history.findIndex((t) => t.role === "agent" && t.text.startsWith('{"question"')); + expect(activityIdx).toBeGreaterThanOrEqual(0); + expect(questionIdx).toBeGreaterThan(activityIdx); + const trace = JSON.parse(history[activityIdx].text) as { + activity: { turns: Array<{ kind: string; text: string }> }; + }; + expect(trace.activity.turns.map((t) => t.kind)).toEqual(["thinking", "tool", "text"]); + }); + + it("inactivity watchdog: an actively-working long turn survives past the timeout; a quiet one is interrupted with its trace kept", async () => { + const { factory, captured } = progressFactory(() => new Promise(() => undefined)); + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: factory, + projectRoot: h.projectRoot, + turnTimeoutMs: 120, + }); + const started = await orch.start("brainstorm", { openingMessage: "go", detach: true }); + const id = started.session.id; + await vi.waitFor(() => expect(captured.progress).toBeDefined()); + + // Keep working for ~3× the timeout — must NOT be interrupted. + for (let i = 0; i < 8; i++) { + await sleep(45); + captured.progress!({ type: "thinking", delta: "." }); + } + expect(orch.getState(id)?.status).toBe("active"); + + // Go quiet → interrupted after the inactivity window, trace preserved. + await vi.waitFor(() => expect(orch.getState(id)?.status).toBe("interrupted"), { timeout: 2000 }); + expect(orch.getState(id)?.error).toMatch(/no agent activity/i); + const history = orch.getState(id)!.conversationHistory; + expect(history.some((t) => t.text.startsWith('{"activity"'))).toBe(true); + expect(captured.dispose).toHaveBeenCalled(); + }); +}); + +describe("detached turns (route posture)", () => { + it("answer(detach) returns immediately with status active and converges to the next question", async () => { + const NEXT: PlanningQuestion = { id: "q2", type: "text", question: "More?" }; + const scripted = makeScriptedSession([ + { type: "question", data: QUESTION }, + { type: "question", data: NEXT }, + ]); + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: vi.fn(async () => ({ session: scripted })), + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); + const started = await orch.start("brainstorm", { openingMessage: "go" }); + expect(started.session.status).toBe("awaiting_input"); + + const stepped = await orch.answer(started.session.id, "q1", "widgets", { detach: true }); + // Detached return reflects the just-accepted answer, not the settled turn… + expect(stepped.session.status).toBe("active"); + expect(stepped.session.currentQuestion).toBeNull(); + // …and the background turn converges to the next question. + await vi.waitFor(() => expect(orch.getState(started.session.id)?.currentQuestion?.id).toBe("q2")); + expect(orch.getState(started.session.id)?.status).toBe("awaiting_input"); + }); + + it("start(detach) without a working factory converges to an error state (never silent)", async () => { + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: vi.fn(async () => { + throw new Error("factory exploded"); + }), + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); + const started = await orch.start("brainstorm", { openingMessage: "go", detach: true }); + expect(started.session.id).toBeTruthy(); + await vi.waitFor(() => expect(orch.getState(started.session.id)?.status).toBe("error")); + expect(orch.getState(started.session.id)?.error).toContain("factory exploded"); + expect(h.emitted.map((e) => e.event)).toContain(CE_EVENTS.error); + }); +}); + +describe("steering protocol", () => { + it("the stage system prompt documents direct, value+comment, and feedback-only response shapes", () => { + const prompt = buildStageSystemPrompt(getStage("brainstorm")!); + expect(prompt).toContain('"value"'); + expect(prompt).toContain('"comment"'); + expect(prompt).toContain('"feedback"'); + expect(prompt).toMatch(/steering/i); + }); +}); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/session-routes.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/session-routes.test.ts new file mode 100644 index 0000000000..ca064e135f --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/session-routes.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginContext, PluginRouteResponse } from "@fusion/core"; +import { createSessionRoutes } from "../routes/session-routes.js"; +import { makeHarness, type TestHarness } from "./_harness.js"; + +/** + * Routes-level smoke test for the POLLING transport. Exercises validation and + * the get-session-state read path that clients poll. The orchestrator's live + * interactive flow is covered by orchestrator-flow.test.ts; here createInter- + * activeAiSession is absent (non-engine context), so `start` returns a 400 — + * which is the correct, non-hanging behavior. + */ + +let h: TestHarness; +beforeEach(() => { + h = makeHarness(); +}); +afterEach(() => { + h.close(); + vi.restoreAllMocks(); +}); + +function route(method: string, path: string) { + const r = createSessionRoutes().find((x) => x.method === method && x.path === path); + if (!r) throw new Error(`route ${method} ${path} not found`); + return r; +} + +async function call(method: string, path: string, req: unknown, ctx: PluginContext): Promise { + return (await route(method, path).handler(req, ctx)) as PluginRouteResponse; +} + +describe("session routes (polling transport)", () => { + it("exposes start / answer / resume / get-session-state / list", () => { + const paths = createSessionRoutes().map((r) => `${r.method} ${r.path}`); + expect(paths).toEqual( + expect.arrayContaining([ + "POST /sessions", + "POST /sessions/:id/answer", + "POST /sessions/:id/resume", + "GET /sessions/:id", + "GET /sessions", + "DELETE /sessions/:id", + ]), + ); + }); + + it("DELETE /sessions/:id discards a session (404 for unknown, gone afterwards, others kept)", async () => { + const { getCeSessionStore } = await import("../session/session-store.js"); + const store = getCeSessionStore(h.ctx); + const keep = store.create({ stage: "brainstorm" }); + const drop = store.create({ stage: "plan" }); + + const missing = await call("DELETE", "/sessions/:id", { params: { id: "nope" } }, h.ctx); + expect(missing.status).toBe(404); + + const deleted = await call("DELETE", "/sessions/:id", { params: { id: drop.id } }, h.ctx); + expect(deleted.status).toBe(200); + expect(store.get(drop.id)).toBeUndefined(); + expect(store.get(keep.id)).toBeDefined(); + }); + + it("GET /sessions lists every session so a client can manage multiple concurrently", async () => { + const { getCeSessionStore } = await import("../session/session-store.js"); + const store = getCeSessionStore(h.ctx); + store.create({ stage: "brainstorm" }); + store.create({ stage: "plan" }); + + const res = await call("GET", "/sessions", { params: {}, query: {} }, h.ctx); + expect(res.status).toBe(200); + const sessions = (res.body as { sessions: Array<{ stage: string }> }).sessions; + expect(sessions.map((s) => s.stage).sort()).toEqual(["brainstorm", "plan"]); + }); + + it("POST /sessions requires a stage", async () => { + const res = await call("POST", "/sessions", { body: {} }, h.ctx); + expect(res.status).toBe(400); + }); + + it("POST /sessions without engine interactive factory returns a clean 400 (no hang)", async () => { + const res = await call("POST", "/sessions", { body: { stage: "brainstorm", message: "go" } }, h.ctx); + expect(res.status).toBe(400); + expect((res.body as { error: string }).error).toMatch(/not available/i); + }); + + it("GET /sessions/:id returns 404 for an unknown id and 200 for a known one", async () => { + const missing = await call("GET", "/sessions/:id", { params: { id: "nope" } }, h.ctx); + expect(missing.status).toBe(404); + + // Seed a session directly so the poll route has something to return. + const { getCeSessionStore } = await import("../session/session-store.js"); + const seeded = getCeSessionStore(h.ctx).create({ stage: "brainstorm" }); + const found = await call("GET", "/sessions/:id", { params: { id: seeded.id } }, h.ctx); + expect(found.status).toBe(200); + expect((found.body as { session: { id: string } }).session.id).toBe(seeded.id); + }); + + it("POST /sessions/:id/answer validates questionId and response", async () => { + const res = await call("POST", "/sessions/:id/answer", { params: { id: "x" }, body: {} }, h.ctx); + expect(res.status).toBe(400); + }); +}); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/session-store.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/session-store.test.ts new file mode 100644 index 0000000000..6528a3c94b --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/session-store.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { CeSessionStore, STALE_INTERVAL_MULTIPLE } from "../session/session-store.js"; +import { ensureCeSchema } from "../schema.js"; +import { makeHarness, type TestHarness } from "./_harness.js"; + +let h: TestHarness; +beforeEach(() => { + h = makeHarness(); +}); +afterEach(() => { + h.close(); +}); + +describe("ensureCeSchema", () => { + it("is idempotent (safe to run repeatedly)", () => { + ensureCeSchema(h.db); + ensureCeSchema(h.db); + const cols = h.db.prepare("PRAGMA table_info(ce_sessions)").all() as Array<{ name: string }>; + const names = cols.map((c) => c.name); + expect(names).toEqual( + expect.arrayContaining([ + "id", + "stage", + "status", + "currentQuestion", + "conversationHistory", + "projectId", + "lastActivityAt", + ]), + ); + }); +}); + +describe("CeSessionStore CRUD + JSON round-trip", () => { + it("creates, reads back, and round-trips JSON fields", () => { + const store = new CeSessionStore(h.db); + const created = store.create({ stage: "brainstorm", projectId: "p1" }); + expect(created.status).toBe("launching"); + + store.update(created.id, { + currentQuestion: { id: "q", type: "confirm", question: "ok?" }, + status: "awaiting_input", + }); + store.appendHistory(created.id, { role: "user", text: "hi", at: "2026-06-02T00:00:00Z" }); + + const read = store.get(created.id)!; + expect(read.currentQuestion?.id).toBe("q"); + expect(read.conversationHistory).toHaveLength(1); + expect(read.status).toBe("awaiting_input"); + expect(read.projectId).toBe("p1"); + }); +}); + +describe("multi-session independence + delete", () => { + it("holds many independent sessions; deleting one leaves the others untouched", () => { + const store = new CeSessionStore(h.db); + const a = store.create({ stage: "brainstorm", projectId: "p1" }); + const b = store.create({ stage: "plan", projectId: "p1" }); + const c = store.create({ stage: "work" }); + expect(store.list()).toHaveLength(3); + + expect(store.delete(b.id)).toBe(true); + expect(store.get(b.id)).toBeUndefined(); + expect(store.get(a.id)).toBeDefined(); + expect(store.get(c.id)).toBeDefined(); + // Deleting a missing row reports false, no throw. + expect(store.delete(b.id)).toBe(false); + }); +}); + +describe("interval-relative staleness (FN-4172 rubric)", () => { + it("does NOT misclassify a healthy-but-slow session as stale", () => { + const store = new CeSessionStore(h.db); + const s = store.create({ stage: "brainstorm", turnIntervalMs: 1000 }); + store.update(s.id, { status: "active" }); + + const now = Date.now(); + // 2.5× the interval old: slow, but within the 3× band → NOT stale. + const slow = store.update(s.id, { status: "active", lastActivityAt: now - 2_500 })!; + expect(STALE_INTERVAL_MULTIPLE).toBe(3); + expect(store.isStale(slow, now)).toBe(false); + + // 4× the interval old → stale. + const stalled = store.update(s.id, { status: "active", lastActivityAt: now - 4_000 })!; + expect(store.isStale(stalled, now)).toBe(true); + }); + + it("never flags terminal sessions as stale regardless of age", () => { + const store = new CeSessionStore(h.db); + const s = store.create({ stage: "brainstorm", turnIntervalMs: 1000 }); + const completed = store.update(s.id, { status: "completed", lastActivityAt: Date.now() - 1_000_000 })!; + expect(store.isStale(completed)).toBe(false); + }); + + it("Bug 2: a human-slow awaiting_input session past 3× is NOT recovered, while a stuck active one still is", () => { + const store = new CeSessionStore(h.db); + const now = Date.now(); + + // A session legitimately waiting on a human, far past 3× the interval. Human + // response time is unbounded — this is not a crashed turn. + const waiting = store.create({ stage: "brainstorm", turnIntervalMs: 1000 }); + store.update(waiting.id, { + status: "awaiting_input", + currentQuestion: { id: "q", type: "text", question: "?" }, + lastActivityAt: now - 100_000, // 100× interval + }); + + // A genuinely stuck in-flight agent turn past the threshold. + const stuck = store.create({ stage: "brainstorm", turnIntervalMs: 1000 }); + store.update(stuck.id, { status: "active", lastActivityAt: now - 100_000 }); + + const recovered = store.recoverStaleSessions(now); + + // The human-wait is excluded from the interval rubric entirely. + expect(recovered).not.toContain(waiting.id); + expect(store.get(waiting.id)!.status).toBe("awaiting_input"); + + // The stuck active turn is still recovered (here: no question → interrupted). + expect(recovered).toContain(stuck.id); + expect(store.get(stuck.id)!.status).toBe("interrupted"); + }); +}); + +describe("corrupt-JSON resilience + status validation", () => { + it("degrades gracefully when a JSON column is corrupted (no throw)", () => { + const store = new CeSessionStore(h.db); + const s = store.create({ stage: "brainstorm" }); + // Corrupt both JSON columns directly in the DB. + h.db + .prepare("UPDATE ce_sessions SET currentQuestion = ?, conversationHistory = ? WHERE id = ?") + .run("{not valid json", "also not json", s.id); + + // Reading the row must not throw; corrupt fields fall back to null / []. + const read = store.get(s.id)!; + expect(read.id).toBe(s.id); + expect(read.currentQuestion).toBeNull(); + expect(read.conversationHistory).toEqual([]); + // The rest of the row still surfaces the session's real state. + expect(read.stage).toBe("brainstorm"); + }); + + it("degrades semantically-wrong-but-valid JSON to null / [] (not just syntax errors)", () => { + const store = new CeSessionStore(h.db); + const s = store.create({ stage: "brainstorm" }); + // Valid JSON, wrong shape: conversationHistory='null' parses to a non-array; + // currentQuestion='{}' parses to an object missing the required question fields. + h.db + .prepare("UPDATE ce_sessions SET currentQuestion = ?, conversationHistory = ? WHERE id = ?") + .run("{}", "null", s.id); + + const read = store.get(s.id)!; + expect(read.currentQuestion).toBeNull(); + expect(read.conversationHistory).toEqual([]); + // appendHistory must not throw spreading the recovered (array) history. + expect(() => store.appendHistory(s.id, { role: "user", text: "hi", at: "t" })).not.toThrow(); + expect(store.get(s.id)!.conversationHistory).toHaveLength(1); + }); +}); + +describe("asCeSessionStatus validation", () => { + it("accepts valid statuses and rejects anything else", async () => { + const { asCeSessionStatus } = await import("../session/session-store.js"); + expect(asCeSessionStatus("active")).toBe("active"); + expect(asCeSessionStatus("interrupted")).toBe("interrupted"); + expect(asCeSessionStatus("bogus")).toBeUndefined(); + expect(asCeSessionStatus("")).toBeUndefined(); + expect(asCeSessionStatus(undefined)).toBeUndefined(); + }); +}); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/settings.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/settings.test.ts new file mode 100644 index 0000000000..5dc51f68ff --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/settings.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import type { PluginSettingType } from "@fusion/plugin-sdk"; +import { listStages } from "../session/stage-registry.js"; +import { + DEFAULT_ENABLED_STAGES, + DEFAULT_MODEL_ID, + DEFAULT_PROVIDER, + DEFAULT_RECONCILE_INTERVAL_MINUTES, + DEFAULT_RECONCILE_ON_HOOKS, + getDefaultModelId, + getDefaultProvider, + getEnabledStages, + getReconcileIntervalMinutes, + getReconcileOnHooks, + settingsSchema, +} from "../settings.js"; + +const VALID_TYPES: PluginSettingType[] = ["string", "number", "boolean", "enum", "password", "array"]; + +describe("compound engineering plugin settings schema", () => { + it("uses only valid plugin setting types and labels", () => { + for (const [key, schema] of Object.entries(settingsSchema)) { + expect(VALID_TYPES).toContain(schema.type); + expect(typeof schema.label).toBe("string"); + expect(schema.label?.trim().length).toBeGreaterThan(0); + + if (schema.type === "enum") { + expect(Array.isArray(schema.enumValues)).toBe(true); + expect(schema.enumValues?.length ?? 0).toBeGreaterThan(0); + } + + if (schema.type === "array") { + expect(schema.itemType).toBe("string"); + } + + expect(key.length).toBeGreaterThan(0); + } + }); + + it("exposes the expected keys grouped into Sessions and Sync", () => { + expect(Object.keys(settingsSchema).sort()).toEqual( + [ + "defaultModelId", + "defaultProvider", + "enabledStages", + "reconcileIntervalMinutes", + "reconcileOnHooks", + ].sort(), + ); + expect(settingsSchema.defaultProvider.group).toBe("Sessions"); + expect(settingsSchema.defaultModelId.group).toBe("Sessions"); + expect(settingsSchema.enabledStages.group).toBe("Sessions"); + expect(settingsSchema.reconcileOnHooks.group).toBe("Sync"); + expect(settingsSchema.reconcileIntervalMinutes.group).toBe("Sync"); + }); + + it("defaults enabledStages to the full stage registry", () => { + expect(DEFAULT_ENABLED_STAGES).toEqual(listStages().map((s) => s.stageId)); + expect(settingsSchema.enabledStages.defaultValue).toEqual(DEFAULT_ENABLED_STAGES); + }); + + it("uses documented literal defaults", () => { + expect(settingsSchema.reconcileOnHooks.defaultValue).toBe(true); + expect(settingsSchema.reconcileIntervalMinutes.defaultValue).toBe(15); + expect(settingsSchema.defaultProvider.defaultValue).toBe(""); + expect(settingsSchema.defaultModelId.defaultValue).toBe(""); + }); + + it("returns defaults for empty settings", () => { + const empty = {}; + expect(getDefaultProvider(empty)).toBeUndefined(); + expect(DEFAULT_PROVIDER).toBe(""); + expect(getDefaultModelId(empty)).toBeUndefined(); + expect(DEFAULT_MODEL_ID).toBe(""); + // getEnabledStages re-reads the LIVE registry default (so runtime-registered + // stages are launchable); DEFAULT_ENABLED_STAGES is the import-time snapshot + // used for the schema/manifest literal. + expect(getEnabledStages(empty)).toEqual(listStages().map((s) => s.stageId)); + expect(getReconcileOnHooks(empty)).toBe(DEFAULT_RECONCILE_ON_HOOKS); + expect(getReconcileIntervalMinutes(empty)).toBe(DEFAULT_RECONCILE_INTERVAL_MINUTES); + }); + + it("returns configured values when provided", () => { + const populated = { + defaultProvider: "anthropic", + defaultModelId: "claude-opus", + enabledStages: ["strategy", "plan"], + reconcileOnHooks: false, + reconcileIntervalMinutes: 30, + } satisfies Record; + + expect(getDefaultProvider(populated)).toBe("anthropic"); + expect(getDefaultModelId(populated)).toBe("claude-opus"); + expect(getEnabledStages(populated)).toEqual(["strategy", "plan"]); + expect(getReconcileOnHooks(populated)).toBe(false); + expect(getReconcileIntervalMinutes(populated)).toBe(30); + }); + + it("clamps the reconcile cadence to at least one minute", () => { + expect(getReconcileIntervalMinutes({ reconcileIntervalMinutes: 0 })).toBe(1); + expect(getReconcileIntervalMinutes({ reconcileIntervalMinutes: -5 })).toBe(1); + expect(getReconcileIntervalMinutes({ reconcileIntervalMinutes: 7.9 })).toBe(7); + }); + + it("falls back to defaults for malformed values", () => { + const liveDefault = listStages().map((s) => s.stageId); + expect(getEnabledStages({ enabledStages: "not-an-array" })).toEqual(liveDefault); + expect(getEnabledStages({ enabledStages: [] })).toEqual(liveDefault); + expect(getDefaultProvider({ defaultProvider: " " })).toBeUndefined(); + expect(getReconcileOnHooks({ reconcileOnHooks: "yes" })).toBe(DEFAULT_RECONCILE_ON_HOOKS); + }); +}); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/skill-installation.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/skill-installation.test.ts new file mode 100644 index 0000000000..93df601021 --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/skill-installation.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + assertPluginLocalTarget, + installBundledCeSkills, + isPluginLocalPath, + resolveBundledSkillsRoot, +} from "../skill-installation.js"; +import { COMPOUND_ENGINEERING_SKILLS } from "../skills.js"; + +describe("compound engineering bundled skill install", () => { + let tmp: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "ce-skill-install-")); + }); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it("installs every bundled CE skill into the plugin-local target", () => { + const targetRoot = join(tmp, "plugin-local", ".fusion-ce-skills"); + const { results } = installBundledCeSkills({ targetRoot }); + + for (const skill of COMPOUND_ENGINEERING_SKILLS) { + const r = results.find((x) => x.skillId === skill.skillId)!; + expect(r.outcome).toBe("installed"); + const skillMd = join(targetRoot, skill.skillId, "SKILL.md"); + expect(existsSync(skillMd)).toBe(true); + } + }); + + it("is idempotent: a second run with the target present is a skip-if-exists no-op", () => { + const targetRoot = join(tmp, ".fusion-ce-skills"); + const first = installBundledCeSkills({ targetRoot }); + expect(first.results.every((r) => r.outcome === "installed")).toBe(true); + + // Tamper with an installed file; skip-if-exists must NOT overwrite it. + const sentinelPath = join(targetRoot, "ce-plan", "SKILL.md"); + writeFileSync(sentinelPath, "SENTINEL"); + + const second = installBundledCeSkills({ targetRoot }); + expect(second.results.every((r) => r.outcome === "skipped")).toBe(true); + expect(readFileSync(sentinelPath, "utf-8")).toBe("SENTINEL"); + }); + + // ── AE2: isolation — a global compound-engineering install is untouched ── + it("AE2: never writes outside the plugin-local target when a global install exists", () => { + // Seed a fake global compound-engineering install under a fake HOME. + const fakeHome = join(tmp, "home"); + const globalSkillsDir = join(fakeHome, ".claude", "skills", "ce-plan"); + mkdirSync(globalSkillsDir, { recursive: true }); + const globalSkillMd = join(globalSkillsDir, "SKILL.md"); + writeFileSync(globalSkillMd, "GLOBAL-ORIGINAL"); + const beforeContent = readFileSync(globalSkillMd, "utf-8"); + const beforeMtime = statSync(globalSkillMd).mtimeMs; + + const targetRoot = join(tmp, "plugin-local", ".fusion-ce-skills"); + const { targetRoot: usedTarget, results } = installBundledCeSkills({ targetRoot }); + + // The install target is provably plugin-local, never the global dir. + expect(usedTarget.includes(join(".claude", "skills"))).toBe(false); + expect(isPluginLocalPath(usedTarget)).toBe(true); + for (const r of results) { + expect(r.targetDir.includes(join(".claude", "skills"))).toBe(false); + } + + // The global install is byte-for-byte and mtime untouched. + expect(readFileSync(globalSkillMd, "utf-8")).toBe(beforeContent); + expect(statSync(globalSkillMd).mtimeMs).toBe(beforeMtime); + }); + + it("AE2 guard: refuses to install into a global client skills directory", () => { + const globalTarget = join(tmp, "home", ".claude", "skills"); + expect(() => assertPluginLocalTarget(globalTarget)).toThrow(/plugin-local/i); + expect(() => installBundledCeSkills({ targetRoot: globalTarget })).toThrow(/plugin-local/i); + expect(isPluginLocalPath(globalTarget)).toBe(false); + }); + + // ── Edge: malformed/missing SKILL.md surfaces a clear error ── + it("edge: a missing/malformed bundled SKILL.md surfaces a clear load error, not a silent skip", () => { + // Point at an empty source root so every skill's source dir is missing. + const emptySource = join(tmp, "empty-source"); + mkdirSync(emptySource, { recursive: true }); + const targetRoot = join(tmp, ".fusion-ce-skills"); + + const { results } = installBundledCeSkills({ targetRoot, sourceRoot: emptySource }); + for (const r of results) { + expect(r.outcome).toBe("error"); + expect(r.reason).toMatch(/missing|SKILL\.md/i); + } + + // Now a malformed SKILL.md (no frontmatter name) for one skill. + const malformedSource = join(tmp, "malformed-source"); + const planDir = join(malformedSource, "ce-plan"); + mkdirSync(planDir, { recursive: true }); + writeFileSync(join(planDir, "SKILL.md"), "no frontmatter here\n"); + const res2 = installBundledCeSkills({ targetRoot: join(tmp, "t2"), sourceRoot: malformedSource }); + const plan = res2.results.find((r) => r.skillId === "ce-plan")!; + expect(plan.outcome).toBe("error"); + expect(plan.reason).toMatch(/frontmatter 'name:'/i); + }); + + it("bundled source root resolves and contains all SKILL.md files", () => { + const root = resolveBundledSkillsRoot(); + expect(existsSync(root)).toBe(true); + for (const skill of COMPOUND_ENGINEERING_SKILLS) { + expect(existsSync(join(root, skill.skillId, "SKILL.md"))).toBe(true); + } + }); +}); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/skill-interaction-audit.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/skill-interaction-audit.test.ts new file mode 100644 index 0000000000..cff9a9c9e8 --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/skill-interaction-audit.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; +import type { PlanningQuestionType } from "@fusion/core"; +import { + RICH_INTERACTION_TYPES, + canRenderRichly, + isRichInteractionType, +} from "../dashboard/ce-question-support.js"; +import { getStage } from "../session/stage-registry.js"; + +/** + * Skill-interaction audit (Success Criteria, U6). + * + * CLASSIFICATION PROVENANCE — be honest. This audit is a DECLARED / + * EXPECTED classification, NOT a measurement taken from driving live `ce-*` + * skill sessions. We do not invoke a real model here. The interaction types + * each stage performs are read from each stage's protocol — the SKILL.md + * "Interaction Rules / Interaction Method" sections that govern how the skill + * asks questions (e.g. ce-brainstorm: "Ask one question at a time", "Prefer + * single-select", "Use multi-select rarely", open-ended free-text questions; + * ce-ideate / ce-plan: single-select-preferred + free-text). Each declared + * interaction is then classified against CeFlow's renderable set + * (`RICH_INTERACTION_TYPES`) to compute a rich-vs-chat coverage ratio. + * + * The test FAILS if any sampled interaction is unclassified (a type CeFlow's + * support module doesn't recognize at all), which is the guard that keeps the + * audit honest as the skills' protocols evolve. When a stage declares a + * confirm/text/single/multi interaction, that is rich-renderable; an + * "unknown_type" declaration would be unclassified and fail. + */ + +interface DeclaredInteraction { + /** A label for the interaction occurrence within the stage's protocol. */ + name: string; + /** The interaction type the stage's protocol uses for it. */ + type: string; + /** Whether the stage's protocol supplies options for this interaction. */ + hasOptions: boolean; +} + +interface StageProtocol { + stageId: string; + /** Source the declaration was read from (for traceability in the report). */ + source: string; + interactions: DeclaredInteraction[]; +} + +/** + * Declared protocols for the sampled stages, derived from each SKILL.md's + * Interaction section. These are protocol declarations, not live captures. + */ +const SAMPLED_STAGES: StageProtocol[] = [ + { + stageId: "brainstorm", + source: "src/skills/ce-brainstorm/SKILL.md → Interaction Rules", + interactions: [ + { name: "narrowing choice (one direction/priority/next step)", type: "single_select", hasOptions: true }, + { name: "compatible set (goals/constraints/non-goals)", type: "multi_select", hasOptions: true }, + { name: "genuinely open / diagnostic question", type: "text", hasOptions: false }, + { name: "proceed-to-write confirmation", type: "confirm", hasOptions: false }, + ], + }, + { + stageId: "ideate", + source: "src/skills/ce-ideate/SKILL.md → Interaction Method", + interactions: [ + { name: "concise single-select when natural options exist", type: "single_select", hasOptions: true }, + { name: "open-ended ideation prompt", type: "text", hasOptions: false }, + ], + }, + { + stageId: "plan", + source: "src/skills/ce-plan/SKILL.md → Interaction Method", + interactions: [ + { name: "concise single-select choice", type: "single_select", hasOptions: true }, + { name: "clarifying free-text question (Phase 0.4 bootstrap)", type: "text", hasOptions: false }, + ], + }, +]; + +function classify(i: DeclaredInteraction): { classified: boolean; rich: boolean } { + const classified = isRichInteractionType(i.type); + if (!classified) return { classified: false, rich: false }; + // canRenderRichly is the same predicate CeFlow uses at runtime. + const rich = canRenderRichly({ + type: i.type as PlanningQuestionType, + options: i.hasOptions ? [{ id: "x", label: "x" }] : undefined, + }); + return { classified: true, rich }; +} + +describe("skill-interaction audit (declared classification)", () => { + it("every sampled stage is a registered stage", () => { + for (const s of SAMPLED_STAGES) { + expect(getStage(s.stageId), `stage ${s.stageId} must be registered`).toBeDefined(); + } + }); + + it("classifies every declared interaction (fails on an unclassified interaction)", () => { + const unclassified: string[] = []; + for (const stage of SAMPLED_STAGES) { + for (const i of stage.interactions) { + if (!isRichInteractionType(i.type)) { + unclassified.push(`${stage.stageId}:${i.name} (type=${i.type})`); + } + } + } + expect(unclassified, `unclassified interactions: ${unclassified.join(", ")}`).toHaveLength(0); + }); + + it("produces a measured rich-vs-chat coverage ratio for the sampled stages", () => { + let total = 0; + let rich = 0; + const perStage: Array<{ stageId: string; rich: number; total: number }> = []; + + for (const stage of SAMPLED_STAGES) { + let sRich = 0; + for (const i of stage.interactions) { + total += 1; + const c = classify(i); + if (c.rich) { + rich += 1; + sRich += 1; + } + } + perStage.push({ stageId: stage.stageId, rich: sRich, total: stage.interactions.length }); + } + + const ratio = rich / total; + + // Emit the produced coverage figure (visible in test output / report). + // eslint-disable-next-line no-console + console.log( + `[skill-interaction-audit] rich-renderable coverage: ${rich}/${total} = ${(ratio * 100).toFixed(1)}% ` + + `(declared classification, not live-measured)\n` + + perStage.map((p) => ` - ${p.stageId}: ${p.rich}/${p.total}`).join("\n"), + ); + + // The audit must compute and assert a real ratio. For the sampled stages, + // every declared interaction maps onto CeFlow's renderable set, so coverage + // is 100% — but the assertion is on the COMPUTED value, and the guard above + // would drop it below 1 (and the unclassified test would fail) the moment a + // stage declares an interaction CeFlow can't express. + expect(total).toBeGreaterThanOrEqual(2 + 2 + 2); // 2-3 stages, ≥2 interactions each + expect(ratio).toBeGreaterThan(0); + expect(ratio).toBeLessThanOrEqual(1); + expect(ratio).toBe(rich / total); + + // Sanity: the four rich types CeFlow advertises are the classification set. + expect([...RICH_INTERACTION_TYPES].sort()).toEqual( + ["confirm", "multi_select", "single_select", "text"], + ); + }); + + it("a hypothetical unrenderable interaction would be unclassified (guard proof)", () => { + const rogue: DeclaredInteraction = { name: "ranked drag-and-drop", type: "rank_order", hasOptions: true }; + expect(isRichInteractionType(rogue.type)).toBe(false); + expect(classify(rogue).rich).toBe(false); + }); +}); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/skill-reachability.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/skill-reachability.test.ts new file mode 100644 index 0000000000..79c4f1af7a --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/skill-reachability.test.ts @@ -0,0 +1,64 @@ +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { installBundledCeSkills } from "../skill-installation.js"; +import { resolveStageSkillPaths, buildStageSystemPrompt } from "../session/orchestrator.js"; +import { getStage } from "../session/stage-registry.js"; + +/** + * Prove the launched stage's ce-* skill is REACHABLE for the session — now via + * the real seam wiring (closes the U2 → U5 carry-forward). + * + * The U4 `CreateInteractiveAiSessionOptions` surface now carries + * `requestedSkillNames` + `additionalSkillPaths`, which the engine adapter + * forwards into `createFnAgent` (`skills` + the loader's `additionalSkillPaths`). + * So the orchestrator hands the session BOTH the stage's skill id and the + * install directory to discover it from. This test asserts: + * 1. the install directory `resolveStageSkillPaths()` returns actually holds + * the stage's `/SKILL.md` after install, and + * 2. the system prompt names the stage's skill id. + * + * The engine package separately proves (compound-engineering-skill-resolution + * .test.ts) that `loadSkills` + the resolver resolve a ce-* skill once that + * directory is on the discovery path — together the chain is closed. + */ + +describe("stage skill reachability (real seam wiring)", () => { + let tmpTargets: string[] = []; + afterEach(() => { + for (const t of tmpTargets) rmSync(t, { recursive: true, force: true }); + tmpTargets = []; + vi.restoreAllMocks(); + }); + + it("resolveStageSkillPaths returns the plugin-local install root the session scans", () => { + // The orchestrator passes this as additionalSkillPaths; never a global path. + const skillPaths = resolveStageSkillPaths(); + expect(skillPaths).toHaveLength(1); + expect(skillPaths[0]).toMatch(/\.fusion-ce-skills$/); + expect(skillPaths[0]).not.toMatch(/\.(claude|codex|gemini)[/\\]skills/); + }); + + it("installing bundled skills onto a discovery root produces the stage's SKILL.md", () => { + const stage = getStage("brainstorm")!; + // Install into a temp discovery root (isolated; mirrors what the real + // plugin-local install produces, without writing into the repo dir). + const target = mkdtempSync(join(tmpdir(), "ce-skill-reach-")); + tmpTargets.push(target); + + const { results } = installBundledCeSkills({ targetRoot: target }); + expect(results.every((r) => r.outcome === "installed" || r.outcome === "skipped")).toBe(true); + + const installedSkillMd = join(target, stage.skillId, "SKILL.md"); + expect(existsSync(installedSkillMd)).toBe(true); + }); + + it("the stage system prompt names the stage's ce-* skill id", () => { + const stage = getStage("brainstorm")!; + const prompt = buildStageSystemPrompt(stage); + expect(prompt).toContain(stage.skillId); // "ce-brainstorm" + expect(prompt).toContain("question"); + expect(prompt).toContain("complete"); + }); +}); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/skill-wiring.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/skill-wiring.test.ts new file mode 100644 index 0000000000..379251380e --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/skill-wiring.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + CreateInteractiveAiSessionOptions, + InteractiveAiSessionEvent, +} from "@fusion/core"; +import { CeOrchestrator } from "../session/orchestrator.js"; +import { getStage } from "../session/stage-registry.js"; +import { resolveDefaultInstallTargetRoot } from "../skill-installation.js"; +import { makeHarness, makeScriptedSession, type TestHarness } from "./_harness.js"; + +/** + * Proves the orchestrator hands a launched session the wiring a LIVE agent needs + * to actually load the stage's bundled ce-* skill (closes the U2/U5 carry-forward): + * - cwd is the real project root (where the agent reads context + writes the + * artifact), NOT the skills directory; + * - requestedSkillNames names the stage's ce-* skill; + * - additionalSkillPaths includes the plugin-local install root so the engine + * loader can discover that skill. + * The engine adapter forwards these to createFnAgent (skills + additionalSkillPaths); + * compound-engineering-skill-resolution.test.ts proves the loader then resolves it. + */ +describe("session skill wiring", () => { + let h: TestHarness; + beforeEach(() => { + h = makeHarness(); + }); + afterEach(() => { + h.close(); + }); + + it("start() passes the stage skill id, install path, and project-root cwd to the factory", async () => { + const captured: CreateInteractiveAiSessionOptions[] = []; + const script: InteractiveAiSessionEvent[] = [ + { type: "complete", data: { artifact: "# done" } }, + ]; + const session = makeScriptedSession(script); + const factory = vi.fn(async (opts: CreateInteractiveAiSessionOptions) => { + captured.push(opts); + return { session }; + }); + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: factory, + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); + + await orch.start("brainstorm", { openingMessage: "let's go" }); + + expect(captured).toHaveLength(1); + const opts = captured[0]; + const stage = getStage("brainstorm")!; + // cwd is the project root, not the skills dir. + expect(opts.cwd).toBe(h.projectRoot); + // the stage's ce-* skill is requested... + expect(opts.requestedSkillNames).toEqual([stage.skillId]); + // ...and the plugin-local install root is on the discovery path. + expect(opts.additionalSkillPaths).toEqual([resolveDefaultInstallTargetRoot()]); + expect(opts.additionalSkillPaths?.[0]).toMatch(/\.fusion-ce-skills$/); + }); +}); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/sync.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/sync.test.ts new file mode 100644 index 0000000000..651ce2cbd4 --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/sync.test.ts @@ -0,0 +1,306 @@ +import { rm } from "node:fs/promises"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { InteractiveAiSessionEvent, PluginContext, Task } from "@fusion/core"; +import { TaskStore } from "@fusion/core"; +import plugin, { + CeOrchestrator, + CE_PLUGIN_ID, + WORK_STAGE_ID, +} from "../index.js"; +import { getCePipelineStore } from "../sync/pipeline-store.js"; +import { CeReconciler, reconcileCePipelines } from "../sync/reconciler.js"; +import { registerStage, unregisterStage } from "../session/stage-registry.js"; +import { makeScriptedSession } from "./_harness.js"; + +/** + * U8 bidirectional-sync tests. REAL in-memory TaskStore (genuine board tasks) + + * the actual lifecycle-hook handlers and reconciler. We exercise the two + * separate state machines (board columns vs ce_pipeline_state) and prove the + * dropped-event convergence path independently of the hooks. + */ + +let rootDir: string; +let taskStore: TaskStore; +let ctx: PluginContext; +let emitted: Array<{ event: string; data: unknown }>; + +beforeEach(async () => { + rootDir = mkdtempSync(join(tmpdir(), "ce-sync-")); + taskStore = new TaskStore(rootDir, join(rootDir, ".fusion-global"), { inMemoryDb: true }); + await taskStore.init(); + emitted = []; + ctx = { + pluginId: CE_PLUGIN_ID, + taskStore, + settings: {}, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + emitEvent: (event: string, data: unknown) => emitted.push({ event, data }), + } as unknown as PluginContext; +}); + +afterEach(async () => { + taskStore?.close(); + await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); +}); + +/** The board enforces ordered transitions; walk a task forward to a target column. */ +const COLUMN_PATH = ["triage", "todo", "in-progress", "in-review", "done"]; +async function moveTo(taskId: string, target: string): Promise { + const current = (await taskStore.getTask(taskId))!.column; + const from = COLUMN_PATH.indexOf(current); + const to = COLUMN_PATH.indexOf(target); + for (let i = from + 1; i <= to; i++) { + await taskStore.moveTask(taskId, COLUMN_PATH[i] as never); + } +} + +/** Run the work stage so a CE pipeline + its first board task + state record exist. */ +async function landPipeline(stage = "plan"): Promise<{ cePipelineId: string; task: Task }> { + // Register-free: drive the WORK stage (which seeds state) but point the link at + // `stage` so we can advance through the real stage order. Simplest: use the + // work bridge directly via the orchestrator at the work stage, then rewrite the + // pipeline state's currentStage to `stage` for ordering tests. + const script: InteractiveAiSessionEvent[] = [ + { type: "complete", data: { artifact: "# log\n", tasks: [{ description: "do stage work" }] } }, + ]; + const orch = new CeOrchestrator({ + ctx, + createInteractiveAiSession: vi.fn(async () => ({ session: makeScriptedSession(script) })), + projectRoot: rootDir, + turnTimeoutMs: 5000, + }); + const started = await orch.start(WORK_STAGE_ID, { openingMessage: "go" }); + const cePipelineId = started.session.id; + const store = getCePipelineStore(ctx); + + if (stage !== WORK_STAGE_ID) { + // Reposition both the link stage and the state stage to `stage` so the + // pipeline has a non-terminal stage to advance FROM. + const links = store.listByPipeline(cePipelineId); + const db = taskStore.getDatabase(); + for (const l of links) { + db.prepare(`UPDATE ce_pipeline_links SET ceStageId = ? WHERE id = ?`).run(stage, l.id); + } + store.upsertState({ cePipelineId, currentStage: stage, status: "running" }); + } + const tasks = await taskStore.listTasks(); + return { cePipelineId, task: tasks[0] }; +} + +describe("U8 inbound hooks (board → pipeline)", () => { + it("onTaskMoved only enqueues when the task is CE-linked; ignores unrelated tasks fast", async () => { + const { task } = await landPipeline("plan"); + const store = getCePipelineStore(ctx); + + // Unrelated (non-CE) board task → hook is a no-op (no queue row). + const other = await taskStore.createTask({ description: "unrelated work" }); + await plugin.hooks.onTaskMoved!(other, "triage", "todo", ctx); + expect(store.listPendingSync()).toHaveLength(0); + + // CE-linked task move → a queue row is appended synchronously. We do NOT + // await the hook (its body is synchronous; awaiting would let the + // fired-and-forgotten reconcile drain the row), so we observe the pending + // entry the fast path wrote before any deferred work runs. + void plugin.hooks.onTaskMoved!(task, "todo", "in-progress", ctx); + const pending = store.listPendingSync(); + expect(pending.length).toBeGreaterThanOrEqual(1); + expect(pending.some((p) => p.taskId === task.id && p.reason === "task_moved")).toBe(true); + }); + + it("the hook handler does NOT advance the pipeline inline (heavy work is deferred)", async () => { + const { cePipelineId, task } = await landPipeline("plan"); + const store = getCePipelineStore(ctx); + + // Move the task to a terminal column then fire ONLY the synchronous part of + // the hook. We assert that synchronously the pipeline stage is unchanged — + // advancement happens in the (deferred) reconcile, not inline. + await moveTo(task.id, "done"); + const stageBefore = store.getState(cePipelineId)!.currentStage; + // Drive the hook but capture state immediately after the synchronous body. + const p = plugin.hooks.onTaskMoved!(task, "in-progress", "done", ctx); + // The synchronous body has already run (enqueue) but the fired-and-forgotten + // reconcile has not been awaited. Inline, the stage must be unchanged. + expect(store.getState(cePipelineId)!.currentStage).toBe(stageBefore); + // A queue row exists (the fast path did its job). + expect(store.listPendingSync().some((q) => q.taskId === task.id)).toBe(true); + await p; // let the fire-and-forget settle for clean teardown. + }); + + it("the hook handler completes well under the 5s budget even with a slow reconciler", async () => { + const { task } = await landPipeline("plan"); + await moveTo(task.id, "done"); + const start = Date.now(); + await plugin.hooks.onTaskMoved!(task, "in-progress", "done", ctx); + // The hook awaits NOTHING heavy; it returns synchronously-ish. + expect(Date.now() - start).toBeLessThan(1000); + }); +}); + +describe("U8 reconciler (convergence + outbound)", () => { + it("AE3: a CE task reaching a terminal column advances the pipeline to the next stage with NO manual step", async () => { + const { cePipelineId, task } = await landPipeline("plan"); + const store = getCePipelineStore(ctx); + expect(store.getState(cePipelineId)!.currentStage).toBe("plan"); + + // Board moves the task to done (the only manual-equivalent action: a normal + // board transition). The hook enqueues; reconcile advances. + await moveTo(task.id, "done"); + await plugin.hooks.onTaskCompleted!({ ...task, column: "done" }, ctx); + await reconcileCePipelines(ctx); + + // Pipeline advanced plan → work (next in stage order) with no manual step. + const state = store.getState(cePipelineId)!; + expect(state.currentStage).toBe("work"); + }); + + it("outbound: advancing the pipeline propagates a NEW next-stage board task", async () => { + const { cePipelineId, task } = await landPipeline("plan"); + const before = (await taskStore.listTasks()).length; + + await moveTo(task.id, "in-review"); + await reconcileCePipelines(ctx); // no hook fired — pure re-derivation. + + const after = await taskStore.listTasks(); + expect(after.length).toBe(before + 1); + const newTask = after.find((t) => t.id !== task.id)!; + const meta = newTask.sourceMetadata as Record; + expect(meta.pluginId).toBe(CE_PLUGIN_ID); + expect(meta.cePipelineId).toBe(cePipelineId); + expect(meta.ceStageId).toBe("work"); + // The pipeline is now awaiting the new board task. + expect(getCePipelineStore(ctx).getState(cePipelineId)!.status).toBe("awaiting_board"); + }); + + it("MISSED HOOK EVENT → the reconcile sweep still converges (no queue row needed)", async () => { + const { cePipelineId, task } = await landPipeline("plan"); + const store = getCePipelineStore(ctx); + + // Simulate a DROPPED hook: move the board task to a terminal column but do + // NOT call any hook and do NOT enqueue anything. + await moveTo(task.id, "done"); + expect(store.listPendingSync()).toHaveLength(0); // nothing was enqueued. + expect(store.getState(cePipelineId)!.currentStage).toBe("plan"); // not advanced yet. + + // The on-demand sweep re-derives the transition from board truth alone. + const result = await new CeReconciler(ctx).reconcile(); + expect(result.advanced).toBe(1); + expect(store.getState(cePipelineId)!.currentStage).toBe("work"); + }); + + it("reconcile is idempotent: a second sweep does not double-advance or duplicate tasks", async () => { + const { cePipelineId, task } = await landPipeline("plan"); + await moveTo(task.id, "done"); + await reconcileCePipelines(ctx); + const afterFirst = (await taskStore.listTasks()).length; + const stageFirst = getCePipelineStore(ctx).getState(cePipelineId)!.currentStage; + + await reconcileCePipelines(ctx); + expect((await taskStore.listTasks()).length).toBe(afterFirst); + expect(getCePipelineStore(ctx).getState(cePipelineId)!.currentStage).toBe(stageFirst); + }); + + it("partial completion does not advance: pipeline stays running until ALL current-stage tasks are terminal", async () => { + const { cePipelineId, task } = await landPipeline("plan"); + const store = getCePipelineStore(ctx); + // Add a second current-stage task to the SAME pipeline/stage. + const t2 = await taskStore.createTask({ description: "second plan task" }); + store.createLink({ taskId: t2.id, cePipelineId, ceStageId: "plan", ceArtifactPath: null }); + + await moveTo(task.id, "done"); // only one terminal. + await reconcileCePipelines(ctx); + expect(store.getState(cePipelineId)!.currentStage).toBe("plan"); // not advanced. + + await moveTo(t2.id, "done"); // now both terminal. + await reconcileCePipelines(ctx); + expect(store.getState(cePipelineId)!.currentStage).toBe("work"); // advanced. + }); + + it("Bug 1: a deleted current-stage task does NOT wedge the pipeline — one terminal + one deleted still advances", async () => { + const { cePipelineId, task } = await landPipeline("plan"); + const store = getCePipelineStore(ctx); + + // Add a SECOND current-stage task linked to the same pipeline/stage, then + // DELETE it from the board (loadTasks will yield undefined for it). + const doomed = await taskStore.createTask({ description: "second plan task (to delete)" }); + store.createLink({ taskId: doomed.id, cePipelineId, ceStageId: "plan", ceArtifactPath: null }); + await taskStore.deleteTask(doomed.id); + + // The remaining task reaches terminal. Pre-fix: the deleted task made + // `every(... t && ...)` false, wedging the pipeline at "plan" forever. + await moveTo(task.id, "done"); + await reconcileCePipelines(ctx); + + // Post-fix: terminality is computed over EXISTING tasks only → it advances. + expect(store.getState(cePipelineId)!.currentStage).toBe("work"); + }); + + it("Bug 1: if ALL current-stage tasks were deleted, the pipeline is left unchanged (no wedge, no crash)", async () => { + const { cePipelineId, task } = await landPipeline("plan"); + const store = getCePipelineStore(ctx); + + await taskStore.deleteTask(task.id); // every current-stage task gone. + + // Safe non-wedging behavior: state unchanged, no advancement, no throw. + await expect(reconcileCePipelines(ctx)).resolves.toBeTruthy(); + expect(store.getState(cePipelineId)!.currentStage).toBe("plan"); + }); + + it("Bug 3: a stage registered with an `order` between two existing stages is the next stage (not append-at-end)", async () => { + // Insert a stage between plan(400) and work(500). Registry/Map insertion + // order would append it at the end; the explicit `order` slots it mid-pipeline. + registerStage({ + stageId: "refine", + order: 450, + skillId: "ce-refine", + artifactLocation: "docs/refine/", + icon: "Wand", + label: "Refine", + }); + try { + const { cePipelineId, task } = await landPipeline("plan"); + const store = getCePipelineStore(ctx); + + await moveTo(task.id, "done"); + await reconcileCePipelines(ctx); + + // Advances to the inserted stage, NOT to "work" (the old append-at-end). + expect(store.getState(cePipelineId)!.currentStage).toBe("refine"); + } finally { + unregisterStage("refine"); + } + }); +}); + +describe("U8 conflict resolution (board vs CE authority)", () => { + it("simultaneous board move + CE advance: board keeps the task column, CE keeps the pipeline content", async () => { + const { cePipelineId, task } = await landPipeline("plan"); + const store = getCePipelineStore(ctx); + + // CE-flow side: the pipeline owns its content; record an artifact (CE-authoritative). + store.transitionState(cePipelineId, { lastArtifactPath: "/docs/plans/p.md" }); + + // Board side: move the task to done (board-authoritative for the column). + await moveTo(task.id, "done"); + + // Reconcile resolves the collision: it READS the board column (never rewrites + // the terminal task) and WRITES only CE-owned fields + a NEW task. + await reconcileCePipelines(ctx); + + // Board authority: the original task's column is exactly what the board set. + const reread = await taskStore.getTask(task.id); + expect(reread!.column).toBe("done"); + + // CE authority: the pipeline content (stage + artifact) is what CE wrote. + const state = store.getState(cePipelineId)!; + expect(state.currentStage).toBe("work"); + expect(state.lastArtifactPath).toBe("/docs/plans/p.md"); + + // The new outbound task is a fresh row — the writers never contended on one cell. + const tasks = await taskStore.listTasks(); + const next = tasks.find((t) => t.id !== task.id)!; + expect((next.sourceMetadata as Record).ceStageId).toBe("work"); + }); +}); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/work-bridge.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/work-bridge.test.ts new file mode 100644 index 0000000000..1ad88086ec --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/work-bridge.test.ts @@ -0,0 +1,159 @@ +import { rm } from "node:fs/promises"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { InteractiveAiSessionEvent, PluginContext } from "@fusion/core"; +import { TaskStore } from "@fusion/core"; +import { + CeOrchestrator, + CE_PLUGIN_ID, + CE_WORK_SOURCE_TYPE, + WORK_STAGE_ID, +} from "../session/orchestrator.js"; +import { getCePipelineStore } from "../sync/pipeline-store.js"; +import { makeScriptedSession } from "./_harness.js"; + +/** + * U7 work bridge tests. These use the REAL in-memory TaskStore (so created tasks + * are genuine board tasks under the normal lifecycle) and a scripted fake + * interactive session (the same deterministic driver U5/U6 use). + */ + +let rootDir: string; +let globalDir: string; +let taskStore: TaskStore; +let ctx: PluginContext; +let emitted: Array<{ event: string; data: unknown }>; + +beforeEach(async () => { + rootDir = mkdtempSync(join(tmpdir(), "ce-work-bridge-")); + globalDir = join(rootDir, ".fusion-global"); + taskStore = new TaskStore(rootDir, globalDir, { inMemoryDb: true }); + await taskStore.init(); + + emitted = []; + ctx = { + pluginId: CE_PLUGIN_ID, + taskStore, + settings: {}, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + emitEvent: (event: string, data: unknown) => { + emitted.push({ event, data }); + }, + } as unknown as PluginContext; +}); + +afterEach(async () => { + taskStore?.close(); + await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); +}); + +function makeOrch(script: InteractiveAiSessionEvent[]) { + const session = makeScriptedSession(script); + return new CeOrchestrator({ + ctx, + createInteractiveAiSession: vi.fn(async () => ({ session })), + projectRoot: rootDir, + turnTimeoutMs: 5000, + }); +} + +describe("work bridge (U7)", () => { + it("lands derived tasks on the board, tagged CE-originated with a resolvable back-reference", async () => { + const orch = makeOrch([ + { + type: "complete", + data: { + artifact: "# Work log\n", + tasks: [ + { title: "Wire the thing", description: "Implement the thing in module X." }, + { description: "Add tests for the thing.", column: "todo" }, + ], + }, + }, + ]); + + const started = await orch.start(WORK_STAGE_ID, { openingMessage: "do the work" }); + expect(started.session.status).toBe("completed"); + const cePipelineId = started.session.id; + + // Two board tasks created. + const tasks = await taskStore.listTasks(); + expect(tasks).toHaveLength(2); + + const pipelineStore = getCePipelineStore(ctx); + + for (const task of tasks) { + // CE-originated provenance: valid SourceType + CE marker + back-ref copy. + // (TaskStore exposes provenance as flat top-level fields on the Task.) + expect(task.sourceType).toBe(CE_WORK_SOURCE_TYPE); + const meta = task.sourceMetadata as Record | undefined; + expect(meta?.pluginId).toBe(CE_PLUGIN_ID); + expect(meta?.cePipelineId).toBe(cePipelineId); + expect(meta?.ceStageId).toBe(WORK_STAGE_ID); + + // Authoritative back-reference: the link row resolves task→pipeline/artifact. + const link = pipelineStore.findByTaskId(task.id); + expect(link).toBeDefined(); + expect(link?.cePipelineId).toBe(cePipelineId); + expect(link?.ceStageId).toBe(WORK_STAGE_ID); + expect(link?.ceArtifactPath).toBe(started.session.artifactPath); + } + + // Pipeline lists exactly its two links. + expect(pipelineStore.listByPipeline(cePipelineId)).toHaveLength(2); + + // Optional column honored. + const todoTask = tasks.find((t) => t.description.includes("Add tests")); + expect(todoTask?.column).toBe("todo"); + }); + + it("created tasks run the NORMAL lifecycle with no plugin interference", async () => { + const orch = makeOrch([ + { type: "complete", data: { tasks: [{ description: "A normal task." }] } }, + ]); + const started = await orch.start(WORK_STAGE_ID, { openingMessage: "go" }); + + const tasks = await taskStore.listTasks(); + expect(tasks).toHaveLength(1); + const task = tasks[0]; + + // It is an ordinary board task: default column, normal mutation works, and the + // plugin attached no extra status/hook state beyond provenance metadata. + expect(task.column).toBe("triage"); + const moved = await taskStore.moveTask(task.id, "todo"); + expect(moved.column).toBe("todo"); + + // Re-read is a clean, normal task (provenance is the only CE footprint). + const reread = await taskStore.getTask(task.id); + expect(reread?.column).toBe("todo"); + expect((reread?.sourceMetadata as Record)?.pluginId).toBe(CE_PLUGIN_ID); + void started; + }); + + it("zero derived tasks is a clean no-op (no board tasks, no orphan link rows)", async () => { + const orch = makeOrch([{ type: "complete", data: { artifact: "# Nothing to do\n", tasks: [] } }]); + const started = await orch.start(WORK_STAGE_ID, { openingMessage: "nothing here" }); + expect(started.session.status).toBe("completed"); + + expect(await taskStore.listTasks()).toHaveLength(0); + expect(getCePipelineStore(ctx).listByPipeline(started.session.id)).toHaveLength(0); + }); + + it("a completion payload with NO tasks field is also a no-op", async () => { + const orch = makeOrch([{ type: "complete", data: { artifact: "# Just an artifact\n" } }]); + const started = await orch.start(WORK_STAGE_ID, { openingMessage: "x" }); + expect(started.session.status).toBe("completed"); + expect(await taskStore.listTasks()).toHaveLength(0); + expect(getCePipelineStore(ctx).listByPipeline(started.session.id)).toHaveLength(0); + }); + + it("a non-work stage with a tasks payload does NOT land board tasks (bridge is work-only)", async () => { + const orch = makeOrch([ + { type: "complete", data: { artifact: "# Brainstorm\n", tasks: [{ description: "should be ignored" }] } }, + ]); + await orch.start("brainstorm", { openingMessage: "ideas" }); + expect(await taskStore.listTasks()).toHaveLength(0); + }); +}); diff --git a/plugins/fusion-plugin-compound-engineering/src/artifacts/__tests__/discovery.test.ts b/plugins/fusion-plugin-compound-engineering/src/artifacts/__tests__/discovery.test.ts new file mode 100644 index 0000000000..b6fbd1c489 --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/artifacts/__tests__/discovery.test.ts @@ -0,0 +1,223 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as realFs from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Mock node:fs so we can observe/inject behaviour around readFileSync and +// accessSync without relying on vi.spyOn (ESM namespace exports are not +// configurable). The list scan probes readability with accessSync (no bytes +// read); readFileSync is only used when an artifact's content is actually +// fetched (readArtifactById). The hooks below default to passthrough and +// individual tests override them. +let readFileHook: ((path: realFs.PathOrFileDescriptor, original: typeof realFs.readFileSync, args: unknown[]) => unknown) | undefined; +let accessHook: ((path: realFs.PathLike, original: typeof realFs.accessSync, args: unknown[]) => unknown) | undefined; + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readFileSync: (path: realFs.PathOrFileDescriptor, ...args: unknown[]) => { + if (readFileHook) return readFileHook(path, actual.readFileSync, args); + return (actual.readFileSync as (...a: unknown[]) => unknown)(path, ...args); + }, + accessSync: (path: realFs.PathLike, ...args: unknown[]) => { + if (accessHook) return accessHook(path, actual.accessSync, args); + return (actual.accessSync as (...a: unknown[]) => unknown)(path, ...args); + }, + }; +}); + +const { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } = realFs; +const { discoverArtifacts, readArtifactById } = await import("../discovery.js"); + +function makeRepo(): string { + return mkdtempSync(join(tmpdir(), "ce-discovery-")); +} + +describe("discoverArtifacts", () => { + let root: string; + + afterEach(() => { + if (root) rmSync(root, { recursive: true, force: true }); + readFileHook = undefined; + accessHook = undefined; + vi.restoreAllMocks(); + }); + + it("returns grouped artifacts from a fixture repo tree (happy path)", () => { + root = makeRepo(); + writeFileSync(join(root, "STRATEGY.md"), "# Strategy"); + writeFileSync(join(root, "CONCEPTS.md"), "# Concepts"); + mkdirSync(join(root, "docs/ideation"), { recursive: true }); + writeFileSync(join(root, "docs/ideation/a.md"), "ideation a"); + writeFileSync(join(root, "docs/ideation/b.md"), "ideation b"); + mkdirSync(join(root, "docs/brainstorms"), { recursive: true }); + writeFileSync(join(root, "docs/brainstorms/x.md"), "brainstorm x"); + mkdirSync(join(root, "docs/plans"), { recursive: true }); + writeFileSync(join(root, "docs/plans/plan1.md"), "plan 1"); + mkdirSync(join(root, "docs/solutions"), { recursive: true }); + writeFileSync(join(root, "docs/solutions/sol.md"), "solution"); + + const result = discoverArtifacts(root); + const byStage = Object.fromEntries(result.groups.map((g) => [g.stage, g])); + + expect(result.totalArtifacts).toBe(7); + expect(result.totalErrors).toBe(0); + expect(byStage.strategy.entries).toHaveLength(1); + expect(byStage.concepts.entries).toHaveLength(1); + expect(byStage.ideation.entries).toHaveLength(2); + expect(byStage.brainstorm.entries).toHaveLength(1); + expect(byStage.plan.entries).toHaveLength(1); + expect(byStage.solution.entries).toHaveLength(1); + // Every group present is flagged present. + expect(byStage.ideation.present).toBe(true); + // All entries are artifacts in the happy path. + expect(result.groups.flatMap((g) => g.entries).every((e) => e.kind === "artifact")).toBe(true); + }); + + it("orders directory artifacts by updatedAt DESC", () => { + root = makeRepo(); + mkdirSync(join(root, "docs/plans"), { recursive: true }); + const older = join(root, "docs/plans/old.md"); + const newer = join(root, "docs/plans/new.md"); + writeFileSync(older, "old"); + writeFileSync(newer, "new"); + // Force deterministic mtimes: old < new. + const now = Date.now(); + utimesSync(older, new Date(now - 10_000), new Date(now - 10_000)); + utimesSync(newer, new Date(now), new Date(now)); + + const result = discoverArtifacts(root); + const plan = result.groups.find((g) => g.stage === "plan")!; + expect(plan.entries.map((e) => e.name)).toEqual(["new.md", "old.md"]); + }); + + it("reports a partial-discovery state: some categories present, others empty", () => { + root = makeRepo(); + writeFileSync(join(root, "STRATEGY.md"), "# Strategy"); + mkdirSync(join(root, "docs/plans"), { recursive: true }); + writeFileSync(join(root, "docs/plans/p.md"), "plan"); + // No ideation / brainstorms / solutions / CONCEPTS. + + const result = discoverArtifacts(root); + const populated = result.groups.filter((g) => g.entries.length > 0); + const empty = result.groups.filter((g) => g.entries.length === 0); + expect(populated.map((g) => g.stage).sort()).toEqual(["plan", "strategy"]); + expect(empty.length).toBeGreaterThan(0); + // Empty groups are still present in the result so the hub can render them. + expect(result.groups).toHaveLength(6); + }); + + it("returns an all-empty result when nothing is present (first-run)", () => { + root = makeRepo(); + const result = discoverArtifacts(root); + expect(result.totalArtifacts).toBe(0); + expect(result.totalErrors).toBe(0); + expect(result.groups.every((g) => g.entries.length === 0 && !g.present)).toBe(true); + }); + + it("represents an unreadable artifact as an error entry, not a crash or silent drop", () => { + root = makeRepo(); + mkdirSync(join(root, "docs/plans"), { recursive: true }); + const readable = join(root, "docs/plans/good.md"); + writeFileSync(readable, "good"); + + // Simulate a malformed/unreadable artifact: the specific file throws when + // the list scan probes readability (accessSync). + accessHook = (path, original, args) => { + if (typeof path === "string" && path.endsWith("good.md")) { + throw new Error("EIO: simulated read failure"); + } + return (original as (...a: unknown[]) => unknown)(path, ...args); + }; + + const result = discoverArtifacts(root); + const plan = result.groups.find((g) => g.stage === "plan")!; + expect(plan.entries).toHaveLength(1); + const entry = plan.entries[0]; + expect(entry.kind).toBe("error"); + expect(entry.kind === "error" && entry.error).toContain("simulated read failure"); + expect(result.totalErrors).toBe(1); + expect(result.totalArtifacts).toBe(0); + }); + + it("ignores unrelated files and does not read outside the conventional locations", () => { + root = makeRepo(); + // Conventional artifact that SHOULD be read. + writeFileSync(join(root, "STRATEGY.md"), "# Strategy"); + mkdirSync(join(root, "docs/ideation"), { recursive: true }); + writeFileSync(join(root, "docs/ideation/keep.md"), "keep"); + // Unrelated files that must NOT be read. + writeFileSync(join(root, "README.md"), "readme"); // root-level non-conventional .md + writeFileSync(join(root, "package.json"), "{}"); + writeFileSync(join(root, "docs/ideation/notes.txt"), "non-md, ignore"); // non-.md in a scanned dir + mkdirSync(join(root, "secrets"), { recursive: true }); + writeFileSync(join(root, "secrets/secret.md"), "TOP SECRET"); // outside the allowlist + mkdirSync(join(root, "docs/random"), { recursive: true }); + writeFileSync(join(root, "docs/random/r.md"), "unrelated"); // docs subtree but not conventional + + const opened: string[] = []; + // The list scan probes readability with accessSync (no bytes read); track + // exactly which paths it touches. + accessHook = (path, original, args) => { + if (typeof path === "string") opened.push(path); + return (original as (...a: unknown[]) => unknown)(path, ...args); + }; + + const result = discoverArtifacts(root); + + // Only the two conventional artifacts were probed. + expect(opened.some((p) => p.endsWith("STRATEGY.md"))).toBe(true); + expect(opened.some((p) => p.endsWith(join("ideation", "keep.md")))).toBe(true); + // Nothing outside the allowlist was opened. + expect(opened.some((p) => p.includes(`${join("secrets", "secret.md")}`))).toBe(false); + expect(opened.some((p) => p.endsWith("README.md"))).toBe(false); + expect(opened.some((p) => p.endsWith("package.json"))).toBe(false); + expect(opened.some((p) => p.endsWith("notes.txt"))).toBe(false); + expect(opened.some((p) => p.includes(join("random", "r.md")))).toBe(false); + + expect(result.totalArtifacts).toBe(2); + }); +}); + +describe("readArtifactById", () => { + let root: string; + + beforeEach(() => { + root = makeRepo(); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it("reads a conventional file artifact", () => { + writeFileSync(join(root, "STRATEGY.md"), "# Strategy body"); + const res = readArtifactById(root, "strategy:STRATEGY.md"); + expect(res).toBeDefined(); + expect(res && "content" in res && res.content).toContain("Strategy body"); + }); + + it("reads a directory artifact's immediate Markdown child", () => { + mkdirSync(join(root, "docs/plans"), { recursive: true }); + writeFileSync(join(root, "docs/plans/p.md"), "plan body"); + const res = readArtifactById(root, "plan:docs/plans/p.md"); + expect(res && "content" in res && res.content).toBe("plan body"); + }); + + it("refuses a forged id that escapes the conventional location", () => { + writeFileSync(join(root, "secrets.md"), "secret"); + // Attempt to traverse out of docs/plans into the repo root. + expect(readArtifactById(root, "plan:../../secrets.md")).toBeUndefined(); + // Wrong stage/path pairing for a file location. + expect(readArtifactById(root, "strategy:CONCEPTS.md")).toBeUndefined(); + // Unknown stage. + expect(readArtifactById(root, "bogus:whatever.md")).toBeUndefined(); + }); + + it("refuses a nested path under a directory location (non-immediate child)", () => { + mkdirSync(join(root, "docs/plans/sub"), { recursive: true }); + writeFileSync(join(root, "docs/plans/sub/deep.md"), "deep"); + expect(readArtifactById(root, "plan:docs/plans/sub/deep.md")).toBeUndefined(); + }); +}); diff --git a/plugins/fusion-plugin-compound-engineering/src/artifacts/discovery.ts b/plugins/fusion-plugin-compound-engineering/src/artifacts/discovery.ts new file mode 100644 index 0000000000..959a4b3b37 --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/artifacts/discovery.ts @@ -0,0 +1,341 @@ +import { accessSync, constants, readdirSync, readFileSync, statSync } from "node:fs"; +import { isAbsolute, join, relative, sep } from "node:path"; + +/** + * CE artifact discovery (U3). + * + * Scans a fixed allowlist of conventional CE artifact locations relative to a + * project root and returns artifacts grouped by stage. The allowlist is the + * ONLY filesystem surface this module touches — it never recurses outside a + * conventional location and never reads a file that does not live under one of + * them. An artifact that cannot be read or is malformed is represented as an + * `error` entry rather than crashing the scan or being silently dropped. + * + * Locations (per the plan): STRATEGY.md, docs/ideation/, docs/brainstorms/, + * docs/plans/, docs/solutions/, CONCEPTS.md. + */ + +export type CeArtifactStage = + | "strategy" + | "ideation" + | "brainstorm" + | "plan" + | "solution" + | "concepts"; + +/** Whether a conventional location is a single file or a directory of files. */ +type LocationKind = "file" | "directory"; + +interface ConventionalLocation { + stage: CeArtifactStage; + /** Human label for the stage group. */ + label: string; + /** Project-root-relative path. */ + path: string; + kind: LocationKind; +} + +/** + * The conventional CE artifact locations. This is the discovery allowlist — the + * scanner reads ONLY these paths (and, for directories, their immediate `.md` + * children). Nothing outside this list is opened. + */ +export const CONVENTIONAL_LOCATIONS: readonly ConventionalLocation[] = [ + { stage: "strategy", label: "Strategy", path: "STRATEGY.md", kind: "file" }, + { stage: "ideation", label: "Ideation", path: "docs/ideation", kind: "directory" }, + { stage: "brainstorm", label: "Brainstorms", path: "docs/brainstorms", kind: "directory" }, + { stage: "plan", label: "Plans", path: "docs/plans", kind: "directory" }, + { stage: "solution", label: "Solutions", path: "docs/solutions", kind: "directory" }, + { stage: "concepts", label: "Concepts", path: "CONCEPTS.md", kind: "file" }, +]; + +/** A discovered, readable artifact. */ +export interface CeArtifact { + /** Stable id: `${stage}:${relativePath}`. Safe to use as a route param after encoding. */ + id: string; + stage: CeArtifactStage; + /** Project-root-relative path with forward slashes. */ + path: string; + /** Filename (basename). */ + name: string; + /** Size in bytes. */ + size: number; + /** Last-modified epoch ms — used for `(stage, updatedAt DESC)` ordering. */ + updatedAt: number; + /** Discriminator. */ + kind: "artifact"; +} + +/** An artifact location that exists but could not be read / was malformed. */ +export interface CeArtifactError { + id: string; + stage: CeArtifactStage; + path: string; + name: string; + /** Discriminator. */ + kind: "error"; + /** Human-readable reason the artifact could not be surfaced. */ + error: string; +} + +export type CeArtifactEntry = CeArtifact | CeArtifactError; + +/** Artifacts (and error entries) grouped by stage. */ +export interface CeArtifactGroup { + stage: CeArtifactStage; + label: string; + /** True when the conventional location for this stage exists on disk. */ + present: boolean; + /** Entries, ordered by `updatedAt DESC` (errors sort last, keyed by name). */ + entries: CeArtifactEntry[]; +} + +export interface DiscoveryResult { + groups: CeArtifactGroup[]; + /** Convenience flags for the hub's empty / partial states. */ + totalArtifacts: number; + totalErrors: number; +} + +const MAX_ARTIFACT_BYTES = 2_000_000; + +function toPosix(p: string): string { + return p.split(sep).join("/"); +} + +/** + * Guard: a resolved path must stay within the project root AND under the + * specific conventional location it was discovered through. This is the + * concrete enforcement of "do not read outside the conventional locations". + */ +function isWithin(root: string, locationAbs: string, candidate: string): boolean { + const relToLocation = relative(locationAbs, candidate); + if (relToLocation.startsWith("..") || isAbsolute(relToLocation)) return false; + const relToRoot = relative(root, candidate); + if (relToRoot.startsWith("..") || isAbsolute(relToRoot)) return false; + return true; +} + +function makeId(stage: CeArtifactStage, relPath: string): string { + return `${stage}:${relPath}`; +} + +/** Build a uniform `error` entry, deriving `id`/`name` from `(stage, relPath)`. */ +function makeError(stage: CeArtifactStage, relPath: string, message: string): CeArtifactError { + return { + id: makeId(stage, relPath), + stage, + path: relPath, + name: relPath.split("/").pop() ?? relPath, + kind: "error", + error: message, + }; +} + +function readArtifactEntry( + stage: CeArtifactStage, + root: string, + locationAbs: string, + abs: string, + relPath: string, +): CeArtifactEntry { + const name = relPath.split("/").pop() ?? relPath; + // Defense in depth: refuse anything that escaped the conventional location. + if (!isWithin(root, locationAbs, abs)) { + return makeError(stage, relPath, "Path is outside its conventional location"); + } + try { + const st = statSync(abs); + if (st.size > MAX_ARTIFACT_BYTES) { + return makeError(stage, relPath, `Artifact too large to read (${st.size} bytes)`); + } + // Probe READ PERMISSION only (no bytes transferred) so an unreadable file is + // surfaced now as an error entry rather than crashing later at render time. + // NOTE: this is a permission probe, NOT a content check — malformed/corrupt + // file CONTENT is only detected at read time (readCeArtifact), not here. + accessSync(abs, constants.R_OK); + return { + id: makeId(stage, relPath), + stage, + path: relPath, + name, + size: st.size, + updatedAt: st.mtimeMs, + kind: "artifact", + }; + } catch (err) { + return makeError(stage, relPath, err instanceof Error ? err.message : String(err)); + } +} + +function sortEntries(entries: CeArtifactEntry[]): CeArtifactEntry[] { + // Composite ordering analogue: artifacts by updatedAt DESC; errors last, + // stable by name. (See docs/performance/dashboard-load.md — the persisted + // equivalent is a `(type, updatedAt DESC)` index.) + return [...entries].sort((a, b) => { + if (a.kind !== b.kind) return a.kind === "artifact" ? -1 : 1; + if (a.kind === "artifact" && b.kind === "artifact") return b.updatedAt - a.updatedAt; + return a.name.localeCompare(b.name); + }); +} + +function discoverLocation(root: string, loc: ConventionalLocation): CeArtifactGroup { + const locationAbs = join(root, loc.path); + const entries: CeArtifactEntry[] = []; + let present = false; + + let st: ReturnType | undefined; + try { + st = statSync(locationAbs); + present = true; + } catch { + // Location simply does not exist — an empty (but valid) category. + return { stage: loc.stage, label: loc.label, present: false, entries: [] }; + } + + if (loc.kind === "file") { + if (st.isFile()) { + entries.push(readArtifactEntry(loc.stage, root, locationAbs, locationAbs, toPosix(loc.path))); + } else { + // A conventional file path that is actually a directory is malformed. + entries.push( + makeError( + loc.stage, + toPosix(loc.path), + "Expected a file at the conventional location but found a directory", + ), + ); + } + return { stage: loc.stage, label: loc.label, present, entries: sortEntries(entries) }; + } + + // Directory location: read ONLY immediate children, only Markdown files. + // Non-recursive on purpose — we never descend into unrelated subtrees. + let names: string[] = []; + try { + if (!st.isDirectory()) { + entries.push( + makeError( + loc.stage, + toPosix(loc.path), + "Expected a directory at the conventional location but found a file", + ), + ); + return { stage: loc.stage, label: loc.label, present, entries: sortEntries(entries) }; + } + names = readdirSync(locationAbs); + } catch (err) { + entries.push(makeError(loc.stage, toPosix(loc.path), err instanceof Error ? err.message : String(err))); + return { stage: loc.stage, label: loc.label, present, entries: sortEntries(entries) }; + } + + for (const childName of names) { + // Ignore unrelated files: only Markdown artifacts count. Dotfiles and any + // non-.md file are skipped outright (not read). + if (childName.startsWith(".")) continue; + if (!childName.toLowerCase().endsWith(".md")) continue; + const abs = join(locationAbs, childName); + const relPath = toPosix(join(loc.path, childName)); + // Skip nested directories named *.md — only regular files are artifacts. + let childStat: ReturnType; + try { + childStat = statSync(abs); + } catch (err) { + entries.push(makeError(loc.stage, relPath, err instanceof Error ? err.message : String(err))); + continue; + } + if (!childStat.isFile()) continue; + entries.push(readArtifactEntry(loc.stage, root, locationAbs, abs, relPath)); + } + + return { stage: loc.stage, label: loc.label, present, entries: sortEntries(entries) }; +} + +/** + * Discover CE artifacts under `projectRoot`, grouped by stage. Never throws for + * per-artifact problems — those become `error` entries. Always returns one + * group per conventional location (empty groups included so the hub can render + * a partial-discovery state). + */ +export function discoverArtifacts(projectRoot: string): DiscoveryResult { + const root = projectRoot; + const groups = CONVENTIONAL_LOCATIONS.map((loc) => discoverLocation(root, loc)); + let totalArtifacts = 0; + let totalErrors = 0; + for (const g of groups) { + for (const e of g.entries) { + if (e.kind === "artifact") totalArtifacts += 1; + else totalErrors += 1; + } + } + return { groups, totalArtifacts, totalErrors }; +} + +/** + * Resolve a single artifact by its `stage:relativePath` id and return its raw + * content. Re-validates the path against the conventional-location allowlist so + * a forged id can never read an arbitrary file. Returns `undefined` if the id + * does not map to a known conventional location or the file is missing. + */ +export function readArtifactById( + projectRoot: string, + id: string, +): { artifact: CeArtifact; content: string } | { error: string } | undefined { + const sepIdx = id.indexOf(":"); + if (sepIdx <= 0) return undefined; + const stage = id.slice(0, sepIdx) as CeArtifactStage; + const relPath = id.slice(sepIdx + 1); + const loc = CONVENTIONAL_LOCATIONS.find((l) => l.stage === stage); + if (!loc) return undefined; + + const locationAbs = join(projectRoot, loc.path); + const abs = join(projectRoot, relPath); + + // The requested path must live under the stage's conventional location. + // For file locations, the path must equal the location itself. + if (loc.kind === "file") { + if (toPosix(relPath) !== toPosix(loc.path)) return undefined; + } else if (!isWithin(projectRoot, locationAbs, abs)) { + return undefined; + } + // Directory artifacts must be immediate Markdown children. + if (loc.kind === "directory") { + const rel = relative(locationAbs, abs); + if (rel.includes(sep) || rel.startsWith("..") || !rel.toLowerCase().endsWith(".md")) { + return undefined; + } + } + + let content: string; + let mtimeMs: number; + let size: number; + try { + const st = statSync(abs); + if (!st.isFile()) return { error: "Artifact is not a readable file" }; + if (st.size > MAX_ARTIFACT_BYTES) return { error: `Artifact too large to read (${st.size} bytes)` }; + mtimeMs = st.mtimeMs; + size = st.size; + } catch (err) { + // A missing file is "not found" (404), not a malformed-artifact error (422). + if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return undefined; + return { error: err instanceof Error ? err.message : String(err) }; + } + try { + content = readFileSync(abs, "utf8"); + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + const name = relPath.split("/").pop() ?? relPath; + return { + artifact: { + id, + stage, + path: toPosix(relPath), + name, + size, + updatedAt: mtimeMs, + kind: "artifact", + }, + content, + }; +} diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard-interop.d.ts b/plugins/fusion-plugin-compound-engineering/src/dashboard-interop.d.ts new file mode 100644 index 0000000000..a51b4163de --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard-interop.d.ts @@ -0,0 +1,34 @@ +// Ambient declaration for the dashboard host's plugin-view context, so this +// bundled plugin can consume the type WITHOUT a runtime dependency on +// `@fusion/dashboard` (a host package). Depending on the host would create a +// dashboard -> plugin -> dashboard cycle and violate the workspace-acyclicity / +// "bundled plugins must not depend on host packages" invariants. The host +// passes the real object at runtime; this minimal structural shape is enough to +// type-check the fields this plugin actually reads. Mirrors the interop pattern +// used by fusion-plugin-dependency-graph. +declare module "@fusion/dashboard/app/plugins/types" { + import type { ReactNode } from "react"; + import type { Task, TaskDetail, WorkflowStep } from "@fusion/core"; + + export type DetailTaskTab = + | "definition" | "logs" | "changes" | "comments" | "model" | "workflow" | "pr" | "retries"; + export type PluginToastType = "success" | "error" | "warning" | "info"; + + export interface PluginCustomEvent { + event: string; + payload: unknown; + } + + export interface PluginDashboardViewContext { + projectId?: string; + tasks: Task[]; + workflowSteps: WorkflowStep[]; + openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void; + renderTaskCard?: (task: Task | TaskDetail) => ReactNode; + addToast?: (message: string, type?: PluginToastType) => void; + subscribePluginEvents?: ( + pluginId: string, + onEvent: (event: PluginCustomEvent) => void, + ) => () => void; + } +} diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard-view.tsx b/plugins/fusion-plugin-compound-engineering/src/dashboard-view.tsx new file mode 100644 index 0000000000..5793e56f1d --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard-view.tsx @@ -0,0 +1,12 @@ +/** + * Dashboard surface entry for the Compound Engineering plugin (U3). + * + * Thin re-export of the real hub component (mirrors how reports splits + * `src/dashboard-view.tsx` from `src/dashboard/ReportsView.tsx`). The export + * name `CompoundEngineeringDashboardView` is the one `registerBundledPluginViews` + * imports — `componentPath` in the manifest is cosmetic; this binding is real. + */ +export { + CompoundEngineeringView as CompoundEngineeringDashboardView, + default, +} from "./dashboard/CompoundEngineeringView.js"; diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/CeFlow.tsx b/plugins/fusion-plugin-compound-engineering/src/dashboard/CeFlow.tsx new file mode 100644 index 0000000000..86598776fb --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/CeFlow.tsx @@ -0,0 +1,556 @@ +import { useMemo, useState } from "react"; +import type { PlanningQuestion } from "@fusion/core"; +import type { CeActivityTurn, CeConversationTurn, CeSession } from "../session/session-store.js"; +import { canRenderRichly } from "./ce-question-support.js"; + +/** + * CeFlow — the interactive renderer (U6). + * + * Renders the four interaction types CeFlow expresses richly (`text`, + * `single_select`, `multi_select`, `confirm`), the FULL conversation so far — + * past questions and answers as proper chat bubbles, the agent's working + * traces (thinking / tool activity) as collapsible blocks — and, while a turn + * runs, a LIVE working pane streaming the agent's current output. + * + * Steering: alongside any selectable question the user can attach free-text + * guidance to their answer (`{value, comment}`) or send guidance WITHOUT + * answering (`{feedback}`) — the stage system prompt instructs the agent to + * treat both as first-class input. + * + * When a turn carries a question CeFlow CANNOT express, it degrades to a + * plain chat view that is VISUALLY MARKED as degraded (R8/AE1) — the stage is + * still completable there via a free-text answer. + * + * It does NOT import `PlanningModeModal` or any dashboard internal (KTD3 scope + * boundary); it only consumes the `PlanningQuestion` shape for parity. + */ + +export interface CeFlowProps { + session?: CeSession; + busy?: boolean; + error?: string; + /** Submit an answer to the current question. */ + onAnswer: (questionId: string, response: unknown) => void; + /** Resume an interrupted/error session. */ + onResume?: () => void; + /** Back to the launcher. */ + onClose?: () => void; +} + +// ── Transcript parsing ─────────────────────────────────────────────────────── + +type DisplayItem = + | { kind: "chat"; role: "user" | "agent"; text: string } + | { kind: "qa-question"; question: PlanningQuestion } + | { kind: "qa-answer"; question?: PlanningQuestion; response: unknown } + | { kind: "activity"; turns: CeActivityTurn[] } + | { kind: "complete" }; + +function tryParseJson(text: string): Record | undefined { + if (!text.startsWith("{")) return undefined; + try { + const parsed: unknown = JSON.parse(text); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } +} + +/** + * Turn the persisted history (chat turns + serialized control records) into + * renderable items. Control records are no longer hidden — questions, answers, + * and working traces are the conversation. + */ +function parseHistory(history: CeConversationTurn[]): DisplayItem[] { + const items: DisplayItem[] = []; + const questionsById = new Map(); + for (const turn of history) { + const obj = tryParseJson(turn.text); + if (obj && turn.role === "agent") { + const q = obj.question as PlanningQuestion | undefined; + if (q && typeof q.id === "string" && typeof q.question === "string") { + questionsById.set(q.id, q); + items.push({ kind: "qa-question", question: q }); + continue; + } + const activity = obj.activity as { turns?: CeActivityTurn[] } | undefined; + if (activity && Array.isArray(activity.turns)) { + items.push({ kind: "activity", turns: activity.turns }); + continue; + } + if (obj.complete === true) { + items.push({ kind: "complete" }); + continue; + } + } + if (obj && turn.role === "user" && "answer" in obj) { + items.push({ + kind: "qa-answer", + question: typeof obj.questionId === "string" ? questionsById.get(obj.questionId) : undefined, + response: obj.answer, + }); + continue; + } + items.push({ kind: "chat", role: turn.role, text: turn.text }); + } + return items; +} + +/** Human-readable rendering of an answer payload (option ids → labels). */ +function formatAnswer( + response: unknown, + question?: PlanningQuestion, +): { main: string; comment?: string; feedbackOnly?: boolean } { + if (response && typeof response === "object" && !Array.isArray(response)) { + const r = response as Record; + if (typeof r.feedback === "string") return { main: r.feedback, feedbackOnly: true }; + if ("value" in r) { + const base = formatAnswer(r.value, question); + return { + main: base.main, + ...(typeof r.comment === "string" && r.comment ? { comment: r.comment } : {}), + }; + } + } + const label = (id: unknown): string => + question?.options?.find((o) => o.id === id)?.label ?? String(id); + if (Array.isArray(response)) return { main: response.map(label).join(", ") }; + if (typeof response === "boolean") return { main: response ? "Yes" : "No" }; + return { main: label(response) }; +} + +// ── Working-trace rendering ────────────────────────────────────────────────── + +/** Render thinking/text/tool activity turns (persisted trace or live pane). */ +function ActivityTrace({ turns, live }: { turns: CeActivityTurn[]; live?: boolean }) { + return ( +
+ {turns.map((t, i) => + t.kind === "tool" ? ( +
+ {t.isError ? "✗" : t.done ? "✓" : "▸"} {t.text} +
+ ) : ( +
+            {t.text}
+          
+ ), + )} +
+ ); +} + +/** Render the full conversation: chat, Q&A bubbles, and working traces. */ +function Transcript({ history }: { history: CeConversationTurn[] }) { + const items = useMemo(() => parseHistory(history), [history]); + if (items.length === 0) return null; + return ( +
    + {items.map((item, i) => { + switch (item.kind) { + case "chat": + return ( +
  1. + {item.role === "agent" ? "Agent" : "You"} + {item.text} +
  2. + ); + case "qa-question": + return ( +
  3. + Agent asked + {item.question.question} +
  4. + ); + case "qa-answer": { + const a = formatAnswer(item.response, item.question); + return ( +
  5. + {a.feedbackOnly ? "You steered" : "You answered"} + {a.main} + {a.comment ? ( + + {a.comment} + + ) : null} +
  6. + ); + } + case "activity": + return ( +
  7. +
    + Agent work ({item.turns.length} step{item.turns.length === 1 ? "" : "s"}) + +
    +
  8. + ); + case "complete": + return ( +
  9. + ✓ Stage complete +
  10. + ); + } + })} +
+ ); +} + +// ── Question rendering ─────────────────────────────────────────────────────── + +/** Rich renderer for a single supported question type. */ +function RichQuestion({ + question, + disabled, + onAnswer, +}: { + question: PlanningQuestion; + disabled: boolean; + onAnswer: (questionId: string, response: unknown) => void; +}) { + const [text, setText] = useState(""); + const [multi, setMulti] = useState([]); + + const submit = (response: unknown) => onAnswer(question.id, response); + + return ( +
+

{question.question}

+ {question.description ?

{question.description}

: null} + + {question.type === "text" ? ( +
{ + e.preventDefault(); + if (text.trim()) submit(text.trim()); + }} + > +