Merge branch 'main' into gsxdsm/fileschanged

This commit is contained in:
gsxdsm
2026-06-03 15:36:13 -07:00
committed by GitHub
162 changed files with 22491 additions and 147 deletions

View File

@@ -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.

View File

@@ -0,0 +1,9 @@
---
"@runfusion/fusion": patch
---
Fix missions stalling when a feature is marked `done` but stranded mid-loop.
A mission feature could be left `status: "done"` while its `loopState` never advanced past `"implementing"` and it had no linked board task (so it was never validated). The slice-completion gate (`MissionStore.computeSliceStatus`) correctly refuses to count an assertion-linked `done` feature until its validator passes, but nothing re-drove a task-less feature, so the slice — and the whole mission — could never auto-progress.
Active-mission recovery now detects these stranded `done` features and re-runs assertion validation directly (no board task), so the gate can resolve: on pass the feature becomes legitimately complete, on fail the normal fix-feature flow takes over. The feature-validation path was extracted into a shared `runFeatureValidation` helper used by both task-completion and recovery.

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Clear the in-review stall deadlock auto-pause on user-initiated retry so dashboard, CLI, and extension retries can actually resume merge/execution work without overriding manual pauses.

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add AI-assisted conflict resolution to the dashboard Create PR flow so users can resolve task-branch merge conflicts against the selected base branch, push the updated branch, and continue PR creation without leaving Fusion.

View File

@@ -2,6 +2,41 @@
Shared domain vocabulary for this project — entities, named processes, and status concepts with project-specific meaning. Seeded with core domain vocabulary, then accretes as ce-compound and ce-compound-refresh process learnings; direct edits are fine. Glossary only, not a spec or catch-all.
## Missions
### Relationships
A Mission owns an ordered list of Milestones; a Milestone owns an ordered list of Slices; a Slice owns a set of Features. Status rolls **up**, not down: a Slice's status is derived from its Features, a Milestone's from its Slices, and a Mission's from its Milestones. Autopilot acts at the Slice boundary — it advances a Mission by activating the next Slice once the current one is complete.
### Mission
A unit of autonomous, multi-step work the system plans and then drives to completion on its own, decomposed into Milestones. A Mission may run under Autopilot or be advanced manually.
### Milestone
An ordered phase of a Mission, containing Slices and optionally depending on earlier Milestones. A Milestone is complete only when all of its Slices are complete.
### Slice
A vertically-scoped, independently-completable chunk of a Milestone, containing Features. A Slice's status is derived from its Features and reaches *complete* only when every Feature counts as done — which, for a Feature carrying Contract Assertions, requires a passing Validator Run.
### Feature
The smallest unit of mission work: a single deliverable evaluated against its Contract Assertions. A Feature carries both a board status (its workflow column, e.g. done) and a loop state (its execution phase); the two are distinct and can legitimately disagree mid-flight, but a done Feature that never reached a terminal loop state is an invariant violation that will stall its Slice.
### Fix Feature
A Feature auto-generated from a failed Validator Run to carry the remediation work for the assertions that failed, linked back to the Feature it descends from.
## Mission execution
### Autopilot
The named process that watches an active Mission and advances it — activating the next pending Slice once the current Slice completes — while tracking its own watching/activating lifecycle and handling retries. When Autopilot is not watching a Mission, slice advancement falls back to a compatibility path.
### Contract Assertion
A checkable acceptance criterion linked to a Feature that an AI validator judges to decide whether the Feature is genuinely done. Every Feature is validator-evaluated — a Feature missing an assertion has one lazily linked before validation — and counts toward Slice completion only after a passing Validator Run.
### Validator Run
A single execution of the AI judge that evaluates a Feature's Contract Assertions and yields a pass, fail, blocked, or error outcome. The validator is read-only — it inspects the implementation and records a verdict, creating no board task and editing no code. A run left running after its owner disappears is reaped to a terminal error state.
### loop state
A Feature's position in the execution loop (being implemented, awaiting or undergoing validation, awaiting a fix, passed, or blocked), distinct from its board status. Logic that gates on loop state must treat it as possibly stale and possibly contradictory with status — a Feature can be marked done while its loop state was never advanced past implementing.
## Merge lifecycle
### Task
@@ -40,6 +75,25 @@ The post-merge step that rebases locally-landed merge commits onto the upstream
### Contamination
Foreign commits — work attributed to other Tasks — appearing on a Task's branch beyond its recorded Fork point. Contamination checks must compute their reference base fresh from the Integration branch rather than reuse the Task's stored base, since a stale stored base makes every legitimately merged commit look foreign.
## 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

View File

@@ -31,6 +31,7 @@ For a full walkthrough (installation, onboarding, first task, and daily workflow
| [Task Management](./task-management.md) | Task creation modes, lifecycle, prompt specs, comments, archiving, and GitHub integration |
| [Todo View](./todo-view.md) | Canonical guide for the experimental Todo View, including enablement, usage, API routes, and storage |
| [Missions](./missions.md) | Mission hierarchy, planning flow, activation, progress tracking, and autopilot behavior |
| [Goals Refinement Gate](./goals-refinement-gate.md) | Evidence gate for activating the conditional post-v1 goals refinement slice only after real usage pain is documented |
| [Research](./research.md) | Research runs, provider setup, dashboard/CLI usage, findings, exports, and task integration |
| [Research View UX Spec](./research-view-ux-spec.md) | Canonical layout and capability-state messaging spec for the Research dashboard view (FN-4138, informs FN-4134/FN-4135) |
| [Workflow Steps](./workflow-steps.md) | Reusable quality gates, templates, pre/post-merge phases, and workflow execution results |

View File

@@ -1720,7 +1720,7 @@ This section preserves the detailed lifecycle/self-healing contracts that were f
- **Stale active branches**: self-healing's `reclaim-stale-active-branches` stage prunes a `fusion/<task-id>` branch with zero unique commits when no usable worktree mapping exists, then clears `task.branch`/`task.worktree`/`task.baseCommitSha`. It must defer reclaim (emit `branch:stale-active-reclaim-deferred`) when the task worktree is in `activeSessionRegistry`, when `executionStartedAt` is within `STALE_ACTIVE_BRANCH_EXECUTION_GRACE_MS` (10 minutes), or when the mapped worktree has uncommitted changes.
- **Worktree metadata reconcile ordering (FN-4962)**: `reconcile-task-worktree-metadata` must run before `reclaim-stale-active-branches`; stale `task.worktree` metadata is rebound to live `fusion/<task-id>` worktrees when present (`task:auto-recover-worktree-metadata-rebound`) or cleared (`task:auto-recover-worktree-metadata-cleared`) when absent.
- **Completion fan-out is synchronous**: `SelfHealingManager.reconcileCompletedTask()` runs on `in-review → done`. Downstream stale `blockedBy` links and residual `fusion/<task-id>` branch/worktree artifacts are reconciled immediately, not on a periodic sweep.
- **In-review stall deadlock**: identical stalls (same code + reason) repeated past `inReviewStallDeadlockThreshold` (default 3) auto-pause with `pausedReason: "in-review-stall-deadlock"` and `status: "failed"`.
- **In-review stall deadlock**: identical stalls (same code + reason) repeated past `inReviewStallDeadlockThreshold` (default 3) auto-pause with `pausedReason: "in-review-stall-deadlock"` and `status: "failed"`. User-initiated retry paths (dashboard retry, `fn_task_retry`, and CLI `task retry`) clear that automatic deadlock pause so the retry can execute, but they never override explicit/manual pauses or unrelated automatic pause reasons.
- **Restart recovery**: `RestartRecoveryCoordinator` classifies interrupted `in-progress` runs. Unusable-worktree session-start failures (`missing`, `incomplete`, `unregistered git worktree`) are recoverable; retries are capped at `MAX_WORKTREE_SESSION_RETRIES=3` before escalating.
- **Executor pre-session liveness gate (FN-4935)**: the gate now skips for fresh acquisitions (`acquisition.source === "fresh"`), emits structured `not_usable_task_worktree:<classification>` diagnostics (including canonicalized registered-path snapshots) and a `worktree:incomplete-detected` audit event with `source: "executor-liveness-gate"`, while preserving the existing `taskDoneRetryCount` / `MAX_TASK_DONE_REQUEUE_RETRIES` requeue contract. FN-5772 adds a bounded nested-root self-heal: when `task.worktree` points at a strict descendant of a registered worktree root inside the configured worktrees dir, executor re-anchors `task.worktree` to the git top-level, emits `worktree:reanchored` (`fromPath`, `toPath`, `source`), and proceeds; repo-root/outside-dir/unregistered top-level mismatches still fail. FN-4651 `worktreeSessionRetryCount` remains scoped to the in-review/session-start recovery path.
- **Stale self-owned active-session reconcile on conflict cleanup (FN-4973)**: when executor worktree-conflict cleanup finds only a same-task stale `activeSessionRegistry` entry and no live in-memory `activeWorktrees` binding for that task/path, it must unregister the stale entry before `removeWorktree` (plus one-shot backstop reconcile on same-task `ActiveSessionWorktreeRemovalError` races). Foreign-task entries remain protected by FN-4811 and must never be reconciled by the requesting task.

View File

@@ -613,6 +613,7 @@ Inspect task definition, logs, review feedback, comments, documents, workflow ou
- In shared task edit/create forms, GitHub Tracking appears at the bottom of **More options**, after **Workflow Steps**.
- From this section you can explicitly enable/disable tracking and manage a per-task repo override (`owner/repo`). Clearing the override saves `null` and falls back to project/global defaults.
- In `in-review`, pull-request controls/status (including stall badges) are in a dedicated **Pull Request** tab instead of the Definition tab.
- In the **Create Pull Request** modal, if preflight detects `conflictsWithBase`, the modal now offers **Resolve conflicts with AI**. Fusion uses an AI coding agent to resolve merge markers on the task branch, commits the result, pushes `fusion/<task-id-lower>` to `origin`, and refreshes preflight so normal PR creation can continue once conflicts are gone.
- The **Review** tab is separate from **Comments**: Review shows actionable PR/reviewer feedback and same-task revision controls, while Comments remains the general collaboration thread.
- **Request revision** in Review resumes work on the same task ID (no refinement task): `in-progress` tasks get steering injection, while `in-review` tasks are moved back to `in-progress` for the same branch/worktree revision pass.
- Review supports a manual **Refresh** action in-place: PR mode pulls latest GitHub review state/decision, while direct mode rehydrates reviewer-agent feedback from persisted task data (no GitHub call).
@@ -1089,7 +1090,7 @@ Dark/light modes via `data-theme`; 54 color themes via `data-color-theme` (lazy-
Reuse existing primitives from `styles.css`:
- **Buttons**: `.btn`, `.btn-primary`, `.btn-danger`, `.btn-warning`, `.btn-sm`, `.btn-icon`, `.btn-icon--active`, `.btn-badge`. All inherit `:focus-visible` via `--focus-ring-strong` and `:active` via `transform: scale(0.97)`.
- **Modals**: `.modal-overlay[.open]`, `.modal`, `.modal-lg`, `.modal-header`, `.modal-close`, `.modal-actions`, `.modal-actions-left/right`. Overlay pads top with `--overlay-padding-top`.
- **Modals**: `.modal-overlay[.open]`, `.modal`, `.modal-lg`, `.modal-header`, `.modal-close`, `.modal-actions`, `.modal-actions-left/right`. Overlay pads top with `--overlay-padding-top`. Overlay dialogs should render through `createPortal(..., document.body)` so `position: fixed` overlays escape transformed, contained, or fixed ancestors.
- **Forms**: `.form-group`, `.input`, `.select`, `.checkbox-label`, `.form-error`. Inputs in `.form-group` get focus styles automatically.
- **Cards**: `.card`, `.card-header`, `.card-id`, `.card-title`, `.card-meta`, `.card-status-badge--{triage,todo,in-progress,in-review,done,archived}`.
- **Utility**: `.touch-target` (44px min), `.visually-hidden`.

View File

@@ -4,12 +4,13 @@
Executor, heartbeat, and planning runs emit one goal-injection diagnostic with outcome `applied`, `no-goals`, or `disabled-or-failed`.
- Run-audit event: `prompt:goal-injection` (`database` domain, target lane) with metadata `{ lane, outcome, goalCount, goalIds, truncated, reason?, errorClass?, runId?, agentId?, taskId? }`.
- Run-audit event: `prompt:goal-injection` (`database` domain, target lane) with metadata `{ lane, outcome, goalCount, goalIds, provenanceGoalIds, truncated, reason?, errorClass?, runId?, agentId?, taskId? }`.
- Goal anchoring events also persist `metadata.goalIds` (alongside existing count/tool fields):
- `goal:injection-applied` / `goal:injection-skipped` → `{ lane, count, goalIds, truncated?, reason? }`
- `goal:retrieval-invoked` → `{ toolName, count, goalIds, notFound }`
- Run cited-goals read path: `GET /api/agents/:id/runs/:runId/cited-goals` returns `{ runId, taskId?, injectedGoalIds, retrievedGoalIds, citedGoalIds }` aggregated from `goal:*` + `prompt:goal-injection` run-audit events.
- Task log (executor lane with `taskId`): `[goal-injection] <outcome> count=<n> ids=<json-array> truncated=<bool> ...`.
- Task log (executor lane with `taskId`): `[goal-injection] <outcome> count=<n> ids=<json-array> provenance=<json-array> truncated=<bool> ...`.
- `goalIds` / `goalCount` describe the active goals injected into the prompt; `provenanceGoalIds` additively records mission-derived task provenance and does not affect prompt selection.
- Guardrail: diagnostics persist goal IDs/counts only; never prompt text, goal titles, or goal descriptions.
## Insight run sweeper (`[insight-sweeper]`)

View File

@@ -0,0 +1,104 @@
# Goals Refinement Gate
[← Docs index](./README.md)
This document defines the evidence gate for activating **Slice 4: Schema/Focus-Set Refinement (Conditional)** in the Goals mission (`M-MP32KU9Y-0001-2ADN`). It is a governance artifact, not an implementation plan.
## Purpose
Slice 4 stays pending and intentionally under-specified until Slices 1–3 produce **observed pain from real use**. Fusion must not build a structured `successMetric` schema, a focus-set concept, or richer goal-progress/reporting surfaces because they seem plausible in advance. Refinement starts only when operators can point to real usage evidence showing the v1 shape is insufficient.
## Locked guardrails carried forward from the mission
The gate inherits the mission guardrails already locked by CEO + CTO + PM:
1. **Hard cap of 5 active goals** remains the v1 operating limit.
2. **Success metrics live in slice/feature text for v1** rather than a structured `successMetric` schema.
3. **Only Slice 1 was activated up front**; later slices were not meant to auto-start just because earlier work shipped.
4. Slice 4 is conditional follow-up work, not an automatic continuation of the v1 Goals rollout.
Until this gate is satisfied, Slice 4 remains pending in practice: no schema-expansion, focus-set, or reporting implementation work should begin.
## Acceptable trigger evidence
A written activation rationale may recommend Slice 4 only when it cites real usage evidence in one or more of these categories.
### 1. Operator friction
Observed operator pain using the v1 goals workflow may justify refinement when teams repeatedly struggle to create, maintain, interpret, or operationalize goals with the existing surfaces.
**Corresponding Slice 4 direction:** general post-v1 refinement work, but only where the pain is demonstrated rather than speculative.
### 2. Prompt-budget or context-window pressure from goal injection
If active-goal injection creates measurable prompt-budget pressure, context-window crowding, or citation noise during actual agent use, that is valid trigger evidence.
**Corresponding Slice 4 direction:** candidate focus-set or narrowing mechanisms so only the most relevant goals are injected or emphasized.
### 3. Unclear prioritization or unclear mission ↔ goal ownership
If real usage shows agents or operators cannot tell which goals should drive a mission, or cannot reliably distinguish active strategic priorities from background goals, that is valid trigger evidence.
**Corresponding Slice 4 direction:** candidate focus-set concepts, richer linkage semantics, or prioritization aids.
### 4. Free-text success-metric limitations that fail agent reasoning
If goals expressed only through free-text slice/feature descriptions cause repeated ambiguity, weak planning, poor validation, or unreliable agent reasoning, that is valid trigger evidence.
**Corresponding Slice 4 direction:** candidate structured `successMetric` schema, but only to solve the observed reasoning failure.
### 5. The hard 5-active-goal cap proving too tight
If real operating practice shows the fixed five-goal cap blocks necessary work, forces unhealthy churn, or hides the difference between globally active goals and a smaller currently emphasized subset, that is valid trigger evidence.
**Corresponding Slice 4 direction:** candidate focus-set concept or related prioritization model.
### 6. Reporting or visibility gaps
If operators cannot answer basic progress, coverage, linkage, or adoption questions with the v1 read surfaces, and the gap is observed in real workflows, that is valid trigger evidence.
**Corresponding Slice 4 direction:** candidate goal-progress or reporting views.
## Activation rule
Slice 4 may be activated only after a **written rationale** is recorded that:
- references **real usage evidence**, not anticipated future needs;
- identifies which trigger-evidence category or categories were observed;
- explains why the observed pain is significant enough to justify refinement now; and
- cites the structured evidence collected in the **FN-5963 conditional refinement trigger evidence pack/template**.
That written rationale must exist **before** anyone calls `fn_slice_activate` for `SL-MP32LAJW-0009-RHJQ`.
## Hard constraint: no automatic refinement
The existence of Slice 4 in the mission does **not** authorize automatic follow-on work.
- No structured `successMetric` schema work starts automatically.
- No focus-set concept starts automatically.
- No reporting or visibility expansion starts automatically.
- No schema or expansion task should be treated as pre-approved merely because Slices 1–3 shipped.
Without the written rationale and evidence trigger above, Slice 4 remains pending and unspecified.
## Separation of concerns
This gate intentionally stays narrow:
- **FN-5961 (this artifact):** defines *when* refinement may start.
- **FN-5962:** maintains the conditional refinement **options backlog** describing candidate directions.
- **FN-5963:** defines the **evidence pack/template** used to gather and cite the real-usage evidence behind an activation request.
This document should reference those sibling deliverables rather than duplicate them.
## Decision rule summary
Use this checklist before any Slice 4 activation:
- Is there observed pain from real use of Slices 1–3?
- Does the evidence fit one or more accepted trigger categories above?
- Has the evidence been captured in the FN-5963 evidence pack/template?
- Has a written rationale been recorded citing that evidence and naming the proposed refinement direction?
- Has all of that happened **before** `fn_slice_activate` is called for Slice 4?
If any answer is no, do not activate Slice 4.

View File

@@ -52,6 +52,25 @@ Mission ↔ goal links are created and removed deliberately as part of normal pl
Mission Manager shows an **Unlinked** indicator on active mission cards when `linkedGoalCount` is zero. This is a read-only attention badge so operators can quickly find active missions that still need an explicit goal association.
### Task → Goal provenance
When a mission feature is linked or triaged into a task, Fusion does **not** copy goal ids onto the task row. Instead, task goal provenance is always derived from the mission link owned by `MissionStore`:
- `listGoalIdsForTask(taskId)` resolves the owning mission from the linked feature hierarchy first (`feature -> slice -> milestone -> mission`), then falls back to the live task row's `missionId` when needed.
- `listGoalsForTask(taskId)` maps those ids back to full `Goal` records using the same goals-table read path as `getMissionWithHierarchy`, so mission reads and task provenance stay in sync.
- Unknown, unlinked, or partially missing hierarchy state resolves fail-soft to `[]`.
- Archived goals remain part of provenance; only missing goal rows are dropped.
This derived bridge lets downstream systems recover which strategic goals a task serves without duplicating mission-goal linkage during task creation.
### Goal-injection diagnostics provenance field
The engine's `resolveAndEmitGoalContext` seam still injects only the always-on active-goal context into prompts, but diagnostics now add `provenanceGoalIds: string[]` alongside the existing injected `goalIds` / `goalCount` fields.
- `goalIds` / `goalCount` continue to describe the active goals injected into the prompt.
- `provenanceGoalIds` records which mission-linked goals the task serves.
- Diagnostics and run-audit metadata persist ids/counts only — never goal titles, descriptions, or prompt text.
## Creating Missions
### Mission base branch defaults

View File

@@ -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

View File

@@ -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.

View File

@@ -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`

View File

@@ -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. `<cwd>/.claude/skills/<id>/SKILL.md`), not by treating an arbitrary `cwd` as a skills directory. Pointing `cwd` at `<installRoot>` (which holds `<id>/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 `<skillId>/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 <id>/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 `<id>/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 `<root>/<skillId>/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).

View File

@@ -0,0 +1,124 @@
---
title: "Mission autopilot stalls forever on a done+implementing feature with no task"
date: 2026-06-03
category: docs/solutions/logic-errors
module: "engine/mission-execution-loop + core/mission-store"
problem_type: logic_error
component: background_job
symptoms:
- "A mission silently stops advancing — no error, no crash, just no progress"
- "Autopilot cycles watching to activating to watching indefinitely in mission_events, never advancing the milestone"
- "A slice stays stuck active even though all of its features report status=done"
- "Wedged feature shows the contradictory combo: status=done plus loopState=implementing plus null lastValidatorStatus plus a linked assertion plus no taskId"
root_cause: missing_workflow_step
resolution_type: code_fix
severity: high
related_components:
- "packages/engine/src/mission-execution-loop.ts (recoverActiveMissions, runFeatureValidation)"
- "packages/core/src/mission-store.ts (computeSliceStatus)"
tags:
- mission-system
- autopilot
- recovery
- slice-completion
- assertion-validation
- loop-state
---
# Mission autopilot stalls forever on a done+implementing feature with no task
## Problem
A mission feature could be left `status="done"` while its `loopState` stayed `"implementing"`, with no linked board task (`taskId`) and never validated (`lastValidatorStatus` null). The slice-completion gate correctly refuses to count an unvalidated, assertion-linked `done` feature, so the slice — and therefore the milestone and the whole mission — could never auto-progress. The mission stalled silently and indefinitely.
## Symptoms
- A mission stops advancing entirely — no error, no crash, just no forward motion.
- Autopilot cycles `watching → activating → watching` forever in `mission_events`, never advancing the milestone.
- A slice stays `active` even though every feature in it reports `status="done"`.
- The wedged features carry the contradictory combination: `status="done"` + `loopState="implementing"` + `lastValidatorStatus=null` + at least one linked assertion + no `taskId`.
## What Didn't Work
The first hypothesis came from reading code alone: an early `return` in the scheduler — the `reconciliation.kind === "blocked"` branch in `handleMissionTaskMove` — looked like it could swallow the transition before the completion handler ran. Plausible on inspection, but **not** what wedged this mission.
The real cause only surfaced by inspecting the live per-project DB read-only (`file:.../.fusion/fusion.db?mode=ro`) and looking at the actual stored feature rows. The diagnosis was then confirmed by contrast: an already-**completed** older mission also had many `done`+`implementing` features, but with **zero** assertions — so the gate let them through. That isolated the *assertion gate* as the active ingredient, not the `done`+`implementing` pairing by itself.
Lesson: reasoning from code alone pointed at the wrong early-return; observed data found the orphan state.
## Solution
Two independent, individually-correct facts interlocked into a deadlock:
1. **The slice gate is strict (by design).** `MissionStore.computeSliceStatus` (`packages/core/src/mission-store.ts:3866-3880`, added by FN-5715) refuses to count an assertion-linked `done` feature toward slice completion unless its validator passed *or* its `loopState` is idle/undefined.
2. **The recovery sweep had a gap.** `MissionExecutionLoop.recoverActiveMissions` only re-drove `implementing` features that still carried a `taskId` (`feature.loopState === "implementing" && feature.taskId`). A task-less stranded `done` feature matched none of the recovery branches (`validating` / `needs_fix` / `implementing && taskId`), so it could never be validated.
The fix adds a recovery branch for the orphan and extracts the validation path into a shared helper. Validation is a read-only judge (no board task created, no code edited), so it is safe to run directly from the recovery sweep.
```ts
// packages/engine/src/mission-execution-loop.ts — recoverActiveMissions,
// after the existing implementing+taskId branch
if (
feature.loopState === "implementing"
&& !feature.taskId
&& feature.status === "done"
&& feature.lastValidatorStatus !== "passed"
&& !this.activeValidations.has(feature.id)
) {
const currentFeature = this.missionStore.getFeature(feature.id) ?? feature;
// Live re-check: skip if it has since passed (avoids racing a concurrent pass)
if (
currentFeature.loopState === "passed"
|| currentFeature.lastValidatorStatus === "passed"
) {
continue;
}
recoveredCount++;
await this.runFeatureValidation(currentFeature);
}
```
The validation execution path was lifted out of `processTaskOutcome` into a reusable private method (behavior-preserving for the existing task-completion path):
```ts
// processTaskOutcome's inline block becomes a single call:
await this.runFeatureValidation(feature);
// shared helper used by both task-completion and recovery:
private async runFeatureValidation(feature: MissionFeature): Promise<void> {
const assertions = this.missionStore.listAssertionsForFeature(feature.id);
if (assertions.length === 0) {
await this.handleValidationPass(feature.id, undefined, "No assertions linked");
return;
}
this.activeValidations.add(feature.id);
try {
const run = this.missionStore.startValidatorRun(feature.id, "task_completion");
const result = await this.runValidation(feature, assertions, run);
// dispatch pass / fail / blocked / error as before
} finally {
this.activeValidations.delete(feature.id);
}
}
```
Shipped in PR #1345 (commit `c2604d5`). Tests added in `packages/engine/src/__tests__/mission-execution-loop.test.ts`; full mission-execution-loop suite plus self-healing/validator-reaper suites stayed green.
## Why This Works
The mission stalled because the validator never ran → `lastValidatorStatus` stayed null → `computeSliceStatus` never let the slice reach `complete` → the milestone never completed → autopilot looped forever. The gate was right to block; the bug was that nothing ever *satisfied* the gate for a task-less feature. Re-driving validation gives the orphan a terminal validator status either way: on pass it becomes legitimately complete and the slice resolves; on fail the existing fix-feature flow takes over. The live `getFeature` re-check before validating avoids racing a concurrent pass.
## Prevention
- **Treat `loopState` as possibly-stale and possibly-contradictory with `status`.** The `done` + non-terminal-`loopState` pairing is an invariant violation worth asserting/reconciling at write time, not just tolerating downstream. Any logic that *gates* on `loopState` inherits this fragility.
- **Recovery/self-healing sweeps keyed on `taskId` must handle the task-less orphan.** Conditions like `loopState === "implementing" && feature.taskId` silently skip any feature missing the key. Enumerate the orphan states explicitly.
- **When two individually-correct rules can interlock into a deadlock** (a strict gate + an incomplete recovery sweep), add an explicit reconciliation path rather than weakening the gate.
- **Diagnostic tip:** when a state machine stalls with no error, inspect the live DB read-only (`?mode=ro`) and read the actual stored values; contrast a wedged instance against a healthy/completed one to isolate the active ingredient. Code-reading alone misdirected this investigation.
## Related Issues
- `docs/missions-completion-contract.md` — the canonical FN-5715 completion-gate contract. It already covers (a) zero-assertion features going to `loopState="passed"` and (b) `taskId == null` features being re-triaged, but does **not** yet cover this specific orphan: `done` + `implementing` + no `taskId` + never validated. This learning extends that contract; the invariant belongs folded into its "Slice Status / Autopilot Advance" and "Validator/loop behavior" sections.
- `docs/missions.md:297` — documents stranded-feature (`taskId == null`) reconciliation and the `mission:stranded-feature-triaged` audit event.
- FN-5721 (#1183) — "Implement mission completion gate contract" (FN-5715 enforcement baseline); closest companion issue.
- FN-5901 — "reap stale mission validator runs": the sibling self-healing pattern for stale *validator* runs. This fix is the analogous self-heal for stranded *implementing* features. (session history)
- FN-5902 (in flight as of 2026-06-02) — "make ALL mission validation AI-run; eliminate zero-assertion auto-pass". Touches the same validation pipeline (`mission-execution-loop.ts` auto-pass branch); changing zero-assertion behavior interacts with this gate. (session history)

View File

@@ -688,6 +688,7 @@ Manual/non-auto-merge behavior:
- `Finish & Close` (PR already merged)
- Manual PR creation first checks for an existing PR on that branch and links it when found.
- If no PR exists, Fusion pushes the task branch to `origin` before creating the PR.
- In the dashboard Create-PR modal, if preflight detects merge conflicts with the selected base branch, you can choose **Resolve conflicts with AI**. Fusion resolves the task branch in-place, commits the result, pushes the updated branch to `origin`, and then lets you retry PR creation.
- When buffered actionable PR feedback exists on a PR that is already merged/closed and the task leaves `in-review`, Fusion creates a dependency-linked follow-up task in `triage` so feedback is not stranded.
## GitHub Tracking Issues

View File

@@ -2601,6 +2601,51 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(task.retrySummary?.total ?? 0).toBe(0);
};
it("clears the deadlock auto-pause for execution-failed in-review retries", async () => {
const store = new TaskStore(tmpDir);
await store.init();
const task = await store.createTask({
title: "deadlock-paused execution-failed task",
description: "test",
column: "todo",
});
await store.updateTask(task.id, {
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "in-progress" },
{ name: "Step 2", status: "pending" },
],
});
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, {
status: "failed",
error: "executor stalled after deadlock pause",
paused: true,
pausedReason: "in-review-stall-deadlock",
mergeRetries: 0,
nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
...nonZeroRetryCounters,
});
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-deadlock-exec", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBeFalsy();
expect(result.details.newColumn).toBe("todo");
const updated = await store.getTask(task.id);
expect(updated?.column).toBe("todo");
expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy();
expect(updated?.paused).toBeUndefined();
expect(updated?.pausedReason).toBeUndefined();
expect(updated?.steps[1].status).toBe("in-progress");
expectRetryCountersReset(updated);
expect(updated?.mergeRetries).toBe(0);
});
it("moves execution-failed in-review task (incomplete steps) to todo preserving progress", async () => {
const store = new TaskStore(tmpDir);
await store.init();
@@ -2671,6 +2716,87 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(updated?.mergeRetries).toBe(0);
});
it("clears the deadlock auto-pause for merge-failed in-review retries", async () => {
const store = new TaskStore(tmpDir);
await store.init();
const task = await store.createTask({
title: "deadlock-paused merge-failed task",
description: "test",
column: "todo",
});
await store.updateTask(task.id, {
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "done" },
],
});
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, {
status: "failed",
error: "merge deadlock",
paused: true,
pausedReason: "in-review-stall-deadlock",
mergeRetries: 3,
nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
...nonZeroRetryCounters,
});
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-deadlock-merge", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBeFalsy();
expect(result.details.newColumn).toBe("in-review");
const updated = await store.getTask(task.id);
expect(updated?.column).toBe("in-review");
expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy();
expect(updated?.paused).toBeUndefined();
expect(updated?.pausedReason).toBeUndefined();
expectRetryCountersReset(updated);
expect(updated?.mergeRetries).toBe(0);
});
it("does not clear manual pauses for merge-failed in-review retries", async () => {
const store = new TaskStore(tmpDir);
await store.init();
const task = await store.createTask({
title: "user-paused merge-failed task",
description: "test",
column: "todo",
});
await store.updateTask(task.id, {
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "done" },
],
});
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, {
status: "failed",
error: "merge deadlock",
paused: true,
pausedReason: "manual",
mergeRetries: 3,
});
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-user-paused-merge", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBeFalsy();
expect(result.details.newColumn).toBe("in-review");
const updated = await store.getTask(task.id);
expect(updated?.paused).toBe(true);
expect(updated?.pausedReason).toBe("manual");
expect(updated?.status).toBeFalsy();
expect(updated?.mergeRetries).toBe(0);
});
it("keeps merge-failed in-review task (all steps done) in in-review and resets merge state", async () => {
const store = new TaskStore(tmpDir);
await store.init();

View File

@@ -0,0 +1,59 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TaskStore } from "@fusion/core";
import { runTaskRetry } from "../commands/task.js";
describe("runTaskRetry", () => {
const originalCwd = process.cwd();
let tmpDir: string;
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "fusion-task-retry-"));
process.chdir(tmpDir);
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(async () => {
consoleLogSpy.mockRestore();
process.chdir(originalCwd);
await rm(tmpDir, { recursive: true, force: true });
});
async function createStore() {
const store = new TaskStore(tmpDir);
await store.init();
return store;
}
it("clears the deadlock auto-pause when retrying a failed task", async () => {
const store = await createStore();
const task = await store.createTask({
title: "deadlock-paused task",
description: "test",
column: "todo",
});
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, {
status: "failed",
error: "merge deadlock",
paused: true,
pausedReason: "in-review-stall-deadlock",
mergeRetries: 4,
});
await runTaskRetry(task.id);
const updated = await store.getTask(task.id);
expect(updated.column).toBe("todo");
expect(updated.status).toBeUndefined();
expect(updated.error).toBeUndefined();
expect(updated.paused).toBeUndefined();
expect(updated.pausedReason).toBeUndefined();
expect(updated.mergeRetries).toBe(0);
});
});

View File

@@ -1,4 +1,4 @@
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core";
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core";
import { aiMergeTask } from "@fusion/engine";
import { createInterface } from "node:readline/promises";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
@@ -1039,6 +1039,9 @@ export async function runTaskRetry(id: string, projectName?: string) {
throw new Error(`Task ${id} is not in a retryable state (status: ${task.status || 'none'})`);
}
const autoPauseClearPatch = buildAutoPauseClearPatch(task);
const clearedDeadlockAutoPause = Object.keys(autoPauseClearPatch).length > 0;
// Clear failure state and stale branch refs so retry can choose a fresh base.
await store.updateTask(id, {
status: null,
@@ -1047,6 +1050,7 @@ export async function runTaskRetry(id: string, projectName?: string) {
branch: null,
baseBranch: null,
baseCommitSha: null,
...autoPauseClearPatch,
...buildManualRetryResetPatch({ resetMergeRetries: true }),
});
@@ -1054,7 +1058,11 @@ export async function runTaskRetry(id: string, projectName?: string) {
await store.moveTask(id, 'todo');
// Log the retry action
await store.logEntry(id, "Retry requested from CLI", "Task reset to todo for retry");
await store.logEntry(
id,
clearedDeadlockAutoPause ? "Retry requested from CLI (cleared deadlock auto-pause)" : "Retry requested from CLI",
"Task reset to todo for retry",
);
console.log();
console.log(` ✓ Retried ${id} → todo (failure state cleared)`);

View File

@@ -5,6 +5,7 @@ import {
TaskStore,
COLUMNS,
COLUMN_LABELS,
buildAutoPauseClearPatch,
buildManualRetryResetPatch,
validateNodeOverrideChange,
type Task,
@@ -990,6 +991,10 @@ export default function kbExtension(pi: ExtensionAPI) {
};
}
const autoPauseClearPatch = buildAutoPauseClearPatch(task);
const clearedDeadlockAutoPause = Object.keys(autoPauseClearPatch).length > 0;
const retryLogSuffix = clearedDeadlockAutoPause ? ", cleared deadlock auto-pause" : "";
// In-review retry: distinguish between execution failures and merge failures.
if (task.column === 'in-review') {
const hasIncompleteSteps = task.steps.some(
@@ -1004,9 +1009,10 @@ export default function kbExtension(pi: ExtensionAPI) {
await store.updateTask(params.id, {
status: null,
error: null,
...autoPauseClearPatch,
...buildManualRetryResetPatch(),
});
await store.logEntry(params.id, "Retry requested via Fusion extension (execution failure in-review → todo, preserving progress)");
await store.logEntry(params.id, `Retry requested via Fusion extension (execution failure in-review → todo, preserving progress${retryLogSuffix})`);
await store.moveTask(params.id, "todo", { preserveProgress: true });
return {
content: [{ type: "text", text: `Retried ${params.id} → todo (execution failure, preserving step progress)` }],
@@ -1017,9 +1023,10 @@ export default function kbExtension(pi: ExtensionAPI) {
await store.updateTask(params.id, {
status: null,
error: null,
...autoPauseClearPatch,
...buildManualRetryResetPatch({ resetMergeRetries: true }),
});
await store.logEntry(params.id, "Retry requested via Fusion extension (in-review merge retry, mergeRetries reset)");
await store.logEntry(params.id, `Retry requested via Fusion extension (in-review merge retry, mergeRetries reset${retryLogSuffix})`);
return {
content: [{ type: "text", text: `Retried ${params.id} → in-review (merge retry state cleared)` }],
details: { taskId: params.id, newColumn: 'in-review' },
@@ -1030,6 +1037,7 @@ export default function kbExtension(pi: ExtensionAPI) {
await store.updateTask(params.id, {
status: null,
error: null,
...autoPauseClearPatch,
...buildManualRetryResetPatch({ resetMergeRetries: true }),
});

View File

@@ -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();

View File

@@ -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];

View File

@@ -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();
});
});

View File

@@ -1,9 +1,51 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { buildManualRetryResetPatch, MANUAL_RETRY_RESET_COUNTER_KEYS } from "../manual-retry-reset.js";
import {
IN_REVIEW_STALL_DEADLOCK_PAUSE_REASON,
MANUAL_RETRY_RESET_COUNTER_KEYS,
buildAutoPauseClearPatch,
buildManualRetryResetPatch,
} from "../manual-retry-reset.js";
const RETRY_SUMMARY_COUNTER_REGEX = /toCount\(task\.(\w+)\)/g;
describe("buildAutoPauseClearPatch", () => {
it("clears the deadlock auto-pause for auto-paused tasks", () => {
expect(buildAutoPauseClearPatch({
paused: true,
userPaused: undefined,
pausedReason: IN_REVIEW_STALL_DEADLOCK_PAUSE_REASON,
})).toEqual({
paused: false,
pausedReason: null,
});
});
it("does not clear an explicit user pause", () => {
expect(buildAutoPauseClearPatch({
paused: true,
userPaused: true,
pausedReason: IN_REVIEW_STALL_DEADLOCK_PAUSE_REASON,
})).toEqual({});
});
it("does not clear unrelated automatic pause reasons", () => {
expect(buildAutoPauseClearPatch({
paused: true,
userPaused: undefined,
pausedReason: "branch-conflict-unrecoverable",
})).toEqual({});
});
it("is a no-op when the task is not paused", () => {
expect(buildAutoPauseClearPatch({
paused: undefined,
userPaused: undefined,
pausedReason: IN_REVIEW_STALL_DEADLOCK_PAUSE_REASON,
})).toEqual({});
});
});
describe("buildManualRetryResetPatch", () => {
it("resets all manual retry counters to zero", () => {
const patch = buildManualRetryResetPatch();

View File

@@ -1940,6 +1940,129 @@ describe("MissionStore", () => {
});
});
describe("task goal provenance", () => {
async function createStoreWithTaskStore() {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
return { ts, ms: ts.getMissionStore(), goals: ts.getGoalStore() };
}
it("returns empty arrays for unknown and unlinked tasks", async () => {
const { ts, ms } = await createStoreWithTaskStore();
const task = await ts.createTask({ title: "Standalone task", description: "No mission link" });
expect(ms.listGoalIdsForTask("FN-DOES-NOT-EXIST")).toEqual([]);
expect(ms.listGoalsForTask("FN-DOES-NOT-EXIST")).toEqual([]);
expect(ms.listGoalIdsForTask(task.id)).toEqual([]);
expect(ms.listGoalsForTask(task.id)).toEqual([]);
});
it("returns an empty array for mission-linked tasks when the mission has no goals", async () => {
const { ts, ms } = await createStoreWithTaskStore();
const mission = ms.createMission({ title: "Mission" });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
const feature = ms.addFeature(slice.id, { title: "Feature" });
const task = await ts.createTask({ title: "Task", description: "Linked task" });
ms.linkFeatureToTask(feature.id, task.id);
expect(ms.listGoalIdsForTask(task.id)).toEqual([]);
expect(ms.listGoalsForTask(task.id)).toEqual([]);
});
it("preserves stable ordering for multiple linked goals and matches hierarchy mapping", async () => {
const { ts, ms, goals } = await createStoreWithTaskStore();
const mission = ms.createMission({ title: "Mission" });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
const feature = ms.addFeature(slice.id, { title: "Feature" });
const goalA = goals.createGoal({ title: "Goal A" });
const goalB = goals.createGoal({ title: "Goal B" });
ms.linkGoal(mission.id, goalA.id);
ms.linkGoal(mission.id, goalB.id);
const task = await ts.createTask({ title: "Task", description: "Linked task" });
ms.linkFeatureToTask(feature.id, task.id);
expect(ms.listGoalIdsForTask(task.id)).toEqual([goalA.id, goalB.id]);
expect(ms.listGoalsForTask(task.id)).toEqual(ms.getMissionWithHierarchy(mission.id)?.linkedGoals ?? []);
});
it("keeps archived linked goals in task provenance", async () => {
const { ts, ms, goals } = await createStoreWithTaskStore();
const mission = ms.createMission({ title: "Mission" });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
const feature = ms.addFeature(slice.id, { title: "Feature" });
const goal = goals.createGoal({ title: "Archived goal" });
ms.linkGoal(mission.id, goal.id);
const archivedGoal = goals.archiveGoal(goal.id);
const task = await ts.createTask({ title: "Task", description: "Linked task" });
ms.linkFeatureToTask(feature.id, task.id);
expect(ms.listGoalIdsForTask(task.id)).toEqual([goal.id]);
expect(ms.listGoalsForTask(task.id)).toEqual([archivedGoal]);
});
it("falls back through feature linkage when tasks.missionId is unset", async () => {
const { ts, ms, goals } = await createStoreWithTaskStore();
const mission = ms.createMission({ title: "Mission" });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
const feature = ms.addFeature(slice.id, { title: "Feature" });
const goal = goals.createGoal({ title: "Fallback goal" });
ms.linkGoal(mission.id, goal.id);
const task = await ts.createTask({ title: "Task", description: "Linked task" });
ms.linkFeatureToTask(feature.id, task.id);
db.prepare("UPDATE tasks SET missionId = NULL WHERE id = ?").run(task.id);
expect(ms.listGoalIdsForTask(task.id)).toEqual([goal.id]);
expect(ms.listGoalsForTask(task.id)).toEqual([goal]);
});
it("resolves provenance for triaged tasks without storing goal ids on the task row", async () => {
const { ts, ms, goals } = await createStoreWithTaskStore();
const goal = goals.createGoal({ title: "Goal title" });
const mission = ms.createMission({ title: "Mission" });
ms.linkGoal(mission.id, goal.id);
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
const feature = ms.addFeature(slice.id, { title: "Feature", description: "Desc" });
const triaged = await ms.triageFeature(feature.id);
const task = await ts.getTask(triaged.taskId!);
expect(ms.listGoalsForTask(triaged.taskId!)).toEqual([
expect.objectContaining({ id: goal.id, title: goal.title }),
]);
expect(task?.missionId).toBe(mission.id);
expect(task).not.toHaveProperty("goalId");
expect(task).not.toHaveProperty("goalIds");
});
it("resolves provenance identically for manual feature linkage", async () => {
const { ts, ms, goals } = await createStoreWithTaskStore();
const goal = goals.createGoal({ title: "Manual goal" });
const mission = ms.createMission({ title: "Mission" });
ms.linkGoal(mission.id, goal.id);
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
const feature = ms.addFeature(slice.id, { title: "Feature" });
const task = await ts.createTask({ title: "Manual task", description: "Manual" });
ms.linkFeatureToTask(feature.id, task.id);
expect(ms.listGoalIdsForTask(task.id)).toEqual([goal.id]);
expect(ms.listGoalsForTask(task.id)).toEqual([
expect.objectContaining({ id: goal.id, title: goal.title }),
]);
});
});
// ── Transaction Tests ────────────────────────────────────────────────
describe("Transaction Handling", () => {

View File

@@ -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<CreateAiSessionFactory | undefined> {
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;
}

View File

@@ -61,6 +61,8 @@ export {
getFnAgent,
setCreateAiSessionFactory,
getCreateAiSessionFactory,
setCreateInteractiveAiSessionFactory,
getCreateInteractiveAiSessionFactory,
type AgentMessage,
} from "./ai-engine-loader.js";
export {
@@ -228,7 +230,12 @@ export {
normalizeTitleForTaskId,
} from "./task-title-id-drift.js";
export { getPrimaryPrInfo } from "./task-helpers.js";
export { MANUAL_RETRY_RESET_COUNTER_KEYS, buildManualRetryResetPatch } from "./manual-retry-reset.js";
export {
IN_REVIEW_STALL_DEADLOCK_PAUSE_REASON,
MANUAL_RETRY_RESET_COUNTER_KEYS,
buildAutoPauseClearPatch,
buildManualRetryResetPatch,
} from "./manual-retry-reset.js";
export type {
TaskIdIntegrityAnomaly,
TaskIdIntegrityAnomalyKind,
@@ -529,6 +536,12 @@ export type {
CreateAiSessionOptions,
AiSessionResult,
CreateAiSessionFactory,
CreateInteractiveAiSessionOptions,
InteractiveAiSessionProgressEvent,
InteractiveAiSessionEvent,
InteractiveAiSession,
CreateInteractiveAiSessionResult,
CreateInteractiveAiSessionFactory,
PluginLogger,
PluginSkillContribution,
PluginWorkflowStepContribution,

View File

@@ -1,5 +1,7 @@
import type { Task } from "./types.js";
export const IN_REVIEW_STALL_DEADLOCK_PAUSE_REASON = "in-review-stall-deadlock";
export const MANUAL_RETRY_RESET_COUNTER_KEYS = [
"stuckKillCount",
"resumeLimboCount",
@@ -17,6 +19,23 @@ export const MANUAL_RETRY_RESET_COUNTER_KEYS = [
"mergeAuditBounceCount",
] as const satisfies ReadonlyArray<keyof Task>;
export function buildAutoPauseClearPatch(
task: Pick<Task, "paused" | "userPaused" | "pausedReason">,
): Partial<Task> {
if (
task.paused === true
&& task.userPaused !== true
&& task.pausedReason === IN_REVIEW_STALL_DEADLOCK_PAUSE_REASON
) {
return {
paused: false,
pausedReason: null as unknown as Task["pausedReason"],
};
}
return {};
}
export function buildManualRetryResetPatch(options?: { resetMergeRetries?: boolean }): Partial<Task> {
const patch: Partial<Task> = {
nextRecoveryAt: null as unknown as Task["nextRecoveryAt"],

View File

@@ -487,6 +487,15 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
};
}
private listGoalsByIds(goalIds: string[]): Goal[] {
return goalIds
.map((goalId) => this.db
.prepare("SELECT id, title, description, status, createdAt, updatedAt FROM goals WHERE id = ?")
.get(goalId) as GoalRow | undefined)
.filter((row): row is GoalRow => Boolean(row))
.map((row) => this.rowToGoal(row));
}
/**
* Convert a database row to a MissionContractAssertion object.
*/
@@ -703,12 +712,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
const mission = this.getMission(id);
if (!mission) return undefined;
const linkedGoals = this.listGoalIdsForMission(id)
.map((goalId) => this.db
.prepare("SELECT id, title, description, status, createdAt, updatedAt FROM goals WHERE id = ?")
.get(goalId) as GoalRow | undefined)
.filter((row): row is GoalRow => Boolean(row))
.map((row) => this.rowToGoal(row));
const linkedGoals = this.listGoalsByIds(this.listGoalIdsForMission(id));
const milestones = this.listMilestones(id);
const milestonesWithSlices = milestones.map((milestone) => {
@@ -1416,6 +1420,45 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
return rows.map((row) => row.missionId);
}
/**
* Resolve task → goal provenance by deriving the owning mission from mission linkage.
* Goal IDs are never duplicated onto the task row; provenance is always recovered from mission links.
*/
listGoalIdsForTask(taskId: string): string[] {
const feature = this.getFeatureByTaskId(taskId);
const missionIdFromFeature = feature
? (() => {
const slice = this.getSlice(feature.sliceId);
if (!slice) {
return undefined;
}
const milestone = this.getMilestone(slice.milestoneId);
return milestone?.missionId;
})()
: undefined;
const missionId = missionIdFromFeature ?? (() => {
const row = this.db
.prepare('SELECT missionId FROM tasks WHERE id = ? AND "deletedAt" IS NULL')
.get(taskId) as { missionId?: string | null } | undefined;
return row?.missionId ?? undefined;
})();
if (!missionId) {
return [];
}
return this.listGoalIdsForMission(missionId);
}
/**
* Resolve task → goal provenance to full Goal records derived from the owning mission.
* Goal rows are read on demand so archived goals remain visible without storing duplicate task-level goal data.
*/
listGoalsForTask(taskId: string): Goal[] {
return this.listGoalsByIds(this.listGoalIdsForTask(taskId));
}
// ── Milestone Operations ───────────────────────────────────────────
/**

View File

@@ -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<Pick<PluginContext, "taskStore" | "settings" | "resolveProjectTaskStore">>,
overrides?: Partial<Pick<PluginContext, "taskStore" | "settings" | "resolveProjectTaskStore" | "emitEvent">>,
): Promise<PluginContext> {
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);
},
}),
};
}

View File

@@ -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<AiSessionResult>;
// ── 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 `<id>/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<void>;
/** Pull the next event produced by the most recent prompt/answer. */
nextEvent(): Promise<InteractiveAiSessionEvent>;
/** Answer the currently-awaiting question, resuming the agent. */
answer(questionId: string, response: unknown): Promise<void>;
/** 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<CreateInteractiveAiSessionResult>;
/**
* 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<TaskStore>;
}

View File

@@ -88,6 +88,7 @@ interface TaskRow {
blockedBy: string | null;
overlapBlockedBy: string | null;
paused: number | null;
pausedReason: string | null;
userPaused: number | null;
baseBranch: string | null;
executionStartBranch: string | null;
@@ -1476,6 +1477,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
blockedBy: row.blockedBy || undefined,
overlapBlockedBy: row.overlapBlockedBy || undefined,
paused: row.paused ? true : undefined,
pausedReason: row.pausedReason || undefined,
userPaused: row.userPaused ? true : undefined,
baseBranch: row.baseBranch || undefined,
executionStartBranch: row.executionStartBranch || undefined,
@@ -2106,6 +2108,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.blockedBy ?? null,
task.overlapBlockedBy ?? null,
task.paused ? 1 : 0,
task.pausedReason ?? null,
task.userPaused ? 1 : 0,
task.baseBranch ?? null,
task.branch ?? null,
@@ -2222,7 +2225,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.db.prepare(`
INSERT INTO tasks (
id, lineageId, title, description, priority, "column", status, size, reviewLevel, currentStep,
worktree, blockedBy, overlapBlockedBy, paused, userPaused, baseBranch, branch, autoMerge, executionStartBranch, baseCommitSha, modelPresetId, modelProvider,
worktree, blockedBy, overlapBlockedBy, paused, pausedReason, userPaused, baseBranch, branch, autoMerge, executionStartBranch, baseCommitSha, modelPresetId, modelProvider,
modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, mergeRetries,
workflowStepRetries, stuckKillCount, resumeLimboCount, resumeLimboTipSha, resumeLimboStepSignature, postReviewFixCount, recoveryRetryCount, taskDoneRetryCount, worktreeSessionRetryCount, completionHandoffLimboRecoveryCount, verificationFailureCount, mergeConflictBounceCount, mergeAuditBounceCount, mergeTransientRetryCount, branchConflictRecoveryCount, reviewerContextRetryCount, reviewerFallbackRetryCount, nextRecoveryAt, error,
summary, thinkingLevel, executionMode, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens,
@@ -2249,7 +2252,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.db.prepare(`
INSERT INTO tasks (
id, lineageId, title, description, priority, "column", status, size, reviewLevel, currentStep,
worktree, blockedBy, overlapBlockedBy, paused, userPaused, baseBranch, branch, autoMerge, executionStartBranch, baseCommitSha, modelPresetId, modelProvider,
worktree, blockedBy, overlapBlockedBy, paused, pausedReason, userPaused, baseBranch, branch, autoMerge, executionStartBranch, baseCommitSha, modelPresetId, modelProvider,
modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, mergeRetries,
workflowStepRetries, stuckKillCount, resumeLimboCount, resumeLimboTipSha, resumeLimboStepSignature, postReviewFixCount, recoveryRetryCount, taskDoneRetryCount, worktreeSessionRetryCount, completionHandoffLimboRecoveryCount, verificationFailureCount, mergeConflictBounceCount, mergeAuditBounceCount, mergeTransientRetryCount, branchConflictRecoveryCount, reviewerContextRetryCount, reviewerFallbackRetryCount, nextRecoveryAt, error,
summary, thinkingLevel, executionMode, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens,
@@ -2274,6 +2277,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
blockedBy = excluded.blockedBy,
overlapBlockedBy = excluded.overlapBlockedBy,
paused = excluded.paused,
pausedReason = excluded.pausedReason,
userPaused = excluded.userPaused,
baseBranch = excluded.baseBranch,
branch = excluded.branch,

View File

@@ -734,6 +734,7 @@ The dashboard server exposes a REST API at `/api`:
- `POST /api/github/issues/import` - Import issue (`{ owner, repo, issueNumber }`)
- `POST /api/github/webhooks` - GitHub App webhook endpoint for badge updates (see GitHub App Setup below)
- `POST /api/tasks/:id/pr/create` - Create PR
- `POST /api/tasks/:id/pr/resolve-conflicts` - Resolve Create-PR merge conflicts with AI and push the task branch
- `GET /api/tasks/:id/pr/status` - Get PR status (5-min staleness, auto background refresh)
- `POST /api/tasks/:id/pr/refresh` - Force refresh PR status
- `GET /api/tasks/:id/issue/status` - Get cached issue status (5-min staleness, auto background refresh)

View File

@@ -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) => (
<TaskCard

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const REQUIRED_TRIGGER_EVIDENCE = [
"Operator friction",
"Prompt-budget or context-window pressure from goal injection",
"Unclear prioritization or unclear mission ↔ goal ownership",
"Free-text success-metric limitations that fail agent reasoning",
"The hard 5-active-goal cap proving too tight",
"Reporting or visibility gaps",
] as const;
const REQUIRED_ACTIVATION_RULE_SNIPPETS = [
"written rationale",
"real usage evidence",
"FN-5963 conditional refinement trigger evidence pack/template",
"`fn_slice_activate` for `SL-MP32LAJW-0009-RHJQ`",
] as const;
const REQUIRED_NO_AUTOMATIC_REFINEMENT_SNIPPETS = [
"does **not** authorize automatic follow-on work",
"No structured `successMetric` schema work starts automatically.",
"No focus-set concept starts automatically.",
"No reporting or visibility expansion starts automatically.",
"Without the written rationale and evidence trigger above, Slice 4 remains pending and unspecified.",
] as const;
describe("Goals refinement gate doc", () => {
it("documents the evidence categories, written-rationale activation rule, and no-auto-refinement constraint", () => {
const doc = readFileSync(
resolve(__dirname, "../../../../docs/goals-refinement-gate.md"),
"utf-8",
);
expect(doc).toContain("# Goals Refinement Gate");
expect(doc).toContain("[← Docs index](./README.md)");
for (const snippet of REQUIRED_TRIGGER_EVIDENCE) {
expect(doc).toContain(snippet);
}
for (const snippet of REQUIRED_ACTIVATION_RULE_SNIPPETS) {
expect(doc).toContain(snippet);
}
for (const snippet of REQUIRED_NO_AUTOMATIC_REFINEMENT_SNIPPETS) {
expect(doc).toContain(snippet);
}
});
});

View File

@@ -2416,6 +2416,18 @@ export interface PrPreflightResponse {
changedFiles: PrPreflightChangedFile[];
}
export interface ResolvePrConflictsResult {
resolved: boolean;
pushed: boolean;
conflictedFiles: string[];
message: string;
}
export interface ResolvePrConflictsResponse {
result: ResolvePrConflictsResult;
preflight: PrPreflightResponse;
}
export interface PrOptionsUser {
login: string;
name?: string;
@@ -2456,6 +2468,14 @@ export function fetchPrPreflight(id: string, projectId?: string, base?: string):
return api<PrPreflightResponse>(withProjectId(`/tasks/${id}/pr/preflight${baseParam}`, projectId));
}
/** Ask Fusion to resolve Create-PR merge conflicts for a task branch */
export function resolvePrConflicts(id: string, base?: string, projectId?: string): Promise<ResolvePrConflictsResponse> {
return api<ResolvePrConflictsResponse>(withProjectId(`/tasks/${id}/pr/resolve-conflicts`, projectId), {
method: "POST",
...(base ? { body: JSON.stringify({ base }) } : {}),
});
}
/** Fetch PR creation options (branches/reviewers/assignees/labels) for a task */
export function fetchPrOptions(id: string, projectId?: string): Promise<PrOptionsResponse> {
return api<PrOptionsResponse>(withProjectId(`/tasks/${id}/pr/options`, projectId));

View File

@@ -80,6 +80,35 @@
gap: var(--space-sm);
}
.pr-create-modal__conflict-resolution {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
padding: var(--space-md);
border: var(--btn-border-width) solid color-mix(in srgb, var(--color-warning) 35%, transparent);
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
}
.pr-create-modal__conflict-copy {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.pr-create-modal__conflict-title,
.pr-create-modal__conflict-message {
margin: 0;
}
.pr-create-modal__conflict-title {
font-weight: 600;
}
.pr-create-modal__conflict-message {
color: var(--text-muted);
}
.pr-create-modal__label {
font-size: 0.75rem;
text-transform: uppercase;
@@ -246,7 +275,8 @@
.pr-create-modal__title-row,
.pr-create-modal__grid-two,
.pr-create-modal__commit-row,
.pr-create-modal__file-row {
.pr-create-modal__file-row,
.pr-create-modal__conflict-resolution {
display: flex;
flex-direction: column;
align-items: flex-start;

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties } from "react";
import { createPortal } from "react-dom";
import { AlertTriangle, CheckCircle2, RefreshCw, Sparkles, X, XCircle } from "lucide-react";
import { getErrorMessage, type PrInfo, type StructuredGhError } from "@fusion/core";
import {
@@ -6,6 +7,7 @@ import {
fetchPrOptions,
fetchPrPreflight,
generatePrMetadata,
resolvePrConflicts,
type PrOptionsLabel,
type PrOptionsResponse,
type PrOptionsUser,
@@ -134,6 +136,7 @@ export function PrCreateModal({
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [resolveConflictError, setResolveConflictError] = useState<string | null>(null);
const [lastGhError, setLastGhError] = useState<ModalGhError | null>(null);
const [aiTitle, setAiTitle] = useState("");
const [aiBody, setAiBody] = useState("");
@@ -146,6 +149,7 @@ export function PrCreateModal({
const [preflight, setPreflight] = useState<PrPreflightResponse | null>(null);
const [baseBranch, setBaseBranch] = useState("");
const [draft, setDraft] = useState(false);
const [resolvingConflicts, setResolvingConflicts] = useState(false);
const [reviewers, setReviewers] = useState<PrOptionsUser[]>([]);
const [assignees, setAssignees] = useState<PrOptionsUser[]>([]);
const [labels, setLabels] = useState<PrOptionsLabel[]>([]);
@@ -156,6 +160,7 @@ export function PrCreateModal({
const requestId = ++requestSeqRef.current;
setLoading(true);
setError(null);
setResolveConflictError(null);
try {
const [metadata, preflightData, optionsData] = await Promise.all([
generatePrMetadata(taskId, projectId),
@@ -281,6 +286,7 @@ export function PrCreateModal({
const handleBaseChange = useCallback(async (nextBase: string) => {
setBaseBranch(nextBase);
setResolveConflictError(null);
try {
const nextPreflight = await fetchPrPreflight(taskId, projectId, nextBase);
setPreflight(nextPreflight);
@@ -289,6 +295,21 @@ export function PrCreateModal({
}
}, [projectId, taskId]);
const handleResolveConflicts = useCallback(async () => {
if (!baseBranch || resolvingConflicts) return;
setResolvingConflicts(true);
setResolveConflictError(null);
try {
const response = await resolvePrConflicts(taskId, baseBranch, projectId);
setPreflight(response.preflight);
addToast("Resolved PR conflicts and pushed branch", "success");
} catch (resolveError) {
setResolveConflictError(getErrorMessage(resolveError));
} finally {
setResolvingConflicts(false);
}
}, [addToast, baseBranch, projectId, resolvingConflicts, taskId]);
const payload = useMemo(() => ({
title: title.trim(),
body: body.trim(),
@@ -323,7 +344,7 @@ export function PrCreateModal({
if (!open) return null;
return (
return createPortal(
<div className="modal-overlay open" onClick={(event) => event.target === event.currentTarget && onClose()}>
<div
ref={modalRef}
@@ -359,6 +380,23 @@ export function PrCreateModal({
<button type="button" className="btn btn-sm" onClick={() => void handleBaseChange(baseBranch)}>
Re-run preflight
</button>
{preflight?.conflictsWithBase ? (
<div className="card pr-create-modal__conflict-resolution">
<div className="pr-create-modal__conflict-copy">
<p className="pr-create-modal__conflict-title">Resolve conflicts with AI</p>
<p className="pr-create-modal__conflict-message">Fusion will use AI to resolve conflicts on this branch and push it.</p>
</div>
<button
type="button"
className="btn btn-sm"
onClick={() => void handleResolveConflicts()}
disabled={resolvingConflicts || loading}
>
{resolvingConflicts ? <RefreshCw size={14} className="spin" /> : null}
Resolve conflicts with AI
</button>
</div>
) : null}
</section>
<section className="pr-create-modal__section">
@@ -445,6 +483,15 @@ export function PrCreateModal({
</div>
</details>
{resolveConflictError ? (
<div className="form-error pr-error" role="alert">
<p>{resolveConflictError}</p>
<div className="pr-error__actions">
<button type="button" className="btn btn-sm pr-error__dismiss" onClick={() => setResolveConflictError(null)} aria-label="Dismiss conflict resolution error">×</button>
</div>
</div>
) : null}
{error && (
<div className="form-error pr-error" role="alert">
<p>{error}</p>
@@ -469,6 +516,7 @@ export function PrCreateModal({
</button>
</div>
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -274,6 +274,10 @@
width: 100%;
}
.task-review-tab__summary-wrap {
flex: 0 0 auto;
}
.task-review-tab__actions {
justify-content: flex-start;
gap: var(--space-sm);

View File

@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PrCreateModal } from "../PrCreateModal";
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
fetchPrPreflight: vi.fn(),
fetchPrOptions: vi.fn(),
createPr: vi.fn(),
resolvePrConflicts: vi.fn(),
}));
vi.mock("../../api", () => ({
@@ -16,6 +17,7 @@ vi.mock("../../api", () => ({
fetchPrPreflight: mocks.fetchPrPreflight,
fetchPrOptions: mocks.fetchPrOptions,
createPr: mocks.createPr,
resolvePrConflicts: mocks.resolvePrConflicts,
}));
const metadata = { title: "AI title", body: "## Summary\n\n## Changes\n\n## Testing\n\n## Linked Task\n", templateUsed: true };
@@ -67,11 +69,63 @@ describe("PrCreateModal", () => {
mocks.fetchPrPreflight.mockResolvedValue(preflight);
mocks.fetchPrOptions.mockResolvedValue(options);
mocks.createPr.mockResolvedValue({ number: 12, title: "AI title", url: "url", status: "open", headBranch: "h", baseBranch: "main", commentCount: 0 } as PrInfo);
mocks.resolvePrConflicts.mockResolvedValue({ result: { resolved: true, pushed: true, conflictedFiles: ["a.ts"], message: "resolved" }, preflight });
});
it("renders nothing when closed", () => {
render(<PrCreateModal open={false} taskId="FN-4756" onClose={vi.fn()} onCreated={vi.fn()} addToast={vi.fn()} />);
expect(screen.queryByRole("dialog")).toBeNull();
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
});
it("portals out of a container-type containing block", async () => {
const { container } = render(
<div data-testid="trap" style={{ containerType: "inline-size" }}>
<PrCreateModal open taskId="FN-4756" onClose={vi.fn()} onCreated={vi.fn()} addToast={vi.fn()} />
</div>,
);
await screen.findByDisplayValue("AI title");
const trap = within(container).getByTestId("trap");
expect(trap.querySelector('[role="dialog"]')).toBeNull();
const dialog = screen.getByRole("dialog");
expect(dialog).toBeInTheDocument();
expect(dialog.parentElement).toHaveClass("modal-overlay", "open");
expect(dialog.parentElement?.parentElement).toBe(document.body);
});
it("portals independently of an outer modal overlay", async () => {
const outerOnClose = vi.fn();
const innerOnClose = vi.fn();
const { container } = render(
<div>
<div className="modal-overlay open" data-testid="outer-overlay" onClick={outerOnClose}>
<div className="modal" role="dialog" aria-modal="true" aria-label="Outer modal">Outer modal</div>
</div>
<div data-testid="outer-shell">
<PrCreateModal open taskId="FN-4756" onClose={innerOnClose} onCreated={vi.fn()} addToast={vi.fn()} />
</div>
</div>,
);
await screen.findByDisplayValue("AI title");
const outerShell = within(container).getByTestId("outer-shell");
expect(outerShell.querySelector('[role="dialog"]')).toBeNull();
const overlays = Array.from(document.body.querySelectorAll(".modal-overlay.open"));
expect(overlays).toHaveLength(2);
const outerOverlay = within(container).getByTestId("outer-overlay");
const innerDialog = screen.getByRole("dialog", { name: "Create Pull Request" });
expect(outerOverlay.contains(innerDialog)).toBe(false);
fireEvent.click(outerOverlay);
expect(outerOnClose).toHaveBeenCalledTimes(1);
expect(innerOnClose).not.toHaveBeenCalled();
expect(screen.getByRole("dialog", { name: "Create Pull Request" })).toBeInTheDocument();
});
it("loads metadata/preflight/options on open and renders key sections", async () => {
@@ -156,11 +210,39 @@ describe("PrCreateModal", () => {
fireEvent.click(screen.getByRole("button", { name: /remove reviewer 1/i }));
});
it("renders AI conflict resolution affordance and enables submit after success", async () => {
mocks.fetchPrPreflight.mockResolvedValue({ ...preflight, conflictsWithBase: true, branchOnRemote: false });
mocks.resolvePrConflicts.mockResolvedValueOnce({ result: { resolved: true, pushed: true, conflictedFiles: ["a.ts"], message: "resolved" }, preflight });
const { addToast } = await renderModalLoaded();
const submitButton = screen.getByRole("button", { name: "Create PR" });
expect(submitButton).toBeDisabled();
const resolveButton = await screen.findByRole("button", { name: "Resolve conflicts with AI" });
fireEvent.click(resolveButton);
await waitFor(() => expect(mocks.resolvePrConflicts).toHaveBeenCalledWith("FN-4756", "main", undefined));
await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled());
expect(addToast).toHaveBeenCalledWith("Resolved PR conflicts and pushed branch", "success");
});
it("surfaces conflict resolution failures", async () => {
mocks.fetchPrPreflight.mockResolvedValue({ ...preflight, conflictsWithBase: true });
mocks.resolvePrConflicts.mockRejectedValueOnce(new Error("unable to resolve"));
await renderModalLoaded();
fireEvent.click(await screen.findByRole("button", { name: "Resolve conflicts with AI" }));
expect(await screen.findByText("unable to resolve")).toBeInTheDocument();
});
it("shows submit error and retries with same payload", async () => {
mocks.createPr.mockRejectedValueOnce(new Error("bad")).mockResolvedValueOnce({ number: 22, title: "ok", url: "u", status: "open", headBranch: "h", baseBranch: "main", commentCount: 0 } as PrInfo);
await renderModalLoaded();
await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "Create PR" }));
await waitFor(() => expect(mocks.createPr).toHaveBeenCalledTimes(1));
expect(await screen.findByText("bad")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => expect(mocks.createPr).toHaveBeenCalledTimes(2));
@@ -181,13 +263,23 @@ describe("PrCreateModal", () => {
});
mocks.createPr.mockRejectedValueOnce(err);
await renderModalLoaded();
await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "Create PR" }));
await waitFor(() => expect(mocks.createPr).toHaveBeenCalledTimes(1));
expect((await screen.findAllByText(/gh auth login/i)).length).toBeGreaterThan(0);
});
it("closes on overlay click", async () => {
const { onClose } = await renderModalLoaded();
const overlay = document.querySelector(".modal-overlay.open");
expect(overlay).toBeTruthy();
fireEvent.click(overlay as Element);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("closes on escape", async () => {
const { onClose } = await renderModalLoaded();
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -504,8 +504,13 @@ describe("TaskReviewTab", () => {
const mobileMediaStart = css.indexOf("@media (max-width: 768px)");
expect(mobileMediaStart).toBeGreaterThanOrEqual(0);
const mobileCss = css.slice(mobileMediaStart);
const baseSummaryWrapRule = css.match(/\.task-review-tab__summary-wrap\s*\{[^}]*\}/)?.[0] ?? "";
expect(baseSummaryWrapRule).toMatch(/flex\s*:\s*1\s+1\s+20rem\s*;/);
expect(baseSummaryWrapRule).not.toMatch(/flex\s*:\s*0\s+0\s+auto\s*;/);
expect(mobileCss).toMatch(/\.task-review-tab__header\s*\{[^}]*flex-direction\s*:\s*column\s*;[^}]*\}/);
expect(mobileCss).toMatch(/\.task-review-tab__summary-wrap\s*\{[^}]*flex\s*:\s*0\s+0\s+auto\s*;[^}]*\}/);
expect(mobileCss).not.toMatch(/\.task-review-tab__summary-wrap\s*\{[^}]*flex\s*:\s*1\s+1\s+20rem\s*;[^}]*\}/);
expect(mobileCss).toMatch(/\.task-review-tab__actions\s*\{[^}]*justify-content\s*:\s*flex-start\s*;[^}]*\}/);
expect(mobileCss).toMatch(/\.task-review-tab__actions\s+\.btn\s*\{[^}]*width\s*:\s*100%\s*;[^}]*\}/);
expect(mobileCss).toMatch(/\.task-review-tab__body\s*\{[^}]*padding\s*:\s*var\(--space-sm\)\s*;[^}]*\}/);
@@ -515,6 +520,99 @@ describe("TaskReviewTab", () => {
expect(css).toMatch(/\.task-review-tab__item\s*\{[^}]*padding\s*:\s*var\(--card-padding\)\s*;[^}]*\}/);
});
it("preserves review header structure across sources and empty or populated states", async () => {
const cases = [
{
task: makeTask({ id: "FN-100" }),
response: {
reviewState: {
source: "reviewer-agent" as const,
summary: { summary: "reviewer-agent", verdict: "REVISE", reviewType: "code" },
items: [],
addressing: [],
},
automationStatus: null,
emptyMessage: "No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.",
},
summaryText: "reviewer-agent · 0 review item(s)",
emptyText: "No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.",
},
{
task: makeTask({ id: "FN-101" }),
response: {
reviewState: {
source: "reviewer-agent" as const,
summary: { summary: "Needs fixes", verdict: "REVISE", reviewType: "code" },
items: [{ id: "reviewer-item-1", body: "Fix failing test", author: { login: "reviewer-agent" }, createdAt: new Date().toISOString(), summary: "Fix failing test" }],
addressing: [],
},
automationStatus: null,
emptyMessage: null,
},
summaryText: "Needs fixes · 1 review item(s)",
itemText: "Fix failing test",
},
{
task: makeTask({ id: "FN-102" }),
response: {
reviewState: {
source: "pull-request" as const,
summary: { reviewDecision: "REVIEW_REQUIRED", reviewers: [], blockingReasons: [], checks: [] },
items: [],
addressing: [],
},
automationStatus: null,
emptyMessage: null,
},
summaryText: "REVIEW_REQUIRED · 0 review item(s)",
emptyText: "No review items yet.",
},
{
task: makeTask({ id: "FN-103" }),
response: {
reviewState: {
source: "pull-request" as const,
summary: { reviewDecision: "APPROVED", reviewers: [], blockingReasons: [], checks: [] },
items: [{ id: "pr-item-1", body: "Looks good", author: { login: "octocat" }, createdAt: new Date().toISOString(), summary: "Looks good" }],
addressing: [],
},
automationStatus: null,
emptyMessage: null,
},
summaryText: "APPROVED · 1 review item(s)",
itemText: "Looks good",
},
];
apiMocks.fetchTaskReview
.mockResolvedValueOnce(cases[0].response)
.mockResolvedValueOnce(cases[1].response)
.mockResolvedValueOnce(cases[2].response)
.mockResolvedValueOnce(cases[3].response);
const { container, rerender } = render(<TaskReviewTab task={cases[0].task} addToast={vi.fn()} />);
for (const [index, testCase] of cases.entries()) {
if (index > 0) {
rerender(<TaskReviewTab task={testCase.task} addToast={vi.fn()} />);
}
expect(await screen.findByText(testCase.summaryText)).toBeInTheDocument();
expect(container.querySelector(".task-review-tab__header")).not.toBeNull();
expect(container.querySelector(".task-review-tab__summary-wrap")).not.toBeNull();
expect(container.querySelector(".task-review-tab__summary-group")).not.toBeNull();
expect(container.querySelector(".task-review-tab__actions")).not.toBeNull();
if (testCase.emptyText) {
expect(screen.getByText(testCase.emptyText)).toBeInTheDocument();
}
if (testCase.itemText) {
expect(screen.getByText(testCase.itemText)).toBeInTheDocument();
}
}
});
it("shows create PR action when in-review without prInfo and auth is available", async () => {
const onRequestCreatePr = vi.fn();
const task = makeTask({ column: "in-review", prInfo: undefined });

View File

@@ -37,6 +37,27 @@ async function loadRoadmapView(): Promise<{ default: PluginViewComponent }> {
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<string, ComponentType<{ context?: PluginDashboardViewContext }>>;
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",

View File

@@ -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}`. */

View File

@@ -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:*",

View File

@@ -0,0 +1,186 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as fusionCore from "@fusion/core";
import type { Task, TaskStore } from "@fusion/core";
const { mockResolvePrConflicts } = vi.hoisted(() => ({
mockResolvePrConflicts: vi.fn(),
}));
vi.mock("../pr-conflict-resolver.js", () => ({
resolvePrConflicts: mockResolvePrConflicts,
}));
import { prRouteCommandRunner } from "../routes/register-git-github.js";
import { createServer } from "../server.js";
import { request as performRequest } from "../test-request.js";
function createTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-001",
title: "Task",
description: "desc",
column: "in-review",
status: "in-review",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
prInfo: {
url: "https://github.com/owner/repo/pull/1",
number: 1,
status: "open",
title: "PR",
headBranch: "fusion/fn-001",
baseBranch: "main",
commentCount: 0,
},
comments: [],
...overrides,
} as Task;
}
function createStore(task: Task): TaskStore {
return {
getTask: vi.fn().mockResolvedValue(task),
listTasks: vi.fn().mockResolvedValue([]),
createTask: vi.fn(),
moveTask: vi.fn(),
updateTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
getSettings: vi.fn().mockResolvedValue({ defaultProvider: "mock", defaultModelId: "scripted" }),
updateSettings: vi.fn(),
logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
addSteeringComment: vi.fn(),
updatePrInfo: vi.fn().mockResolvedValue(undefined),
updatePrInfoByNumber: vi.fn().mockResolvedValue(undefined),
addPrInfo: vi.fn().mockResolvedValue(undefined),
removePrInfoByNumber: vi.fn().mockResolvedValue(undefined),
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/tmp/project"),
getFusionDir: vi.fn().mockReturnValue("/tmp/project/.fusion"),
getDatabase: vi.fn().mockReturnValue({
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }),
}),
getMissionStore: vi.fn().mockReturnValue({ listMissions: vi.fn().mockReturnValue([]) }),
on: vi.fn(),
off: vi.fn(),
} as unknown as TaskStore;
}
type TryRunResult = Awaited<ReturnType<typeof prRouteCommandRunner.tryRun>>;
const runQueue: Array<{ ok: true; value: string } | { ok: false; error: Error }> = [];
const tryRunQueue: TryRunResult[] = [];
function queueRunSuccess(value = "") {
runQueue.push({ ok: true, value });
}
function queueTryRunSuccess(value = "") {
tryRunQueue.push({ ok: true, stdout: value });
}
describe("POST /pr/resolve-conflicts", () => {
const originalRepoEnv = process.env.GITHUB_REPOSITORY;
beforeEach(() => {
vi.clearAllMocks();
runQueue.length = 0;
tryRunQueue.length = 0;
process.env.GITHUB_REPOSITORY = "owner/repo";
vi.spyOn(fusionCore, "getCurrentRepo").mockReturnValue({ owner: "owner", repo: "repo" });
vi.spyOn(fusionCore, "isGhAuthenticated").mockReturnValue(true);
vi.spyOn(prRouteCommandRunner, "run").mockImplementation(async () => {
const next = runQueue.shift();
if (!next) throw new Error("Unexpected run command");
if (next.ok) return next.value;
throw next.error;
});
vi.spyOn(prRouteCommandRunner, "tryRun").mockImplementation(async () => {
const next = tryRunQueue.shift();
if (!next) throw new Error("Unexpected tryRun command");
return next;
});
});
afterEach(() => {
vi.restoreAllMocks();
if (originalRepoEnv === undefined) {
delete process.env.GITHUB_REPOSITORY;
} else {
process.env.GITHUB_REPOSITORY = originalRepoEnv;
}
});
it("rejects non in-review tasks", async () => {
const app = createServer(createStore(createTask({ column: "todo", status: "todo" })));
const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/resolve-conflicts", JSON.stringify({ base: "main" }), { "content-type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("Task must be in 'in-review' column");
expect(mockResolvePrConflicts).not.toHaveBeenCalled();
});
it("returns updated preflight after successful resolution and logs the push path", async () => {
queueTryRunSuccess("main"); // resolvePrBaseRef local base check
queueTryRunSuccess("main"); // computePrPreflight base check
queueTryRunSuccess("refs/heads/fusion/fn-001\n"); // remote branch exists
queueRunSuccess("2\n"); // git rev-list --count
queueRunSuccess(""); // git merge-tree --write-tree --name-only
queueRunSuccess("abc123\tResolve conflicts\tDev\n"); // git log
queueRunSuccess("3\t1\tsrc/a.ts\n"); // git diff --numstat
queueRunSuccess("M\tsrc/a.ts\n"); // git diff --name-status
mockResolvePrConflicts.mockResolvedValue({
resolved: true,
pushed: true,
conflictedFiles: ["src/a.ts"],
message: "Resolved conflicts and pushed branch.",
});
const store = createStore(createTask());
const app = createServer(store);
const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/resolve-conflicts", JSON.stringify({ base: "main" }), { "content-type": "application/json" });
expect(response.status).toBe(200);
expect(mockResolvePrConflicts).toHaveBeenCalledWith(expect.objectContaining({
taskId: "FN-001",
baseRef: "main",
rootDir: "/tmp/project",
}));
expect(response.body.result).toMatchObject({ resolved: true, pushed: true });
expect(response.body.preflight.conflictsWithBase).toBe(false);
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "AI resolved PR conflicts", expect.stringContaining("fusion/fn-001"));
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Pushed branch after PR conflict resolution", "fusion/fn-001");
expect(tryRunQueue).toHaveLength(0);
expect(runQueue).toHaveLength(0);
});
it("returns a structured retryable error when markers remain unresolved", async () => {
queueTryRunSuccess("main"); // resolvePrBaseRef local base check
mockResolvePrConflicts.mockResolvedValue({
resolved: false,
pushed: false,
conflictedFiles: ["src/conflicted.ts"],
message: "AI conflict resolution left unresolved markers in 1 file(s).",
});
const app = createServer(createStore(createTask()));
const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/resolve-conflicts", JSON.stringify({ base: "main" }), { "content-type": "application/json" });
expect(response.status).toBe(409);
expect(response.body.error).toContain("unresolved markers");
expect(response.body.details).toMatchObject({
code: "conflict-resolution-failed",
retryable: true,
unresolvedFiles: ["src/conflicted.ts"],
head: "fusion/fn-001",
base: "main",
});
expect(tryRunQueue).toHaveLength(0);
expect(runQueue).toHaveLength(0);
});
});

View File

@@ -498,6 +498,44 @@ describe("POST /tasks/:id/retry", () => {
expect(updateCall.nextRecoveryAt).toBeNull();
});
it("clears the deadlock auto-pause when retrying an execution-failed in-review task", async () => {
const executionFailedTask = {
...FAKE_TASK_DETAIL,
column: "in-review" as const,
status: "failed",
paused: true,
pausedReason: "in-review-stall-deadlock",
mergeRetries: 0,
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "in-progress" },
{ name: "Step 2", status: "pending" },
],
};
const movedTask = { ...executionFailedTask, column: "todo" as const, status: undefined, paused: undefined, pausedReason: undefined };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(executionFailedTask);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(executionFailedTask);
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
status: null,
error: null,
paused: false,
pausedReason: null,
...buildManualRetryResetPatch(),
});
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo", { preserveProgress: true });
expect(store.logEntry).toHaveBeenCalledWith(
"KB-001",
"Retry requested from dashboard (execution failure in-review → todo, preserving progress, cleared deadlock auto-pause)",
);
});
it("retries execution-failed in-review task by moving to todo with progress preserved", async () => {
const executionFailedTask = {
...FAKE_TASK_DETAIL,
@@ -531,6 +569,44 @@ describe("POST /tasks/:id/retry", () => {
);
});
it("clears the deadlock auto-pause when retrying a merge-failed in-review task", async () => {
const mergeFailedTask = {
...FAKE_TASK_DETAIL,
column: "in-review" as const,
status: "failed",
paused: true,
pausedReason: "in-review-stall-deadlock",
mergeRetries: 3,
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "done" },
{ name: "Step 2", status: "done" },
],
};
(store.getTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(mergeFailedTask)
.mockResolvedValueOnce({ ...mergeFailedTask, paused: undefined, pausedReason: undefined, status: undefined, mergeRetries: 0 });
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(mergeFailedTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
status: null,
error: null,
paused: false,
pausedReason: null,
...buildManualRetryResetPatch({ resetMergeRetries: true }),
});
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith(
"KB-001",
"Retry requested from dashboard (in-review merge retry, mergeRetries reset, cleared deadlock auto-pause)",
);
});
it("retries merge-failed in-review task by staying in-review with mergeRetries reset", async () => {
const mergeFailedTask = {
...FAKE_TASK_DETAIL,
@@ -564,6 +640,71 @@ describe("POST /tasks/:id/retry", () => {
);
});
it("does not clear an explicit user pause when retrying in-review merge failure", async () => {
const mergeFailedTask = {
...FAKE_TASK_DETAIL,
column: "in-review" as const,
status: "failed",
paused: true,
userPaused: true,
pausedReason: "manual",
mergeRetries: 3,
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "done" },
],
};
(store.getTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(mergeFailedTask)
.mockResolvedValueOnce(mergeFailedTask);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(mergeFailedTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
const updateCall = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(updateCall).not.toHaveProperty("paused");
expect(updateCall).not.toHaveProperty("pausedReason");
expect(store.logEntry).toHaveBeenCalledWith(
"KB-001",
"Retry requested from dashboard (in-review merge retry, mergeRetries reset)",
);
});
it("does not clear unrelated automatic pauses when retrying in-review merge failure", async () => {
const mergeFailedTask = {
...FAKE_TASK_DETAIL,
column: "in-review" as const,
status: "failed",
paused: true,
pausedReason: "branch-conflict-unrecoverable",
mergeRetries: 3,
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "done" },
],
};
(store.getTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(mergeFailedTask)
.mockResolvedValueOnce(mergeFailedTask);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(mergeFailedTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
const updateCall = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(updateCall).not.toHaveProperty("paused");
expect(updateCall).not.toHaveProperty("pausedReason");
expect(store.logEntry).toHaveBeenCalledWith(
"KB-001",
"Retry requested from dashboard (in-review merge retry, mergeRetries reset)",
);
});
it("retries zero-step merge-failed in-review task with prior merge attempts by staying in-review", async () => {
const mergeFailedTask = {
...FAKE_TASK_DETAIL,

View File

@@ -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");

View File

@@ -13,6 +13,11 @@ export {
export { createSkillsAdapter, getProjectSettingsPath, type SkillsAdapter, type DiscoveredSkill, type CatalogEntry, type CatalogFetchResult, type ToggleSkillResult, type UpstreamError, type UpstreamErrorCode, type SkillContent, type SkillFileEntry } from "./skills-adapter.js";
export { GitHubClient, isPrMergeReady, type GitHubClientOptions, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type FindPrParams, type CreateIssueParams, type CreatedIssue } from "./github.js";
export { generatePrMetadata, type GeneratedPrMetadata } from "./pr-metadata-generator.js";
export {
resolvePrConflicts,
type ResolvePrConflictsInput,
type ResolvePrConflictsResult,
} from "./pr-conflict-resolver.js";
export { maybeCreateTrackingIssue, type MaybeCreateTrackingIssueDeps } from "./github-tracking.js";
export {
buildIssueSearchQueries,

View File

@@ -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

View File

@@ -0,0 +1,258 @@
import { access, mkdir, readFile, rm } from "node:fs/promises";
import { join, resolve } from "node:path";
import type { Settings, TaskStore } from "@fusion/core";
import { createResolvedAgentSession } from "@fusion/engine";
import { runGitCommand } from "./routes/resolve-diff-base.js";
const GIT_TIMEOUT_MS = 60_000;
const SESSION_PROMPT = [
"You are resolving merge conflicts for a Fusion task branch before GitHub PR creation.",
"Edit only the conflicted files in this worktree.",
"Remove every conflict marker (`<<<<<<<`, `=======`, `>>>>>>>`) and produce a coherent merged result.",
"Preserve the task branch intent while integrating the selected base branch changes.",
"Do NOT run git commands, do NOT create commits, and do NOT push.",
"When you finish, every conflicted file must be saved without conflict markers.",
].join("\n");
export interface ResolvePrConflictsInput {
taskId: string;
baseRef: string;
rootDir: string;
store: TaskStore;
settings: Settings;
}
export interface ResolvePrConflictsResult {
resolved: boolean;
pushed: boolean;
conflictedFiles: string[];
message: string;
}
function getHeadBranch(taskId: string): string {
return `fusion/${taskId.toLowerCase()}`;
}
function getDefaultSessionModel(settings: Settings): { provider: string | undefined; modelId: string | undefined } {
if (settings.defaultProviderOverride && settings.defaultModelIdOverride) {
return {
provider: settings.defaultProviderOverride,
modelId: settings.defaultModelIdOverride,
};
}
return {
provider: settings.defaultProvider,
modelId: settings.defaultModelId,
};
}
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
async function resolveUsableWorktree(candidatePath: string | undefined, branchName: string): Promise<string | null> {
if (!candidatePath) {
return null;
}
const absolutePath = resolve(candidatePath);
if (!await pathExists(absolutePath)) {
return null;
}
try {
const currentBranch = (await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], absolutePath, GIT_TIMEOUT_MS)).trim();
if (currentBranch === branchName) {
return absolutePath;
}
} catch {
return null;
}
return null;
}
async function listConflictedFiles(cwd: string): Promise<string[]> {
const output = await runGitCommand(["diff", "--name-only", "--diff-filter=U"], cwd, GIT_TIMEOUT_MS).catch(() => "");
return output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
}
async function findFilesWithConflictMarkers(rootDir: string, files: string[]): Promise<string[]> {
const conflicted: string[] = [];
for (const file of files) {
try {
const contents = await readFile(join(rootDir, file), "utf8");
if (/^<<<<<<< /m.test(contents) || /^=======$/m.test(contents) || /^>>>>>>> /m.test(contents)) {
conflicted.push(file);
}
} catch {
// Best-effort verification.
}
}
return conflicted;
}
async function abortMerge(cwd: string): Promise<void> {
try {
await runGitCommand(["merge", "--abort"], cwd, GIT_TIMEOUT_MS);
return;
} catch {
// fall through
}
try {
await runGitCommand(["reset", "--merge"], cwd, GIT_TIMEOUT_MS);
} catch {
// best-effort cleanup
}
}
async function runResolutionAgent(params: {
cwd: string;
taskId: string;
conflictedFiles: string[];
settings: Settings;
}): Promise<void> {
const { cwd, taskId, conflictedFiles, settings } = params;
const sessionModel = getDefaultSessionModel(settings);
const { session } = await createResolvedAgentSession({
cwd,
systemPrompt: SESSION_PROMPT,
tools: "coding",
sessionPurpose: "merger",
defaultProvider: sessionModel.provider,
defaultModelId: sessionModel.modelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
settings,
});
try {
await session.prompt([
`Resolve Create-PR merge conflicts for task ${taskId}.`,
"",
"Conflicted files:",
...conflictedFiles.map((file) => `- ${file}`),
"",
"Instructions:",
"1. Read each conflicted file in the current worktree.",
"2. Edit only the listed files to remove all conflict markers.",
"3. Keep the branch in a coherent post-merge state.",
"4. Do not run git commands, do not commit, and do not push.",
].join("\n"));
} finally {
try {
session.dispose();
} catch {
// ignore dispose failures
}
}
}
export async function resolvePrConflicts(input: ResolvePrConflictsInput): Promise<ResolvePrConflictsResult> {
const { taskId, baseRef, rootDir, store } = input;
const task = await store.getTask(taskId);
const branchName = getHeadBranch(taskId);
const reusableWorktree = await resolveUsableWorktree(task.worktree, branchName);
const tempWorktreePath = join(rootDir, ".fusion", "worktrees", `conflict-${taskId.toLowerCase()}`);
const cwd = reusableWorktree ?? tempWorktreePath;
const createdTemporaryWorktree = !reusableWorktree;
if (createdTemporaryWorktree) {
await mkdir(join(rootDir, ".fusion", "worktrees"), { recursive: true });
await rm(tempWorktreePath, { recursive: true, force: true });
await runGitCommand(["worktree", "add", "--force", tempWorktreePath, branchName], rootDir, GIT_TIMEOUT_MS);
}
try {
try {
await runGitCommand(["checkout", branchName], cwd, GIT_TIMEOUT_MS);
await runGitCommand(["merge", "--no-commit", "--no-ff", baseRef], cwd, GIT_TIMEOUT_MS);
} catch (error) {
const conflictedFiles = await listConflictedFiles(cwd);
if (conflictedFiles.length === 0) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to merge ${baseRef} into ${branchName}: ${message}`);
}
await store.logEntry(taskId, "Started AI PR conflict resolution", `${conflictedFiles.length} conflicted file(s)`);
try {
await runResolutionAgent({
cwd,
taskId,
conflictedFiles,
settings: input.settings,
});
const unresolvedFiles = await findFilesWithConflictMarkers(cwd, conflictedFiles);
if (unresolvedFiles.length > 0) {
await abortMerge(cwd);
await store.logEntry(
taskId,
"AI PR conflict resolution left unresolved markers",
`Merge aborted. Worktree may still contain partial AI edits for manual review: ${unresolvedFiles.join(", ")}`,
);
return {
resolved: false,
pushed: false,
conflictedFiles: unresolvedFiles,
message: `AI conflict resolution left unresolved markers in ${unresolvedFiles.length} file(s).`,
};
}
await store.logEntry(taskId, "AI PR conflict resolution completed", `${conflictedFiles.length} conflicted file(s) resolved`);
await runGitCommand(["add", "-A"], cwd, GIT_TIMEOUT_MS);
await runGitCommand([
"commit",
"-m",
`fix(FN-5949): resolve PR conflicts for ${taskId}`,
"-m",
`Fusion-Task-Id: ${taskId}`,
], cwd, GIT_TIMEOUT_MS);
await runGitCommand(["push", "-u", "origin", branchName], cwd, GIT_TIMEOUT_MS);
await store.logEntry(taskId, "Pushed PR branch after AI conflict resolution", branchName);
return {
resolved: true,
pushed: true,
conflictedFiles,
message: `Resolved conflicts with ${baseRef} and pushed ${branchName}.`,
};
} catch (resolutionError) {
await abortMerge(cwd);
throw resolutionError;
}
}
await runGitCommand(["add", "-A"], cwd, GIT_TIMEOUT_MS);
await runGitCommand([
"commit",
"-m",
`fix(FN-5949): merge ${baseRef} into ${taskId}`,
"-m",
`Fusion-Task-Id: ${taskId}`,
], cwd, GIT_TIMEOUT_MS);
await runGitCommand(["push", "-u", "origin", branchName], cwd, GIT_TIMEOUT_MS);
await store.logEntry(taskId, "Pushed PR branch after conflict-free merge", branchName);
return {
resolved: true,
pushed: true,
conflictedFiles: [],
message: `Merged ${baseRef} into ${branchName} and pushed the branch.`,
};
} finally {
if (createdTemporaryWorktree) {
try {
await runGitCommand(["worktree", "remove", "--force", tempWorktreePath], rootDir, GIT_TIMEOUT_MS);
} catch {
await rm(tempWorktreePath, { recursive: true, force: true }).catch(() => undefined);
}
}
}
}

View File

@@ -46,6 +46,7 @@ import { GitHubSourceIssueCloseService } from "../github-source-issue-close.js";
import { githubRateLimiter } from "../github-poll.js";
import * as projectStoreResolver from "../project-store-resolver.js";
import { generatePrMetadata } from "../pr-metadata-generator.js";
import { resolvePrConflicts } from "../pr-conflict-resolver.js";
import {
classifyWebhookEvent,
getGitHubAppConfig,
@@ -295,6 +296,17 @@ function parsePreflightCommits(output: string): Array<{ sha: string; subject: st
.slice(0, 50);
}
interface PrPreflightResponse {
branchOnRemote: boolean;
commitsPresent: boolean;
conflictsWithBase: boolean;
ghAuthOk: boolean;
defaultBaseBranch: string;
head: string;
commits: Array<{ sha: string; subject: string; author: string }>;
changedFiles: Array<{ path: string; additions: number; deletions: number; status: "added" | "modified" | "deleted" | "renamed" }>;
}
function parsePreflightChangedFiles(numstatOutput: string, nameStatusOutput: string): Array<{
path: string;
additions: number;
@@ -339,6 +351,73 @@ function parsePreflightChangedFiles(numstatOutput: string, nameStatusOutput: str
return results;
}
async function computePrPreflight(task: Task, repoRoot: string, requestedBase?: string): Promise<PrPreflightResponse> {
const defaultBaseBranch = requestedBase?.trim()
? ensureSafeGitRef(requestedBase, "base branch")
: await resolveDefaultPrBaseBranch(task, repoRoot);
const head = `fusion/${task.id.toLowerCase()}`;
const safeHead = ensureSafeGitRef(head, "head branch");
const response: PrPreflightResponse = {
branchOnRemote: false,
commitsPresent: false,
conflictsWithBase: false,
ghAuthOk: isGhAuthenticated(),
defaultBaseBranch,
head,
commits: [],
changedFiles: [],
};
const baseRef = await resolvePrBaseRef(repoRoot, defaultBaseBranch).catch(() => defaultBaseBranch);
const remoteBranchCheck = await prRouteCommandRunner.tryRun(
`git ls-remote --exit-code --heads origin ${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
);
if (remoteBranchCheck.ok) {
response.branchOnRemote = true;
} else if (remoteBranchCheck.code !== 2) {
response.branchOnRemote = false;
}
const commitCountOutput = await prRouteCommandRunner.run(
`git rev-list --count ${shellQuote(baseRef)}..${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => "0");
response.commitsPresent = Number.parseInt(commitCountOutput, 10) > 0;
const mergeTreeOutput = await prRouteCommandRunner.run(
`git merge-tree --write-tree --name-only ${shellQuote(baseRef)} ${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => "");
response.conflictsWithBase = mergeTreeOutput.trim().length > 0;
const [commitLogOutput, numstatOutput, nameStatusOutput] = await Promise.all([
prRouteCommandRunner.run(
`git log --no-merges ${shellQuote(baseRef)}..${shellQuote(safeHead)} --format=%H%x09%s%x09%an`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => ""),
prRouteCommandRunner.run(
`git diff --numstat ${shellQuote(baseRef)}..${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => ""),
prRouteCommandRunner.run(
`git diff --name-status ${shellQuote(baseRef)}..${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => ""),
]);
response.commits = parsePreflightCommits(commitLogOutput);
response.changedFiles = parsePreflightChangedFiles(numstatOutput, nameStatusOutput);
return response;
}
function parseGhJsonLines<T>(output: string): T[] {
return output
.split(/\r?\n/)
@@ -4611,6 +4690,77 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
}
});
/**
* POST /api/tasks/:id/pr/resolve-conflicts
* Resolve Create-PR merge conflicts on the task branch, push the branch,
* and return refreshed preflight state.
*/
router.post("/tasks/:id/pr/resolve-conflicts", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
if (task.column !== "in-review") {
throw badRequest("Task must be in 'in-review' column to resolve PR conflicts");
}
if (req.body?.base !== undefined && typeof req.body.base !== "string") {
throw badRequest("base must be a string when provided");
}
const repoRoot = scopedStore.getRootDir();
const envRepo = process.env.GITHUB_REPOSITORY?.trim();
const repoInfo = envRepo
? (() => {
const [owner = "", repo = ""] = envRepo.split("/");
return owner && repo ? { owner, repo } : null;
})()
: getCurrentRepo(repoRoot);
if (!repoInfo) {
throw badRequest("Could not determine GitHub repository. Set GITHUB_REPOSITORY env var or configure git remote.");
}
const requestedBase = typeof req.body?.base === "string" ? req.body.base.trim() : "";
const defaultBaseBranch = requestedBase || await resolveDefaultPrBaseBranch(task, repoRoot);
const baseBranch = ensureSafeGitRef(defaultBaseBranch, "base branch");
const head = ensureSafeGitRef(`fusion/${task.id.toLowerCase()}`, "head branch");
const baseRef = await resolvePrBaseRef(repoRoot, baseBranch).catch(() => baseBranch);
const result = await resolvePrConflicts({
taskId: task.id,
baseRef,
rootDir: repoRoot,
store: scopedStore,
settings: await scopedStore.getSettings(),
});
if (!result.resolved) {
throw conflict(result.message, {
code: "conflict-resolution-failed",
retryable: true,
unresolvedFiles: result.conflictedFiles,
head,
base: baseBranch,
});
}
await scopedStore.logEntry(task.id, "AI resolved PR conflicts", `${head} against ${baseRef} in ${repoInfo.owner}/${repoInfo.repo}`);
if (result.pushed) {
await scopedStore.logEntry(task.id, "Pushed branch after PR conflict resolution", head);
}
const preflight = await computePrPreflight(task, repoRoot, baseBranch);
res.json({ result, preflight });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw notFound(`Task ${req.params.id} not found`);
}
throw toPrApiError(err, "Failed to resolve PR conflicts");
}
});
/**
* POST /api/tasks/:id/pr/generate-metadata
* Generate AI PR title/body metadata for the Create PR dialog.
@@ -4648,80 +4798,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
const task = await scopedStore.getTask(req.params.id);
const repoRoot = scopedStore.getRootDir();
const requestedBase = typeof req.query.base === "string" ? req.query.base.trim() : "";
const defaultBaseBranch = requestedBase
? ensureSafeGitRef(requestedBase, "base branch")
: await resolveDefaultPrBaseBranch(task, repoRoot);
const head = `fusion/${task.id.toLowerCase()}`;
const safeHead = ensureSafeGitRef(head, "head branch");
const response: {
branchOnRemote: boolean;
commitsPresent: boolean;
conflictsWithBase: boolean;
ghAuthOk: boolean;
defaultBaseBranch: string;
head: string;
commits: Array<{ sha: string; subject: string; author: string }>;
changedFiles: Array<{ path: string; additions: number; deletions: number; status: "added" | "modified" | "deleted" | "renamed" }>;
} = {
branchOnRemote: false,
commitsPresent: false,
conflictsWithBase: false,
ghAuthOk: isGhAuthenticated(),
defaultBaseBranch,
head,
commits: [],
changedFiles: [],
};
const baseRef = await resolvePrBaseRef(repoRoot, defaultBaseBranch).catch(() => defaultBaseBranch);
const remoteBranchCheck = await prRouteCommandRunner.tryRun(
`git ls-remote --exit-code --heads origin ${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
);
if (remoteBranchCheck.ok) {
response.branchOnRemote = true;
} else if (remoteBranchCheck.code !== 2) {
response.branchOnRemote = false;
}
const commitCountOutput = await prRouteCommandRunner.run(
`git rev-list --count ${shellQuote(baseRef)}..${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => "0");
response.commitsPresent = Number.parseInt(commitCountOutput, 10) > 0;
const mergeTreeOutput = await prRouteCommandRunner.run(
`git merge-tree --write-tree --name-only ${shellQuote(baseRef)} ${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => "");
response.conflictsWithBase = mergeTreeOutput.trim().length > 0;
const [commitLogOutput, numstatOutput, nameStatusOutput] = await Promise.all([
prRouteCommandRunner.run(
`git log --no-merges ${shellQuote(baseRef)}..${shellQuote(safeHead)} --format=%H%x09%s%x09%an`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => ""),
prRouteCommandRunner.run(
`git diff --numstat ${shellQuote(baseRef)}..${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => ""),
prRouteCommandRunner.run(
`git diff --name-status ${shellQuote(baseRef)}..${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => ""),
]);
response.commits = parsePreflightCommits(commitLogOutput);
response.changedFiles = parsePreflightChangedFiles(numstatOutput, nameStatusOutput);
res.json(response);
res.json(await computePrPreflight(task, repoRoot, requestedBase));
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;

View File

@@ -29,6 +29,7 @@ import {
findDuplicateMatches,
deterministicGuardLocks,
runDeterministicDuplicateGuard,
buildAutoPauseClearPatch,
buildManualRetryResetPatch,
reconcileDeterministicDuplicate,
extractIntentSignature,
@@ -1390,6 +1391,10 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
throw badRequest(`Task is not in a retryable state (current status: ${task.status || 'none'})`);
}
const autoPauseClearPatch = buildAutoPauseClearPatch(task);
const clearedDeadlockAutoPause = Object.keys(autoPauseClearPatch).length > 0;
const retryLogSuffix = clearedDeadlockAutoPause ? ", cleared deadlock auto-pause" : "";
// In-review retry: distinguish between execution failures (incomplete steps)
// and merge failures (all steps done).
if (isInReviewRetry) {
@@ -1405,11 +1410,12 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
await scopedStore.updateTask(req.params.id, {
status: null,
error: null,
...autoPauseClearPatch,
...buildManualRetryResetPatch(),
});
await scopedStore.logEntry(
req.params.id,
"Retry requested from dashboard (execution failure in-review → todo, preserving progress)",
`Retry requested from dashboard (execution failure in-review → todo, preserving progress${retryLogSuffix})`,
);
const updated = await scopedStore.moveTask(req.params.id, "todo", { preserveProgress: true });
res.json(updated);
@@ -1419,9 +1425,10 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
await scopedStore.updateTask(req.params.id, {
status: null,
error: null,
...autoPauseClearPatch,
...buildManualRetryResetPatch({ resetMergeRetries: true }),
});
await scopedStore.logEntry(req.params.id, "Retry requested from dashboard (in-review merge retry, mergeRetries reset)");
await scopedStore.logEntry(req.params.id, `Retry requested from dashboard (in-review merge retry, mergeRetries reset${retryLogSuffix})`);
const updated = await scopedStore.getTask(req.params.id);
res.json(updated);
return;
@@ -1434,6 +1441,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
branch: null,
baseBranch: null,
baseCommitSha: null,
...autoPauseClearPatch,
...buildManualRetryResetPatch({ resetMergeRetries: true }),
});

View File

@@ -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<PluginCustomSseListener>();
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,

View File

@@ -210,7 +210,7 @@ const qualityAppSettingsOnlyTests = ["app/components/__tests__/SettingsModal.tes
const qualityApiTests = [
// Critical HTTP/server behavior: auth, task/project/settings mutation,
// git/GitHub, agents, nodes, chat/files, realtime, and isolation guards.
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,pr-routes-auto-merge,pr-routes.contract,project-routes,project-store-resolver,register-git-github.pr-options-preflight-metadata,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-branch-groups,routes-git,routes-github,routes-merge-advance-push-origin,routes-nodes,routes-nodes-sync-contract,routes-planning,routes-secrets-sync,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-deterministic-dedup,routes-tasks-duplicate-check,routes-tasks-explicit-duplicate-marker,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket,recover-branch-binding-route}.test.ts",
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,pr-routes-auto-merge,pr-routes.contract,project-routes,project-store-resolver,register-git-github.pr-options-preflight-metadata,register-git-github.pr-resolve-conflicts,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-branch-groups,routes-git,routes-github,routes-merge-advance-push-origin,routes-nodes,routes-nodes-sync-contract,routes-planning,routes-secrets-sync,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-deterministic-dedup,routes-tasks-duplicate-check,routes-tasks-explicit-duplicate-marker,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket,recover-branch-binding-route}.test.ts",
"src/__tests__/dashboard-test-config-guard.test.ts",
"src/routes/__tests__/{custom-provider-routes,custom-providers,register-docker-node-routes,register-diagnostics-routes,stash-recovery-routes}.test.ts",
"scripts/__tests__/run-vitest-with-heap.test.ts",

View File

@@ -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");
});
});

View File

@@ -69,6 +69,7 @@ describe("goal injection diagnostics wiring seam", () => {
for (const lane of lanes) {
const store = {
getGoalStore: () => ({ listGoals: () => [goal("G-1", "one", "2026-01-01T00:00:00.000Z")] }),
getMissionStore: () => ({ listGoalIdsForTask: () => ["G-PROV-1", "G-PROV-2"] }),
logEntry: vi.fn().mockResolvedValue(undefined),
recordRunAuditEvent: vi.fn().mockResolvedValue(undefined),
} as any;
@@ -86,12 +87,17 @@ describe("goal injection diagnostics wiring seam", () => {
expect(audit.database).toHaveBeenCalledTimes(1);
expect(audit.database.mock.calls[0][0].metadata.lane).toBe(lane);
expect(store.recordRunAuditEvent).toHaveBeenCalledTimes(1);
expect(store.recordRunAuditEvent.mock.calls[0][0].metadata.lane).toBe(lane);
expect(store.recordRunAuditEvent.mock.calls[0][0].metadata).toMatchObject({
lane,
provenanceGoalIds: ["G-PROV-1", "G-PROV-2"],
});
expect(store.logEntry.mock.calls[0][1]).toContain('provenance=["G-PROV-1","G-PROV-2"]');
}
});
it("resolveAndEmitGoalContext handles missing getGoalStore with disabled classification", async () => {
const store = {
getMissionStore: () => ({ listGoalIdsForTask: () => [] }),
logEntry: vi.fn().mockResolvedValue(undefined),
recordRunAuditEvent: vi.fn().mockResolvedValue(undefined),
} as any;
@@ -109,6 +115,7 @@ describe("goal injection diagnostics wiring seam", () => {
expect(resolution.classification).toMatchObject({ outcome: "disabled-or-failed", reason: "store-unavailable" });
expect(audit.database).toHaveBeenCalledTimes(1);
expect(store.recordRunAuditEvent).toHaveBeenCalledTimes(1);
expect(store.recordRunAuditEvent.mock.calls[0][0].metadata).toMatchObject({ provenanceGoalIds: [] });
});
it("emits applied audit metadata for positive injection", async () => {
const goals = [goal("G-1", "one", "2026-01-01T00:00:00.000Z"), goal("G-2", "two", "2026-01-02T00:00:00.000Z")];
@@ -151,6 +158,32 @@ describe("goal injection diagnostics wiring seam", () => {
expect(recordRunAuditEvent.mock.calls[0][0].metadata).toMatchObject({ outcome: "no-goals", goalCount: 0, goalIds: [] });
});
it("fails soft when provenance resolution throws", async () => {
const store = {
getGoalStore: () => ({ listGoals: () => [goal("G-1", "one", "2026-01-01T00:00:00.000Z")] }),
getMissionStore: () => ({
listGoalIdsForTask: () => {
throw new Error("boom");
},
}),
logEntry: vi.fn().mockResolvedValue(undefined),
recordRunAuditEvent: vi.fn().mockResolvedValue(undefined),
} as any;
const audit = { database: vi.fn().mockResolvedValue(undefined) } as any;
await expect(resolveAndEmitGoalContext({
lane: "executor",
store,
audit,
taskId: "FN-1",
runContext: { runId: "exec-run", agentId: "agent-1", taskId: "FN-1", phase: "execute" },
})).resolves.toMatchObject({
classification: { outcome: "applied", goalIds: ["G-1"] },
});
expect(store.recordRunAuditEvent.mock.calls[0][0].metadata).toMatchObject({ provenanceGoalIds: [] });
});
it("classifies list failure and keeps prompt construction alive", async () => {
const recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
const store = { logEntry: vi.fn().mockResolvedValue(undefined), recordRunAuditEvent } as any;

View File

@@ -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<InteractiveAgentResult> {
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");
});
});

View File

@@ -556,6 +556,87 @@ describe("MissionExecutionLoop", () => {
});
});
describe("recoverActiveMissions stranded done features", () => {
function wireHierarchy(slice: Slice, features: MissionFeature[]) {
missionStore.getMissionWithHierarchy = vi.fn((id: string) => {
const mission = missionStore.getMission(id);
if (!mission) return undefined;
return {
...mission,
milestones: [
{
...createMockMilestone({ missionId: id }),
slices: [{ ...slice, features }],
},
],
};
}) as any;
}
it("re-validates a done feature stranded in 'implementing' with no linked task", async () => {
// Regression: a feature marked "done" whose loopState never left
// "implementing" (and which was never validated and has no board task)
// can never validate on its own — the prior recovery loop only re-drove
// implementing features that still had a taskId. The slice-completion
// gate then refuses to count it, wedging the whole mission. Recovery
// must re-drive validation so the slice can eventually complete.
const mission = createMockMission({ id: "M-STRAND", status: "active" });
missionStore._setMission(mission);
const slice = createMockSlice({ id: "SL-STRAND", milestoneId: "MS-001", status: "active" });
const orphan = createMockFeature({
id: "F-STRAND",
sliceId: "SL-STRAND",
status: "done",
loopState: "implementing",
lastValidatorStatus: undefined,
taskId: undefined,
});
(missionStore as any)._addFeatureWithManagedAssertion(orphan);
wireHierarchy(slice, [missionStore.getFeature("F-STRAND") as MissionFeature]);
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
const result = await loop.recoverActiveMissions();
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-STRAND", "task_completion");
expect(result.recoveredCount).toBeGreaterThanOrEqual(1);
});
it("leaves an already-validated done feature untouched", async () => {
const mission = createMockMission({ id: "M-OK", status: "active" });
missionStore._setMission(mission);
const slice = createMockSlice({ id: "SL-OK", milestoneId: "MS-001", status: "active" });
const validated = createMockFeature({
id: "F-OK",
sliceId: "SL-OK",
status: "done",
loopState: "passed",
lastValidatorStatus: "passed",
taskId: undefined,
});
(missionStore as any)._addFeatureWithManagedAssertion(validated);
wireHierarchy(slice, [missionStore.getFeature("F-OK") as MissionFeature]);
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
await loop.recoverActiveMissions();
expect(missionStore.startValidatorRun).not.toHaveBeenCalled();
});
});
describe("reapStaleValidatorRuns", () => {
it("reaps stale runs across trigger types and records audit metadata", async () => {
vi.useFakeTimers();

View File

@@ -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);
}
});
});
});

View File

@@ -22,6 +22,7 @@ export interface GoalInjectionDiagnostic {
outcome: GoalInjectionOutcome;
goalCount: number;
goalIds: string[];
provenanceGoalIds: string[];
truncated: boolean;
reason?: GoalInjectionDisabledReason;
errorClass?: string;
@@ -31,7 +32,8 @@ export interface GoalInjectionDiagnostic {
timestamp: string;
}
export interface GoalInjectionDiagnosticInput extends Omit<GoalInjectionDiagnostic, "timestamp"> {
export interface GoalInjectionDiagnosticInput extends Omit<GoalInjectionDiagnostic, "timestamp" | "provenanceGoalIds"> {
provenanceGoalIds?: string[];
store?: TaskStore;
runContext?: EngineRunContext | null;
}
@@ -140,6 +142,17 @@ export async function resolveAndEmitGoalContext(input: ResolveAndEmitGoalContext
: undefined,
});
let provenanceGoalIds: string[] = [];
if (input.taskId && typeof input.store.getMissionStore === "function") {
try {
provenanceGoalIds = input.store.getMissionStore().listGoalIdsForTask(input.taskId);
} catch (error) {
diagnosticsLog.warn(
`failed to resolve goal provenance for task ${input.taskId} in ${input.lane}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
await emitGoalAnchoringAudit(input.audit, {
lane: input.lane,
taskId: input.taskId,
@@ -152,6 +165,7 @@ export async function resolveAndEmitGoalContext(input: ResolveAndEmitGoalContext
await emitGoalInjectionDiagnostic({
lane: input.lane,
...resolution.classification,
provenanceGoalIds,
runId: input.runContext?.runId,
agentId: input.runContext?.agentId,
taskId: input.taskId,
@@ -164,9 +178,10 @@ export async function resolveAndEmitGoalContext(input: ResolveAndEmitGoalContext
function formatAgentLogLine(input: GoalInjectionDiagnostic): string {
const ids = JSON.stringify(input.goalIds);
const provenanceIds = JSON.stringify(input.provenanceGoalIds);
const reason = input.reason ? ` reason=${input.reason}` : "";
const errorClass = input.errorClass ? ` err=${input.errorClass}` : "";
return `[goal-injection] ${input.outcome} count=${input.goalCount} ids=${ids} truncated=${String(input.truncated)}${reason}${errorClass}`;
return `[goal-injection] ${input.outcome} count=${input.goalCount} ids=${ids} provenance=${provenanceIds} truncated=${String(input.truncated)}${reason}${errorClass}`;
}
/**
@@ -191,6 +206,7 @@ export async function emitGoalInjectionDiagnostic(
outcome: input.outcome,
goalCount: input.goalCount,
goalIds: [...input.goalIds],
provenanceGoalIds: [...(input.provenanceGoalIds ?? [])],
truncated: input.truncated,
...(input.reason ? { reason: input.reason } : {}),
...(input.errorClass ? { errorClass: input.errorClass } : {}),
@@ -232,6 +248,7 @@ export async function emitGoalInjectionDiagnostic(
outcome: record.outcome,
goalCount: record.goalCount,
goalIds: record.goalIds,
provenanceGoalIds: record.provenanceGoalIds,
truncated: record.truncated,
...(record.reason ? { reason: record.reason } : {}),
...(record.errorClass ? { errorClass: record.errorClass } : {}),

View File

@@ -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<AiSessionResult> => {
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.

View File

@@ -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<void>;
state: {
messages: Array<{
role: string;
content?: string | Array<{ type: string; text?: string; thinking?: string }>;
}>;
};
dispose?: () => void | Promise<void>;
}
/** 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<InteractiveAgentResult>;
/** 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<CreateInteractiveAiSessionResult> {
const agentResult = await agentFactory(options);
const agent = agentResult.session;
let state: LoopState = "idle";
let pendingEvent: Promise<InteractiveAiSessionEvent> | 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<InteractiveAiSessionEvent> {
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<void> {
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<InteractiveAiSessionEvent> {
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<void> {
if (terminalEvent) return;
if (state !== "awaiting_input") {
pendingEvent = Promise.resolve<InteractiveAiSessionEvent>({
type: "error",
data: { message: "answer() called while not awaiting input." },
});
return;
}
if (currentQuestion && questionId !== currentQuestion.id) {
pendingEvent = Promise.resolve<InteractiveAiSessionEvent>({
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 };
}

View File

@@ -293,6 +293,42 @@ export class MissionExecutionLoop extends EventEmitter {
loopLog.error(`Recovery failed for implementing feature ${feature.id}:`, err);
}
}
// Features marked "done" but stranded in "implementing" with no
// linked task can never validate on their own: the branches above
// only re-drive features that still carry a taskId. Meanwhile the
// slice-completion gate (MissionStore.computeSliceStatus) refuses
// to count an assertion-linked "done" feature until its validator
// passes — so the slice, milestone, and mission can never
// auto-progress. Re-drive validation directly so the gate can
// resolve. Validation is a read-only judge (no board task, no code
// changes); on pass the feature becomes legitimately complete, on
// fail the normal fix-feature flow takes over.
if (
feature.loopState === "implementing"
&& !feature.taskId
&& feature.status === "done"
&& feature.lastValidatorStatus !== "passed"
&& !this.activeValidations.has(feature.id)
) {
const currentFeature = this.missionStore.getFeature(feature.id) ?? feature;
if (
currentFeature.loopState === "passed"
|| currentFeature.lastValidatorStatus === "passed"
) {
continue;
}
try {
loopLog.warn(
`Recovery: re-validating stranded "done" feature ${feature.id} `
+ `(loopState=${feature.loopState}, no linked task) so its slice can complete`,
);
recoveredCount++;
await this.runFeatureValidation(currentFeature);
} catch (err) {
loopLog.error(`Recovery failed for stranded done feature ${feature.id}:`, err);
}
}
}
}
}
@@ -354,46 +390,59 @@ export class MissionExecutionLoop extends EventEmitter {
return;
}
// Lazily guarantee a linked assertion before validation so every feature
// is evaluated by the validator even when legacy data is missing links.
let assertions = this.missionStore.listAssertionsForFeature(feature.id);
if (assertions.length === 0) {
loopLog.log(`Feature ${feature.id} has no linked assertions; lazily ensuring store-managed assertion linkage`);
assertions = this.missionStore.ensureFeatureAssertionLinked(feature.id);
}
// Mark feature as being validated
this.activeValidations.add(feature.id);
try {
loopLog.log(`Running internal validation for feature ${feature.id} — no board task created (policy: docs/missions.md)`);
// Start the validator run (no board task per docs/missions.md)
const run = this.missionStore.startValidatorRun(feature.id, "task_completion");
loopLog.log(`Started validator run ${run.id} for feature ${feature.id}`);
// Run the validation
const result = await this.runValidation(feature, assertions, run);
// Handle the result
if (result.status === "pass") {
await this.handleValidationPass(feature.id, run.id, result.summary);
} else if (result.status === "fail") {
await this.handleValidationFail(feature.id, run.id, result);
} else if (result.status === "blocked") {
await this.handleValidationBlocked(feature.id, run.id, result.blockedReason);
} else if (result.status === "error") {
await this.handleValidationError(feature.id, run.id, result.summary);
}
} finally {
this.activeValidations.delete(feature.id);
}
await this.runFeatureValidation(feature);
} catch (err) {
loopLog.error(`Error processing task outcome for ${taskId}:`, err);
// Don't crash the loop - log and continue
}
}
/**
* Run assertion validation for a feature and apply the outcome.
*
* Shared by processTaskOutcome (task-triggered) and recoverActiveMissions
* (self-healing for features stranded mid-loop with no board task). Callers
* are responsible for confirming the feature is eligible to validate; this
* method handles lazy assertion linkage, validator run bookkeeping, and
* dispatch of the validation result.
*/
private async runFeatureValidation(feature: MissionFeature): Promise<void> {
// Lazily guarantee a linked assertion before validation so every feature
// is evaluated by the validator even when legacy data is missing links.
let assertions = this.missionStore.listAssertionsForFeature(feature.id);
if (assertions.length === 0) {
loopLog.log(`Feature ${feature.id} has no linked assertions; lazily ensuring store-managed assertion linkage`);
assertions = this.missionStore.ensureFeatureAssertionLinked(feature.id);
}
// Mark feature as being validated
this.activeValidations.add(feature.id);
try {
loopLog.log(`Running internal validation for feature ${feature.id} — no board task created (policy: docs/missions.md)`);
// Start the validator run (no board task per docs/missions.md)
const run = this.missionStore.startValidatorRun(feature.id, "task_completion");
loopLog.log(`Started validator run ${run.id} for feature ${feature.id}`);
// Run the validation
const result = await this.runValidation(feature, assertions, run);
// Handle the result
if (result.status === "pass") {
await this.handleValidationPass(feature.id, run.id, result.summary);
} else if (result.status === "fail") {
await this.handleValidationFail(feature.id, run.id, result);
} else if (result.status === "blocked") {
await this.handleValidationBlocked(feature.id, run.id, result.blockedReason);
} else if (result.status === "error") {
await this.handleValidationError(feature.id, run.id, result.summary);
}
} finally {
this.activeValidations.delete(feature.id);
}
}
/**
* Run the validation AI session for a feature.
*

View File

@@ -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 `<id>/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<AgentResult>
? [options.systemPromptLayers.dynamic]
: [],
...(effectiveExtensionPaths.length > 0 ? { additionalExtensionPaths: [...effectiveExtensionPaths] } : {}),
...(options.additionalSkillPaths && options.additionalSkillPaths.length > 0
? { additionalSkillPaths: [...options.additionalSkillPaths] }
: {}),
...(skillsOverrideFn ? { skillsOverride: skillsOverrideFn } : {}),
});
await resourceLoader.reload();

View File

@@ -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/

View File

@@ -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/<skillId>/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.

View File

@@ -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
}
}
}

View File

@@ -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"
}
}

View File

@@ -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<InteractiveAiSessionEvent>(() => undefined)),
dispose: vi.fn(),
};
}

View File

@@ -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,
);
});
});

View File

@@ -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);
});
});

View File

@@ -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<InteractiveAiSessionEvent> => {
if (cursor === 0) return { type: "question", data: QUESTION };
// turn 2+ hangs forever
return new Promise<InteractiveAiSessionEvent>(() => 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);
});
});

View File

@@ -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<T>() {
let resolve!: (v: T) => void;
const promise = new Promise<T>((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<InteractiveAiSessionEvent>) {
const captured: { progress?: (e: InteractiveAiSessionProgressEvent) => void; dispose: ReturnType<typeof vi.fn> } = {
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<InteractiveAiSessionEvent>();
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<InteractiveAiSessionEvent>(() => 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);
});
});

View File

@@ -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<PluginRouteResponse> {
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);
});
});

View File

@@ -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();
});
});

View File

@@ -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<string, unknown>;
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);
});
});

View File

@@ -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);
}
});
});

View File

@@ -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);
});
});

View File

@@ -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 `<skillId>/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");
});
});

View File

@@ -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$/);
});
});

View File

@@ -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<void> {
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<string, unknown>;
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<string, unknown>).ceStageId).toBe("work");
});
});

View File

@@ -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<string, unknown> | 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<string, unknown>)?.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);
});
});

View File

@@ -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<typeof realFs>();
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();
});
});

View File

@@ -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<typeof statSync> | 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<typeof statSync>;
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,
};
}

View File

@@ -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;
}
}

View File

@@ -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";

View File

@@ -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<string, unknown> | undefined {
if (!text.startsWith("{")) return undefined;
try {
const parsed: unknown = JSON.parse(text);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: 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<string, PlanningQuestion>();
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<string, unknown>;
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 (
<div
className={`ce-flow-activity${live ? " is-live" : ""}`}
data-testid={live ? "ce-flow-live-activity" : "ce-flow-activity-trace"}
>
{turns.map((t, i) =>
t.kind === "tool" ? (
<div
key={i}
className={`ce-activity-tool${t.isError ? " is-error" : t.done ? " is-done" : " is-running"}`}
data-testid="ce-activity-tool"
>
<span className="ce-activity-tool-marker">{t.isError ? "✗" : t.done ? "✓" : "▸"}</span> {t.text}
</div>
) : (
<pre key={i} className={`ce-activity-block ce-activity-${t.kind}`} data-kind={t.kind}>
{t.text}
</pre>
),
)}
</div>
);
}
/** 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 (
<ol className="ce-flow-transcript" data-testid="ce-flow-transcript">
{items.map((item, i) => {
switch (item.kind) {
case "chat":
return (
<li key={i} className={`ce-flow-turn ce-flow-turn-${item.role}`} data-role={item.role}>
<span className="ce-flow-turn-role">{item.role === "agent" ? "Agent" : "You"}</span>
<span className="ce-flow-turn-text">{item.text}</span>
</li>
);
case "qa-question":
return (
<li key={i} className="ce-flow-turn ce-flow-turn-agent ce-flow-turn-question" data-testid="ce-flow-past-question">
<span className="ce-flow-turn-role">Agent asked</span>
<span className="ce-flow-turn-text">{item.question.question}</span>
</li>
);
case "qa-answer": {
const a = formatAnswer(item.response, item.question);
return (
<li
key={i}
className={`ce-flow-turn ce-flow-turn-user ce-flow-turn-answer${a.feedbackOnly ? " is-steering" : ""}`}
data-testid="ce-flow-past-answer"
>
<span className="ce-flow-turn-role">{a.feedbackOnly ? "You steered" : "You answered"}</span>
<span className="ce-flow-turn-text">{a.main}</span>
{a.comment ? (
<span className="ce-flow-turn-comment" data-testid="ce-flow-answer-comment">
{a.comment}
</span>
) : null}
</li>
);
}
case "activity":
return (
<li key={i} className="ce-flow-turn ce-flow-turn-agent ce-flow-turn-activity">
<details className="ce-flow-activity-details" data-testid="ce-flow-activity">
<summary>Agent work ({item.turns.length} step{item.turns.length === 1 ? "" : "s"})</summary>
<ActivityTrace turns={item.turns} />
</details>
</li>
);
case "complete":
return (
<li key={i} className="ce-flow-turn ce-flow-turn-agent ce-flow-turn-done">
<span className="ce-flow-turn-text">✓ Stage complete</span>
</li>
);
}
})}
</ol>
);
}
// ── 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<string[]>([]);
const submit = (response: unknown) => onAnswer(question.id, response);
return (
<div className="ce-flow-question" data-testid="ce-flow-question" data-qtype={question.type}>
<p className="ce-flow-question-text">{question.question}</p>
{question.description ? <p className="ce-flow-question-desc">{question.description}</p> : null}
{question.type === "text" ? (
<form
className="ce-flow-text"
onSubmit={(e) => {
e.preventDefault();
if (text.trim()) submit(text.trim());
}}
>
<textarea
data-testid="ce-flow-text-input"
aria-label={question.question}
value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)}
rows={3}
/>
<button type="submit" className="btn btn-primary" disabled={disabled || !text.trim()}>
Send
</button>
</form>
) : null}
{question.type === "confirm" ? (
<div className="ce-flow-confirm">
<button
type="button"
className="btn btn-primary"
data-testid="ce-flow-confirm-yes"
disabled={disabled}
onClick={() => submit(true)}
>
Yes
</button>
<button
type="button"
className="btn"
data-testid="ce-flow-confirm-no"
disabled={disabled}
onClick={() => submit(false)}
>
No
</button>
</div>
) : null}
{question.type === "single_select" ? (
<ul className="ce-flow-options" data-testid="ce-flow-single">
{(question.options ?? []).map((opt) => (
<li key={opt.id}>
<button
type="button"
className="ce-flow-option btn"
data-option={opt.id}
disabled={disabled}
onClick={() => submit(opt.id)}
>
<span className="ce-flow-option-label">{opt.label}</span>
{opt.description ? <span className="ce-flow-option-desc">{opt.description}</span> : null}
</button>
</li>
))}
</ul>
) : null}
{question.type === "multi_select" ? (
<form
className="ce-flow-options"
data-testid="ce-flow-multi"
onSubmit={(e) => {
e.preventDefault();
submit(multi);
}}
>
<ul>
{(question.options ?? []).map((opt) => {
const checked = multi.includes(opt.id);
return (
<li key={opt.id}>
<label className="ce-flow-checkbox">
<input
type="checkbox"
data-option={opt.id}
checked={checked}
disabled={disabled}
onChange={(e) =>
setMulti((prev) =>
e.target.checked ? [...prev, opt.id] : prev.filter((id) => id !== opt.id),
)
}
/>
<span className="ce-flow-option-label">{opt.label}</span>
</label>
</li>
);
})}
</ul>
<button type="submit" className="btn btn-primary" data-testid="ce-flow-multi-submit" disabled={disabled}>
Confirm selection
</button>
</form>
) : null}
</div>
);
}
/**
* Degraded chat fallback (R8/AE1). Used when a question can't be expressed by
* the rich renderer. Visibly marked as degraded; the stage is still completable
* because the user can answer in free text, which is submitted back through the
* same answer route.
*/
function DegradedQuestion({
question,
disabled,
onAnswer,
}: {
question: PlanningQuestion;
disabled: boolean;
onAnswer: (questionId: string, response: unknown) => void;
}) {
const [text, setText] = useState("");
return (
<div className="ce-flow-question ce-flow-degraded" data-testid="ce-flow-degraded" data-qtype={question.type}>
<p className="ce-flow-degraded-banner" role="status" data-testid="ce-flow-degraded-banner">
⚠ Chat fallback — this prompt can&apos;t be shown as buttons here. Answer in your own words below.
</p>
<p className="ce-flow-question-text">{question.question}</p>
{question.description ? <p className="ce-flow-question-desc">{question.description}</p> : null}
{Array.isArray(question.options) && question.options.length > 0 ? (
<ul className="ce-flow-degraded-options">
{question.options.map((opt) => (
<li key={opt.id}>{opt.label}</li>
))}
</ul>
) : null}
<form
className="ce-flow-text"
onSubmit={(e) => {
e.preventDefault();
if (text.trim()) onAnswer(question.id, text.trim());
}}
>
<textarea
data-testid="ce-flow-degraded-input"
aria-label={question.question}
value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)}
rows={3}
/>
<button type="submit" className="btn btn-primary" disabled={disabled || !text.trim()}>
Send
</button>
</form>
</div>
);
}
/**
* Question panel with steering. Wraps the rich/degraded renderer and adds the
* guidance channel for selectable questions:
* - guidance typed + an option clicked → `{value, comment}` (answer + steer),
* - guidance typed + "Send guidance" → `{feedback}` (steer without answering).
* Free-text questions skip the extra box — their answer field already takes
* the user's own words.
*/
function QuestionPanel({
question,
disabled,
onAnswer,
}: {
question: PlanningQuestion;
disabled: boolean;
onAnswer: (questionId: string, response: unknown) => void;
}) {
const [guidance, setGuidance] = useState("");
const rich = canRenderRichly(question);
const submitWithGuidance = (questionId: string, response: unknown) => {
const comment = guidance.trim();
onAnswer(questionId, comment ? { value: response, comment } : response);
setGuidance("");
};
const sendGuidanceOnly = () => {
const feedback = guidance.trim();
if (!feedback) return;
onAnswer(question.id, { feedback });
setGuidance("");
};
const showGuidance = rich && question.type !== "text";
return (
<div className="ce-flow-question-panel">
{rich ? (
<RichQuestion question={question} disabled={disabled} onAnswer={submitWithGuidance} />
) : (
<DegradedQuestion question={question} disabled={disabled} onAnswer={onAnswer} />
)}
{showGuidance ? (
<div className="ce-flow-guidance" data-testid="ce-flow-guidance">
<label className="ce-flow-guidance-label" htmlFor="ce-flow-guidance-input">
Steer in your own words (optional — attached to your answer, or sent on its own)
</label>
<div className="ce-flow-guidance-row">
<textarea
id="ce-flow-guidance-input"
data-testid="ce-flow-guidance-input"
value={guidance}
disabled={disabled}
onChange={(e) => setGuidance(e.target.value)}
rows={2}
placeholder="e.g. focus on the mobile flow, skip auth for now…"
/>
<button
type="button"
className="btn"
data-testid="ce-flow-guidance-send"
disabled={disabled || !guidance.trim()}
onClick={sendGuidanceOnly}
>
Send guidance
</button>
</div>
</div>
) : null}
</div>
);
}
// ── Flow surface ─────────────────────────────────────────────────────────────
export function CeFlow(props: CeFlowProps) {
const { session, busy, error, onAnswer, onResume, onClose } = props;
const question = session?.currentQuestion ?? undefined;
if (!session) {
return (
<div className="ce-flow card" data-testid="ce-flow-empty">
<p>No active session.</p>
{onClose ? (
<button type="button" className="btn" onClick={onClose}>
Back
</button>
) : null}
</div>
);
}
const status = session.status;
const settledTerminal = status === "completed";
const recoverable = status === "interrupted" || status === "error";
const working = status === "active" || status === "launching";
return (
<div className="ce-flow card" data-testid="ce-flow" data-status={status} data-stage={session.stage}>
<header className="ce-flow-header">
<h3>{session.stage}</h3>
<span className="ce-flow-status" data-testid="ce-flow-status">
{status.replace("_", " ")}
</span>
{onClose ? (
<button type="button" className="btn ce-flow-close" onClick={onClose}>
Close
</button>
) : null}
</header>
<Transcript history={session.conversationHistory} />
{working || (busy && status !== "awaiting_input") ? (
<div className="ce-flow-working" data-testid="ce-flow-thinking">
<p className="ce-flow-working-label">
<span className="ce-flow-pulse" aria-hidden="true" />
Agent working…
</p>
{session.liveActivity && session.liveActivity.length > 0 ? (
<ActivityTrace turns={session.liveActivity} live />
) : null}
</div>
) : null}
{error ? (
<p className="ce-flow-error" role="alert" data-testid="ce-flow-error">
{error}
</p>
) : null}
{status === "awaiting_input" && question ? (
<QuestionPanel question={question} disabled={Boolean(busy)} onAnswer={onAnswer} />
) : null}
{recoverable ? (
<div className="ce-flow-recover" data-testid="ce-flow-recover">
<p className="ce-flow-error" role="alert">
Session {status}{session.error ? `: ${session.error}` : ""}.
</p>
{onResume ? (
<button type="button" className="btn btn-primary" data-testid="ce-flow-resume" onClick={onResume} disabled={Boolean(busy)}>
Resume
</button>
) : null}
</div>
) : null}
{settledTerminal ? (
<div className="ce-flow-complete" data-testid="ce-flow-complete">
<p>Stage complete.</p>
{session.artifactPath ? (
<p className="ce-flow-artifact-path" data-testid="ce-flow-artifact-path">
Artifact: {session.artifactPath}
</p>
) : null}
</div>
) : null}
</div>
);
}
export default CeFlow;

View File

@@ -0,0 +1,519 @@
.ce-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem 1.25rem;
height: 100%;
overflow: auto;
}
.ce-view-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
.ce-view-header h2 {
margin: 0;
}
.ce-view-summary {
font-size: 0.8rem;
opacity: 0.7;
}
.ce-loading,
.ce-view-error {
padding: 0.75rem 1rem;
font-size: 0.85rem;
}
.ce-view-error {
color: var(--color-danger, #d23);
}
.ce-empty {
max-width: 36rem;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: flex-start;
}
.ce-empty h3 {
margin: 0;
}
.ce-empty-hint {
opacity: 0.7;
font-size: 0.85rem;
}
.ce-groups {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
gap: 1rem;
}
.ce-group {
border: 1px solid var(--color-border, rgba(128, 128, 128, 0.25));
border-radius: 8px;
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.ce-group[data-empty="true"] {
opacity: 0.65;
}
.ce-group-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.ce-group-header h3 {
margin: 0;
font-size: 0.95rem;
}
.ce-group-count {
font-size: 0.75rem;
opacity: 0.6;
}
.ce-group-empty {
margin: 0;
font-size: 0.8rem;
opacity: 0.6;
}
.ce-artifact-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.ce-artifact {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.85rem;
}
.ce-artifact.is-selected {
font-weight: 600;
}
.ce-artifact-btn {
background: none;
border: none;
text-align: left;
cursor: pointer;
display: flex;
flex-direction: column;
color: inherit;
padding: 0.25rem 0;
flex: 1;
}
.ce-artifact-path {
font-size: 0.7rem;
opacity: 0.55;
}
.ce-artifact-error .ce-artifact-error-msg {
color: var(--color-danger, #d23);
font-size: 0.78rem;
}
.ce-artifact-error {
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
}
.ce-view[data-mobile="true"] .ce-groups {
grid-template-columns: 1fr;
}
/* --- Stage launcher (U6) --- */
.ce-launcher {
margin: 0.75rem 0;
}
.ce-launcher-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.5rem;
}
.ce-launcher-tile {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
}
.ce-launcher-icon {
flex: none;
}
.ce-view-start {
margin-left: auto;
}
/* --- CeFlow interactive renderer (U6) --- */
.ce-flow {
margin: 0.75rem 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.ce-flow-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ce-flow-header h3 {
margin: 0;
text-transform: capitalize;
}
.ce-flow-status {
font-size: 0.72rem;
opacity: 0.7;
text-transform: capitalize;
}
.ce-flow-close {
margin-left: auto;
}
.ce-flow-transcript {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 320px;
overflow-y: auto;
}
.ce-flow-turn {
display: flex;
flex-direction: column;
}
.ce-flow-turn-role {
font-size: 0.65rem;
text-transform: uppercase;
opacity: 0.55;
}
.ce-flow-turn-agent .ce-flow-turn-text {
white-space: pre-wrap;
}
.ce-flow-thinking {
font-style: italic;
opacity: 0.7;
}
.ce-flow-question {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.ce-flow-question-text {
font-weight: 600;
margin: 0;
}
.ce-flow-question-desc {
font-size: 0.8rem;
opacity: 0.75;
margin: 0;
}
.ce-flow-text {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.ce-flow-text textarea {
width: 100%;
resize: vertical;
}
.ce-flow-confirm {
display: flex;
gap: 0.5rem;
}
.ce-flow-options {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.ce-flow-options ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.ce-flow-option {
width: 100%;
text-align: left;
display: flex;
flex-direction: column;
}
.ce-flow-option-desc {
font-size: 0.72rem;
opacity: 0.7;
}
.ce-flow-checkbox {
display: flex;
align-items: center;
gap: 0.4rem;
cursor: pointer;
}
.ce-flow-error {
color: var(--color-danger, #d23);
font-size: 0.82rem;
}
/* Degraded chat fallback (R8/AE1) — must read as visibly distinct. */
.ce-flow-degraded {
border: 1px dashed var(--color-warning, #c80);
border-radius: 6px;
padding: 0.6rem;
background: color-mix(in srgb, var(--color-warning, #c80) 8%, transparent);
}
.ce-flow-degraded-banner {
margin: 0 0 0.4rem;
font-size: 0.78rem;
font-weight: 600;
color: var(--color-warning, #a60);
}
.ce-flow-degraded-options {
font-size: 0.78rem;
opacity: 0.8;
margin: 0 0 0.4rem;
padding-left: 1.1rem;
}
/* Sessions panel — manage/switch across multiple concurrent CE sessions. */
.ce-sessions {
margin-bottom: 0.8rem;
}
.ce-sessions-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.ce-session-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ce-session-row.is-active .ce-session-open {
border-color: var(--color-accent, #36c);
background: color-mix(in srgb, var(--color-accent, #36c) 8%, transparent);
}
.ce-session-open {
flex: 1;
display: flex;
align-items: baseline;
gap: 0.6rem;
text-align: left;
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 6px;
background: transparent;
cursor: pointer;
}
.ce-session-open:disabled {
cursor: default;
opacity: 0.6;
}
.ce-session-stage {
font-weight: 600;
}
.ce-session-status {
font-size: 0.74rem;
text-transform: capitalize;
opacity: 0.8;
}
.ce-session-status-awaiting_input {
color: var(--color-warning, #a60);
font-weight: 600;
opacity: 1;
}
.ce-session-status-error,
.ce-session-status-interrupted {
color: var(--color-danger, #d23);
opacity: 1;
}
.ce-session-status-completed {
color: var(--color-success, #2a7);
opacity: 1;
}
.ce-session-updated {
margin-left: auto;
font-size: 0.72rem;
opacity: 0.6;
}
/* ── Q&A transcript bubbles ────────────────────────────────────────────── */
.ce-flow-transcript {
list-style: none;
margin: 0 0 0.8rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
max-height: 50vh;
overflow-y: auto;
}
.ce-flow-turn {
display: flex;
flex-direction: column;
gap: 0.15rem;
max-width: 85%;
padding: 0.45rem 0.65rem;
border-radius: 10px;
background: color-mix(in srgb, var(--color-border, #ddd) 30%, transparent);
}
.ce-flow-turn-user {
align-self: flex-end;
background: color-mix(in srgb, var(--color-accent, #36c) 12%, transparent);
}
.ce-flow-turn-role {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.55;
}
.ce-flow-turn-text {
white-space: pre-wrap;
word-break: break-word;
font-size: 0.86rem;
}
.ce-flow-turn-question {
border-left: 3px solid var(--color-accent, #36c);
}
.ce-flow-turn-answer.is-steering {
border-left: 3px solid var(--color-warning, #c80);
}
.ce-flow-turn-comment {
font-size: 0.78rem;
font-style: italic;
opacity: 0.85;
border-top: 1px dashed color-mix(in srgb, var(--color-border, #ddd) 60%, transparent);
padding-top: 0.25rem;
}
.ce-flow-turn-done {
align-self: center;
background: color-mix(in srgb, var(--color-success, #2a7) 10%, transparent);
font-size: 0.8rem;
}
.ce-flow-turn-activity {
background: transparent;
padding: 0;
max-width: 100%;
}
/* ── Agent working trace (persisted + live) ─────────────────────────────── */
.ce-flow-activity-details summary {
cursor: pointer;
font-size: 0.78rem;
opacity: 0.7;
}
.ce-flow-activity {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin: 0.3rem 0 0;
padding: 0.5rem 0.6rem;
border: 1px solid color-mix(in srgb, var(--color-border, #ddd) 70%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--color-border, #ddd) 12%, transparent);
max-height: 16rem;
overflow-y: auto;
}
.ce-activity-block {
margin: 0;
font-size: 0.76rem;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-mono, ui-monospace, monospace);
}
.ce-activity-thinking {
opacity: 0.6;
font-style: italic;
}
.ce-activity-tool {
font-size: 0.76rem;
font-family: var(--font-mono, ui-monospace, monospace);
}
.ce-activity-tool.is-running .ce-activity-tool-marker {
color: var(--color-accent, #36c);
}
.ce-activity-tool.is-done .ce-activity-tool-marker {
color: var(--color-success, #2a7);
}
.ce-activity-tool.is-error .ce-activity-tool-marker {
color: var(--color-danger, #d23);
}
/* ── Live working pane ──────────────────────────────────────────────────── */
.ce-flow-working {
margin: 0.4rem 0;
}
.ce-flow-working-label {
display: flex;
align-items: center;
gap: 0.45rem;
margin: 0 0 0.3rem;
font-size: 0.82rem;
opacity: 0.85;
}
.ce-flow-pulse {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-accent, #36c);
animation: ce-pulse 1.2s ease-in-out infinite;
}
@keyframes ce-pulse {
0%, 100% { opacity: 0.25; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1); }
}
/* ── Steering / guidance channel ────────────────────────────────────────── */
.ce-flow-guidance {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px dashed color-mix(in srgb, var(--color-border, #ddd) 70%, transparent);
}
.ce-flow-guidance-label {
display: block;
font-size: 0.74rem;
opacity: 0.65;
margin-bottom: 0.3rem;
}
.ce-flow-guidance-row {
display: flex;
gap: 0.4rem;
align-items: flex-end;
}
.ce-flow-guidance-row textarea {
flex: 1;
resize: vertical;
}

View File

@@ -0,0 +1,429 @@
import "./CompoundEngineeringView.css";
import { useCallback, useMemo, useState } from "react";
import * as LucideIcons from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types";
import { useArtifacts } from "./hooks/useArtifacts.js";
import { useViewportMode } from "./hooks/useViewportMode.js";
import { useCeSession, type CeSessionSubscribe } from "./hooks/useCeSession.js";
import { useCeSessions, type CeSessionsSubscribe } from "./hooks/useCeSessions.js";
import { getArtifactPreviewUrl } from "./hooks/api.js";
import { CeFlow } from "./CeFlow.js";
import { getStage, listStages, type CeStageDefinition } from "../session/stage-registry.js";
import type { CeArtifactEntry, CeArtifactGroup } from "../artifacts/discovery.js";
import type { CeSession, CeSessionStatus } from "../session/session-store.js";
const CE_PLUGIN_ID = "fusion-plugin-compound-engineering";
/** Resolve a lucide icon name (from the registry) to a component, with fallback. */
function resolveIcon(name: string): LucideIcon {
const icons = LucideIcons as unknown as Record<string, LucideIcon>;
return icons[name] ?? LucideIcons.Circle;
}
/** Launcher: lists exactly the registered stages (R4) and launches one. */
function StageLauncher({
stages,
disabled,
onLaunch,
}: {
stages: CeStageDefinition[];
disabled: boolean;
onLaunch: (stage: CeStageDefinition) => void;
}) {
return (
<div className="ce-launcher card" data-testid="ce-launcher">
<h3>Start a stage</h3>
<ul className="ce-launcher-list">
{stages.map((stage) => {
const Icon = resolveIcon(stage.icon);
return (
<li key={stage.stageId}>
<button
type="button"
className="ce-launcher-tile btn"
data-testid="ce-launcher-stage"
data-stage={stage.stageId}
disabled={disabled}
onClick={() => onLaunch(stage)}
>
<Icon className="ce-launcher-icon" size={18} aria-hidden="true" />
<span className="ce-launcher-label">{stage.label}</span>
</button>
</li>
);
})}
</ul>
</div>
);
}
/** Statuses that are settled (no agent turn in flight). */
const TERMINAL: ReadonlySet<CeSessionStatus> = new Set(["completed", "error", "interrupted"]);
function statusLabel(status: CeSessionStatus): string {
return status.replace("_", " ");
}
/**
* Sessions panel: every CE session (each an independent pipeline run) with its
* stage, status, and last activity — open any to keep working on it, discard
* settled ones. Sessions keep running server-side while not open here.
*/
function SessionsPanel({
sessions,
activeSessionId,
disabled,
onOpen,
onDiscard,
}: {
sessions: CeSession[];
activeSessionId?: string;
disabled: boolean;
onOpen: (session: CeSession) => void;
onDiscard: (session: CeSession) => void;
}) {
if (sessions.length === 0) return null;
return (
<section className="ce-sessions card" data-testid="ce-sessions">
<header className="ce-group-header">
<h3>Sessions</h3>
<span className="ce-group-count">{sessions.length}</span>
</header>
<ul className="ce-sessions-list">
{sessions.map((s) => {
const stageLabel = getStage(s.stage)?.label ?? s.stage;
const awaiting = s.status === "awaiting_input";
return (
<li
key={s.id}
className={`ce-session-row${s.id === activeSessionId ? " is-active" : ""}`}
data-testid="ce-session-row"
data-session={s.id}
data-status={s.status}
>
<button
type="button"
className="ce-session-open"
data-testid="ce-session-open"
disabled={disabled}
onClick={() => onOpen(s)}
>
<span className="ce-session-stage">{stageLabel}</span>
<span className={`ce-session-status ce-session-status-${s.status}`} data-testid="ce-session-status">
{awaiting ? "needs your input" : statusLabel(s.status)}
</span>
<span className="ce-session-updated">{new Date(s.updatedAt).toLocaleString()}</span>
</button>
{TERMINAL.has(s.status) ? (
<button
type="button"
className="btn ce-session-discard"
data-testid="ce-session-discard"
disabled={disabled}
onClick={() => onDiscard(s)}
>
Discard
</button>
) : null}
</li>
);
})}
</ul>
</section>
);
}
interface CompoundEngineeringViewProps {
context?: PluginDashboardViewContext;
/** Test seam: override the active project id without a host context. */
projectId?: string;
/** Test seam: force the viewport-gated fetch on/off. */
enabledOverride?: boolean;
}
function readProjectId(props: CompoundEngineeringViewProps): string | undefined {
if (props.projectId) return props.projectId;
const ctx = props.context as { projectId?: string } | undefined;
return ctx?.projectId;
}
/** First-run / empty state: no artifacts AND no errors anywhere. */
function EmptyState({ onStart }: { onStart: () => void }) {
return (
<div className="ce-empty card" data-testid="ce-empty-state">
<h3>Start your compounding pipeline</h3>
<p>
No compound-engineering artifacts found yet. Compound Engineering tracks the documents your
pipeline produces — strategy, ideation, brainstorms, plans, solutions, and concepts — as you
move through each stage.
</p>
<p className="ce-empty-hint">
Begin with a stage and its artifact will appear here, grouped and traceable.
</p>
<button type="button" className="btn btn-primary" data-testid="ce-start-action" onClick={onStart}>
Start a stage
</button>
</div>
);
}
function ArtifactRow({
entry,
projectId,
onSelect,
selected,
}: {
entry: CeArtifactEntry;
projectId?: string;
onSelect: (id: string) => void;
selected: boolean;
}) {
if (entry.kind === "error") {
return (
<li className="ce-artifact ce-artifact-error" data-testid="ce-artifact-error">
<span className="ce-artifact-name">{entry.name}</span>
<span className="ce-artifact-error-msg" role="alert">
Could not read: {entry.error}
</span>
<span className="ce-artifact-path">{entry.path}</span>
</li>
);
}
return (
<li className={`ce-artifact${selected ? " is-selected" : ""}`} data-testid="ce-artifact">
<button type="button" className="ce-artifact-btn" onClick={() => onSelect(entry.id)}>
<span className="ce-artifact-name">{entry.name}</span>
<span className="ce-artifact-path">{entry.path}</span>
</button>
<a
className="ce-artifact-open"
href={getArtifactPreviewUrl(entry.id, projectId)}
target="_blank"
rel="noreferrer"
>
Open
</a>
</li>
);
}
function StageGroup({
group,
projectId,
onSelect,
selectedId,
}: {
group: CeArtifactGroup;
projectId?: string;
onSelect: (id: string) => void;
selectedId?: string;
}) {
const empty = group.entries.length === 0;
return (
<section className="ce-group" data-testid="ce-group" data-stage={group.stage} data-empty={empty ? "true" : "false"}>
<header className="ce-group-header">
<h3>{group.label}</h3>
<span className="ce-group-count">{group.entries.length}</span>
</header>
{empty ? (
<p className="ce-group-empty" data-testid="ce-group-empty">
No {group.label.toLowerCase()} artifacts yet.
</p>
) : (
<ul className="ce-artifact-list">
{group.entries.map((entry) => (
<ArtifactRow
key={entry.id}
entry={entry}
projectId={projectId}
onSelect={onSelect}
selected={selectedId === entry.id}
/>
))}
</ul>
)}
</section>
);
}
export function CompoundEngineeringView(props: CompoundEngineeringViewProps) {
const projectId = readProjectId(props);
const { mobile, active } = useViewportMode();
const enabled = props.enabledOverride ?? active;
const { result, loading, error } = useArtifacts({ projectId, enabled });
const [selectedId, setSelectedId] = useState<string | undefined>();
const stages = listStages();
// Live push: when the host forwards a plugin:custom SSE event for THIS session,
// refetch — lower latency than the poll fallback. Uses the host-provided
// subscribe capability (no raw EventSource, no deep dashboard import); when the
// host doesn't supply it, the hook falls back to polling.
const subscribePluginEvents = (props.context as PluginDashboardViewContext | undefined)
?.subscribePluginEvents;
const subscribe = useMemo<CeSessionSubscribe | undefined>(() => {
if (!subscribePluginEvents) return undefined;
return (sessionId, _projectId, onSessionEvent) =>
subscribePluginEvents(CE_PLUGIN_ID, ({ payload }) => {
if ((payload as { sessionId?: string } | undefined)?.sessionId === sessionId) {
onSessionEvent();
}
});
}, [subscribePluginEvents]);
const ceSession = useCeSession(subscribe ? { subscribe } : {});
// Session list refresh: ANY CE push event means some session changed.
const subscribeList = useMemo<CeSessionsSubscribe | undefined>(() => {
if (!subscribePluginEvents) return undefined;
return (onAnyEvent) => subscribePluginEvents(CE_PLUGIN_ID, () => onAnyEvent());
}, [subscribePluginEvents]);
const ceSessions = useCeSessions({
projectId,
enabled,
...(subscribeList ? { subscribe: subscribeList } : {}),
});
const [launcherOpen, setLauncherOpen] = useState(false);
const totalArtifacts = result?.totalArtifacts ?? 0;
const totalErrors = result?.totalErrors ?? 0;
const hasAnything = totalArtifacts > 0 || totalErrors > 0;
// Partial discovery: at least one category populated AND at least one empty.
const populatedGroups = result?.groups.filter((g) => g.entries.length > 0).length ?? 0;
const emptyGroups = result?.groups.filter((g) => g.entries.length === 0).length ?? 0;
const isPartial = populatedGroups > 0 && emptyGroups > 0;
const onStart = () => setLauncherOpen(true);
const onLaunch = useCallback(
(stage: CeStageDefinition) => {
setLauncherOpen(false);
void ceSession
.start(stage.stageId, { message: `Start the ${stage.label} stage.`, projectId })
.then(() => ceSessions.refresh());
},
[ceSession, ceSessions, projectId],
);
const onOpenSession = useCallback(
(s: CeSession) => {
void ceSession.open(s.id, { projectId });
},
[ceSession, projectId],
);
const onDiscardSession = useCallback(
(s: CeSession) => {
void ceSessions.remove(s.id);
},
[ceSessions],
);
// Closing the flow returns to the overview WITHOUT stopping the session —
// it keeps running server-side and stays reachable from the sessions panel.
const onCloseFlow = useCallback(() => {
ceSession.reset();
void ceSessions.refresh();
}, [ceSession, ceSessions]);
// Once a session is active here, the flow renderer owns the surface until
// closed — but the sessions panel stays visible so other sessions remain
// one click away (switching does not stop the open one).
if (ceSession.session) {
return (
<div className="ce-view" data-testid="compound-engineering-view" data-mobile={mobile ? "true" : "false"}>
<div className="ce-view-header">
<h2>Compound Engineering</h2>
</div>
<SessionsPanel
sessions={ceSessions.sessions}
activeSessionId={ceSession.session.id}
disabled={ceSession.busy}
onOpen={onOpenSession}
onDiscard={onDiscardSession}
/>
<CeFlow
session={ceSession.session}
busy={ceSession.busy}
error={ceSession.error}
onAnswer={ceSession.answer}
onResume={ceSession.resume}
onClose={onCloseFlow}
/>
</div>
);
}
return (
<div className="ce-view" data-testid="compound-engineering-view" data-mobile={mobile ? "true" : "false"}>
<div className="ce-view-header">
<h2>Compound Engineering</h2>
{hasAnything ? (
<span className="ce-view-summary" data-testid="ce-summary">
{totalArtifacts} artifact{totalArtifacts === 1 ? "" : "s"}
{totalErrors > 0 ? ` · ${totalErrors} unreadable` : ""}
{isPartial ? " · partial" : ""}
</span>
) : null}
{hasAnything ? (
<button type="button" className="btn btn-primary ce-view-start" data-testid="ce-start-action-header" onClick={onStart}>
Start a stage
</button>
) : null}
</div>
{launcherOpen ? (
<StageLauncher stages={stages} disabled={ceSession.busy} onLaunch={onLaunch} />
) : null}
<SessionsPanel
sessions={ceSessions.sessions}
disabled={ceSession.busy}
onOpen={onOpenSession}
onDiscard={onDiscardSession}
/>
{ceSessions.error ? (
<div className="ce-view-error card" role="alert" data-testid="ce-sessions-error">
Failed to load sessions: {ceSessions.error}
</div>
) : null}
{ceSession.error && !ceSession.session ? (
<div className="ce-view-error card" role="alert" data-testid="ce-session-error">
Failed to start session: {ceSession.error}
</div>
) : null}
{error ? (
<div className="ce-view-error card" role="alert" data-testid="ce-fetch-error">
Failed to load artifacts: {error}
</div>
) : null}
{loading && !result ? (
<div className="ce-loading" data-testid="ce-loading">
Discovering artifacts…
</div>
) : null}
{result && !hasAnything ? (
<EmptyState onStart={onStart} />
) : null}
{result && hasAnything ? (
<div className="ce-groups" data-partial={isPartial ? "true" : "false"}>
{result.groups.map((group) => (
<StageGroup
key={group.stage}
group={group}
projectId={projectId}
onSelect={setSelectedId}
selectedId={selectedId}
/>
))}
</div>
) : null}
</div>
);
}
export default CompoundEngineeringView;

View File

@@ -0,0 +1,292 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import type { PlanningQuestion } from "@fusion/core";
import { CeFlow } from "../CeFlow.js";
import type { CeSession } from "../../session/session-store.js";
function makeSession(over: Partial<CeSession> & { currentQuestion?: PlanningQuestion | null }): CeSession {
return {
id: "s1",
stage: "brainstorm",
status: "awaiting_input",
currentQuestion: null,
conversationHistory: [],
projectId: null,
artifactPath: null,
error: null,
turnIntervalMs: 1000,
lastActivityAt: Date.now(),
createdAt: "2026-06-02T00:00:00Z",
updatedAt: "2026-06-02T00:00:00Z",
...over,
};
}
describe("CeFlow — rich question rendering + submit", () => {
it("renders + submits a text question", () => {
const onAnswer = vi.fn();
const q: PlanningQuestion = { id: "q-text", type: "text", question: "What's the goal?" };
render(<CeFlow session={makeSession({ currentQuestion: q })} onAnswer={onAnswer} />);
const input = screen.getByTestId("ce-flow-text-input");
fireEvent.change(input, { target: { value: "ship faster" } });
fireEvent.click(screen.getByRole("button", { name: "Send" }));
expect(onAnswer).toHaveBeenCalledWith("q-text", "ship faster");
});
it("renders + submits a single_select question", () => {
const onAnswer = vi.fn();
const q: PlanningQuestion = {
id: "q-single",
type: "single_select",
question: "Pick a direction",
options: [
{ id: "a", label: "Alpha" },
{ id: "b", label: "Beta" },
],
};
render(<CeFlow session={makeSession({ currentQuestion: q })} onAnswer={onAnswer} />);
fireEvent.click(screen.getByText("Beta"));
expect(onAnswer).toHaveBeenCalledWith("q-single", "b");
});
it("renders + submits a multi_select question", () => {
const onAnswer = vi.fn();
const q: PlanningQuestion = {
id: "q-multi",
type: "multi_select",
question: "Which goals?",
options: [
{ id: "g1", label: "Speed" },
{ id: "g2", label: "Quality" },
{ id: "g3", label: "Cost" },
],
};
render(<CeFlow session={makeSession({ currentQuestion: q })} onAnswer={onAnswer} />);
const boxes = screen.getByTestId("ce-flow-multi").querySelectorAll("input[type=checkbox]");
fireEvent.click(boxes[0]);
fireEvent.click(boxes[2]);
fireEvent.click(screen.getByTestId("ce-flow-multi-submit"));
expect(onAnswer).toHaveBeenCalledWith("q-multi", ["g1", "g3"]);
});
it("renders + submits a confirm question (both branches)", () => {
const onAnswer = vi.fn();
const q: PlanningQuestion = { id: "q-c", type: "confirm", question: "Write the doc now?" };
const { rerender } = render(<CeFlow session={makeSession({ currentQuestion: q })} onAnswer={onAnswer} />);
fireEvent.click(screen.getByTestId("ce-flow-confirm-yes"));
expect(onAnswer).toHaveBeenLastCalledWith("q-c", true);
rerender(<CeFlow session={makeSession({ currentQuestion: q })} onAnswer={onAnswer} />);
fireEvent.click(screen.getByTestId("ce-flow-confirm-no"));
expect(onAnswer).toHaveBeenLastCalledWith("q-c", false);
});
});
describe("CeFlow — degraded fallback (AE1)", () => {
it("falls back to a visibly-degraded chat view for an unrenderable interaction, and the stage still completes", () => {
const onAnswer = vi.fn();
// A type CeFlow cannot express richly — degrades to chat.
const rogue = {
id: "q-rogue",
type: "rank_order",
question: "Rank these by priority",
options: [{ id: "a", label: "A" }],
} as unknown as PlanningQuestion;
const { rerender } = render(<CeFlow session={makeSession({ currentQuestion: rogue })} onAnswer={onAnswer} />);
// Visibly marked as degraded.
const banner = screen.getByTestId("ce-flow-degraded-banner");
expect(banner).toBeInTheDocument();
expect(screen.queryByTestId("ce-flow-question")).not.toBeInTheDocument();
// Stage is still completable: free-text answer submits through the same route.
fireEvent.change(screen.getByTestId("ce-flow-degraded-input"), { target: { value: "A then B" } });
fireEvent.click(screen.getByRole("button", { name: "Send" }));
expect(onAnswer).toHaveBeenCalledWith("q-rogue", "A then B");
// After the answer the orchestrator reaches `complete` → CeFlow shows done.
rerender(
<CeFlow
session={makeSession({ status: "completed", currentQuestion: null, artifactPath: "/repo/docs/brainstorms/x.md" })}
onAnswer={onAnswer}
/>,
);
expect(screen.getByTestId("ce-flow-complete")).toBeInTheDocument();
expect(screen.getByTestId("ce-flow-artifact-path")).toHaveTextContent("/repo/docs/brainstorms/x.md");
});
it("degrades a select question that arrives with no options", () => {
const onAnswer = vi.fn();
const q: PlanningQuestion = { id: "q-empty", type: "single_select", question: "Pick", options: [] };
render(<CeFlow session={makeSession({ currentQuestion: q })} onAnswer={onAnswer} />);
expect(screen.getByTestId("ce-flow-degraded")).toBeInTheDocument();
});
});
describe("CeFlow — steering (guidance channel)", () => {
const q: PlanningQuestion = {
id: "q-steer",
type: "single_select",
question: "Pick a direction",
options: [
{ id: "a", label: "Alpha" },
{ id: "b", label: "Beta" },
],
};
it("attaches typed guidance to the chosen answer as {value, comment}", () => {
const onAnswer = vi.fn();
render(<CeFlow session={makeSession({ currentQuestion: q })} onAnswer={onAnswer} />);
fireEvent.change(screen.getByTestId("ce-flow-guidance-input"), {
target: { value: "focus on mobile" },
});
fireEvent.click(screen.getByText("Beta"));
expect(onAnswer).toHaveBeenCalledWith("q-steer", { value: "b", comment: "focus on mobile" });
});
it("sends guidance WITHOUT answering as {feedback}", () => {
const onAnswer = vi.fn();
render(<CeFlow session={makeSession({ currentQuestion: q })} onAnswer={onAnswer} />);
const send = screen.getByTestId("ce-flow-guidance-send");
expect(send).toBeDisabled(); // empty guidance can't be sent
fireEvent.change(screen.getByTestId("ce-flow-guidance-input"), {
target: { value: "skip auth for now" },
});
fireEvent.click(send);
expect(onAnswer).toHaveBeenCalledWith("q-steer", { feedback: "skip auth for now" });
});
it("plain answers stay unwrapped when no guidance is typed", () => {
const onAnswer = vi.fn();
render(<CeFlow session={makeSession({ currentQuestion: q })} onAnswer={onAnswer} />);
fireEvent.click(screen.getByText("Alpha"));
expect(onAnswer).toHaveBeenCalledWith("q-steer", "a");
});
it("free-text questions get no extra guidance box (their answer field already takes free text)", () => {
const textQ: PlanningQuestion = { id: "q-text", type: "text", question: "Goal?" };
render(<CeFlow session={makeSession({ currentQuestion: textQ })} onAnswer={vi.fn()} />);
expect(screen.queryByTestId("ce-flow-guidance")).not.toBeInTheDocument();
});
});
describe("CeFlow — Q&A transcript rendering", () => {
const pastQ: PlanningQuestion = {
id: "q-past",
type: "single_select",
question: "Which path?",
options: [
{ id: "x", label: "The X path" },
{ id: "y", label: "The Y path" },
],
};
function historyWith(answer: unknown) {
return [
{ role: "user" as const, text: "kick off", at: "t0" },
{ role: "agent" as const, text: JSON.stringify({ question: pastQ }), at: "t1" },
{ role: "user" as const, text: JSON.stringify({ answer, questionId: "q-past" }), at: "t2" },
];
}
it("renders past questions and answers as bubbles, mapping option ids to labels", () => {
render(
<CeFlow
session={makeSession({ status: "active", conversationHistory: historyWith("y") })}
onAnswer={vi.fn()}
/>,
);
expect(screen.getByTestId("ce-flow-past-question")).toHaveTextContent("Which path?");
// The answer shows the LABEL, not the raw option id.
expect(screen.getByTestId("ce-flow-past-answer")).toHaveTextContent("The Y path");
// The opening message renders as a plain user bubble.
expect(screen.getByText("kick off")).toBeInTheDocument();
});
it("renders {value, comment} answers with the steering comment attached", () => {
render(
<CeFlow
session={makeSession({
status: "active",
conversationHistory: historyWith({ value: "x", comment: "but keep it small" }),
})}
onAnswer={vi.fn()}
/>,
);
expect(screen.getByTestId("ce-flow-past-answer")).toHaveTextContent("The X path");
expect(screen.getByTestId("ce-flow-answer-comment")).toHaveTextContent("but keep it small");
});
it("renders {feedback} turns as steering, not answers", () => {
render(
<CeFlow
session={makeSession({
status: "active",
conversationHistory: historyWith({ feedback: "go another way" }),
})}
onAnswer={vi.fn()}
/>,
);
const turn = screen.getByTestId("ce-flow-past-answer");
expect(turn).toHaveTextContent("You steered");
expect(turn).toHaveTextContent("go another way");
});
it("renders persisted working traces as a collapsible activity block", () => {
const history = [
{
role: "agent" as const,
text: JSON.stringify({
activity: {
turns: [
{ kind: "thinking", text: "Scanning the repo…", at: "t" },
{ kind: "tool", text: "Read", at: "t", done: true },
],
},
}),
at: "t1",
},
];
render(<CeFlow session={makeSession({ status: "active", conversationHistory: history })} onAnswer={vi.fn()} />);
const details = screen.getByTestId("ce-flow-activity");
expect(details).toHaveTextContent("Agent work (2 steps)");
expect(screen.getByText("Scanning the repo…")).toBeInTheDocument();
expect(screen.getByTestId("ce-activity-tool")).toHaveTextContent("Read");
});
});
describe("CeFlow — lifecycle surfaces", () => {
it("shows the working pane while a turn runs", () => {
render(<CeFlow session={makeSession({ status: "active", currentQuestion: null })} busy onAnswer={vi.fn()} />);
expect(screen.getByTestId("ce-flow-thinking")).toBeInTheDocument();
});
it("streams live working output (thinking + tools) while the agent works", () => {
const session = makeSession({
status: "active",
currentQuestion: null,
liveActivity: [
{ kind: "thinking", text: "Considering options…", at: "t" },
{ kind: "tool", text: "Grep", at: "t", done: false },
],
});
render(<CeFlow session={session} onAnswer={vi.fn()} />);
const pane = screen.getByTestId("ce-flow-live-activity");
expect(pane).toHaveTextContent("Considering options…");
expect(screen.getByTestId("ce-activity-tool")).toHaveTextContent("Grep");
});
it("offers resume on an interrupted session", () => {
const onResume = vi.fn();
render(
<CeFlow
session={makeSession({ status: "interrupted", currentQuestion: null, error: "stalled" })}
onAnswer={vi.fn()}
onResume={onResume}
/>,
);
fireEvent.click(screen.getByTestId("ce-flow-resume"));
expect(onResume).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,218 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { DiscoveryResult } from "../../artifacts/discovery.js";
// Mock the network layer so the view renders from seeded discovery results.
const listArtifacts = vi.fn(async (): Promise<DiscoveryResult> => {
throw new Error("listArtifacts mock not configured");
});
const listSessions = vi.fn(async (): Promise<CeSession[]> => []);
const deleteSession = vi.fn(async (_id: string, _projectId?: string): Promise<void> => undefined);
const getSession = vi.fn(async (_id: string, _projectId?: string): Promise<CeSession> => {
throw new Error("getSession mock not configured");
});
vi.mock("../hooks/api.js", () => ({
listArtifacts: () => listArtifacts(),
getArtifactPreviewUrl: (id: string) => `/preview/${id}`,
listSessions: () => listSessions(),
deleteSession: (id: string, projectId?: string) => deleteSession(id, projectId),
getSession: (id: string, projectId?: string) => getSession(id, projectId),
startSession: vi.fn(),
answerSession: vi.fn(),
resumeSession: vi.fn(),
}));
import { CompoundEngineeringView } from "../CompoundEngineeringView.js";
import { __test_clearArtifactsCache } from "../hooks/useArtifacts.js";
import type { CeSession } from "../../session/session-store.js";
function mkCeSession(over: Partial<CeSession>): CeSession {
return {
id: "sess-1",
stage: "brainstorm",
status: "awaiting_input",
currentQuestion: null,
conversationHistory: [],
projectId: "p1",
artifactPath: null,
error: null,
turnIntervalMs: 1000,
lastActivityAt: Date.now(),
createdAt: "2026-06-03T00:00:00Z",
updatedAt: "2026-06-03T00:00:00Z",
...over,
};
}
const ALL_STAGES: Array<{ stage: DiscoveryResult["groups"][number]["stage"]; label: string }> = [
{ stage: "strategy", label: "Strategy" },
{ stage: "ideation", label: "Ideation" },
{ stage: "brainstorm", label: "Brainstorms" },
{ stage: "plan", label: "Plans" },
{ stage: "solution", label: "Solutions" },
{ stage: "concepts", label: "Concepts" },
];
function makeResult(overrides: Partial<Record<DiscoveryResult["groups"][number]["stage"], DiscoveryResult["groups"][number]["entries"]>>): DiscoveryResult {
const groups = ALL_STAGES.map(({ stage, label }) => ({
stage,
label,
present: Boolean(overrides[stage]?.length),
entries: overrides[stage] ?? [],
}));
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 };
}
describe("CompoundEngineeringView", () => {
beforeEach(() => {
__test_clearArtifactsCache();
listArtifacts.mockReset();
listSessions.mockReset();
listSessions.mockResolvedValue([]);
deleteSession.mockReset();
deleteSession.mockResolvedValue(undefined);
getSession.mockReset();
});
afterEach(() => vi.clearAllMocks());
it("renders the empty / first-run state with an orientation + start action", async () => {
listArtifacts.mockResolvedValue(makeResult({}));
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
await screen.findByTestId("ce-empty-state");
expect(screen.getByText(/Start your compounding pipeline/i)).toBeInTheDocument();
const start = screen.getByTestId("ce-start-action");
expect(start).toBeInTheDocument();
// Start affordance is wired to a placeholder (toast); clicking does not throw.
fireEvent.click(start);
});
it("renders the partial-discovery state (some categories present, others empty)", async () => {
listArtifacts.mockResolvedValue(
makeResult({
strategy: [
{ kind: "artifact", id: "strategy:STRATEGY.md", stage: "strategy", path: "STRATEGY.md", name: "STRATEGY.md", size: 10, updatedAt: 1 },
],
plan: [
{ kind: "artifact", id: "plan:docs/plans/p.md", stage: "plan", path: "docs/plans/p.md", name: "p.md", size: 5, updatedAt: 2 },
],
}),
);
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
await screen.findByTestId("ce-summary");
// Partial flag surfaces in the summary and on the groups container.
expect(screen.getByTestId("ce-summary").textContent).toMatch(/partial/i);
const groups = screen.getByTestId("ce-summary").closest(".ce-view")!.querySelector(".ce-groups");
expect(groups?.getAttribute("data-partial")).toBe("true");
// Populated groups render artifacts; empty ones render an empty hint.
expect(screen.getAllByTestId("ce-artifact")).toHaveLength(2);
expect(screen.getAllByTestId("ce-group-empty").length).toBeGreaterThan(0);
});
it("renders an error entry for an unreadable artifact (not a crash or silent drop)", async () => {
listArtifacts.mockResolvedValue(
makeResult({
plan: [
{ kind: "error", id: "plan:docs/plans/bad.md", stage: "plan", path: "docs/plans/bad.md", name: "bad.md", error: "EIO: simulated read failure" },
],
}),
);
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
const errorEntry = await screen.findByTestId("ce-artifact-error");
expect(errorEntry).toBeInTheDocument();
expect(errorEntry.textContent).toMatch(/simulated read failure/i);
// Surfaced as an unreadable count in the summary.
expect(screen.getByTestId("ce-summary").textContent).toMatch(/unreadable/i);
});
it("lists multiple sessions with status badges; terminal sessions get a discard affordance", async () => {
listArtifacts.mockResolvedValue(makeResult({}));
listSessions.mockResolvedValue([
mkCeSession({ id: "a", stage: "brainstorm", status: "awaiting_input" }),
mkCeSession({ id: "b", stage: "plan", status: "active" }),
mkCeSession({ id: "c", stage: "work", status: "completed" }),
]);
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
await screen.findByTestId("ce-sessions");
const rows = screen.getAllByTestId("ce-session-row");
expect(rows).toHaveLength(3);
expect(rows.map((r) => r.getAttribute("data-status"))).toEqual([
"awaiting_input",
"active",
"completed",
]);
// Awaiting sessions advertise that they need the user.
expect(rows[0].textContent).toMatch(/needs your input/i);
// Only the terminal session can be discarded.
expect(screen.getAllByTestId("ce-session-discard")).toHaveLength(1);
});
it("opens an existing session from the list into the flow (and back without losing it)", async () => {
listArtifacts.mockResolvedValue(makeResult({}));
listSessions.mockResolvedValue([
mkCeSession({ id: "a", stage: "brainstorm", status: "awaiting_input" }),
mkCeSession({ id: "b", stage: "plan", status: "active" }),
]);
getSession.mockResolvedValue(
mkCeSession({
id: "a",
status: "awaiting_input",
currentQuestion: { id: "q1", type: "text", question: "Topic?" },
}),
);
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
await screen.findByTestId("ce-sessions");
fireEvent.click(screen.getAllByTestId("ce-session-open")[0]);
// The flow surface opens on the adopted session…
const flow = await screen.findByTestId("ce-flow");
expect(flow.getAttribute("data-stage")).toBe("brainstorm");
expect(getSession).toHaveBeenCalledWith("a", "p1");
// …while the sessions panel stays visible for switching, with the open
// session marked active.
expect(screen.getByTestId("ce-sessions")).toBeInTheDocument();
const rows = screen.getAllByTestId("ce-session-row");
expect(rows[0].className).toMatch(/is-active/);
// Closing returns to the overview; the session list survives (the session
// itself keeps running server-side — close does not delete anything).
fireEvent.click(screen.getByText("Close"));
await screen.findByTestId("ce-empty-state");
expect(screen.getByTestId("ce-sessions")).toBeInTheDocument();
expect(deleteSession).not.toHaveBeenCalled();
});
it("discards a terminal session via the list", async () => {
listArtifacts.mockResolvedValue(makeResult({}));
listSessions.mockResolvedValue([mkCeSession({ id: "done", stage: "plan", status: "completed" })]);
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
await screen.findByTestId("ce-sessions");
listSessions.mockResolvedValue([]);
fireEvent.click(screen.getByTestId("ce-session-discard"));
await waitFor(() => expect(deleteSession).toHaveBeenCalledWith("done", "p1"));
await waitFor(() => expect(screen.queryByTestId("ce-sessions")).not.toBeInTheDocument());
});
it("does not fetch when the viewport-gated flag is disabled", async () => {
listArtifacts.mockResolvedValue(makeResult({}));
render(<CompoundEngineeringView projectId="p1" enabledOverride={false} />);
// Give effects a tick.
await waitFor(() => expect(screen.getByTestId("compound-engineering-view")).toBeInTheDocument());
expect(listArtifacts).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,83 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { DiscoveryResult } from "../../artifacts/discovery.js";
import type { CeSession } from "../../session/session-store.js";
import { listStages } from "../../session/stage-registry.js";
// Mock the whole api module: artifacts (so the view renders empty) + session.
const startSession = vi.fn<(stage: string, opts?: unknown) => Promise<CeSession>>();
vi.mock("../hooks/api.js", () => ({
listArtifacts: async (): Promise<DiscoveryResult> => ({
groups: [],
totalArtifacts: 0,
totalErrors: 0,
}),
getArtifactPreviewUrl: (id: string) => `/preview/${id}`,
startSession: (stage: string, opts?: unknown) => startSession(stage, opts),
answerSession: vi.fn(),
resumeSession: vi.fn(),
getSession: vi.fn(),
}));
import { CompoundEngineeringView } from "../CompoundEngineeringView.js";
import { __test_clearArtifactsCache } from "../hooks/useArtifacts.js";
afterEach(() => {
__test_clearArtifactsCache();
startSession.mockReset();
});
function mkSession(over: Partial<CeSession>): CeSession {
return {
id: "s1",
stage: "brainstorm",
status: "awaiting_input",
currentQuestion: { id: "q1", type: "text", question: "What's the topic?" },
conversationHistory: [],
projectId: null,
artifactPath: null,
error: null,
turnIntervalMs: 1000,
lastActivityAt: Date.now(),
createdAt: "t",
updatedAt: "t",
...over,
};
}
describe("Stage launcher (R4)", () => {
it("lists exactly the registered stages", async () => {
render(<CompoundEngineeringView enabledOverride projectId="p1" />);
// Empty-state start affordance opens the launcher.
await waitFor(() => screen.getByTestId("ce-empty-state"));
fireEvent.click(screen.getByTestId("ce-start-action"));
const tiles = await screen.findAllByTestId("ce-launcher-stage");
const expected = listStages();
expect(tiles).toHaveLength(expected.length);
const renderedStages = tiles.map((t) => t.getAttribute("data-stage")).sort();
expect(renderedStages).toEqual(expected.map((s) => s.stageId).sort());
// And the labels match the registry.
for (const stage of expected) {
expect(screen.getByText(stage.label)).toBeInTheDocument();
}
});
it("launching a stage starts its session and renders CeFlow", async () => {
startSession.mockResolvedValue(mkSession({ stage: "plan" }));
render(<CompoundEngineeringView enabledOverride projectId="p1" />);
await waitFor(() => screen.getByTestId("ce-empty-state"));
fireEvent.click(screen.getByTestId("ce-start-action"));
const planTile = (await screen.findAllByTestId("ce-launcher-stage")).find(
(t) => t.getAttribute("data-stage") === "plan",
)!;
await act(async () => {
fireEvent.click(planTile);
});
expect(startSession).toHaveBeenCalledWith("plan", expect.objectContaining({ projectId: "p1" }));
expect(await screen.findByTestId("ce-flow")).toBeInTheDocument();
expect(screen.getByTestId("ce-flow-text-input")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,38 @@
/**
* The renderable set of `CeFlow` (R8/AE1 boundary).
*
* `CeFlow` renders four interaction types richly: `text` (free-text input),
* `single_select`, `multi_select`, and `confirm`. Any other interaction — an
* unknown future question type, or a select-type question that arrives without
* the options it needs to render choices — is NOT expressible by the rich
* renderer and must degrade to the visibly-marked chat fallback.
*
* This module is the single source of truth for that boundary so the renderer
* and the skill-interaction audit agree on what "renderable richly" means.
*/
import type { PlanningQuestion, PlanningQuestionType } from "@fusion/core";
/** The interaction types CeFlow renders with dedicated rich controls. */
export const RICH_INTERACTION_TYPES: readonly PlanningQuestionType[] = [
"text",
"single_select",
"multi_select",
"confirm",
] as const;
export function isRichInteractionType(type: string): type is PlanningQuestionType {
return (RICH_INTERACTION_TYPES as readonly string[]).includes(type);
}
/**
* Whether CeFlow can render this concrete question with rich controls. A
* select-type question with no usable options can't present choices, so it
* degrades to chat even though its `type` is in the rich set.
*/
export function canRenderRichly(question: Pick<PlanningQuestion, "type" | "options">): boolean {
if (!isRichInteractionType(question.type)) return false;
if (question.type === "single_select" || question.type === "multi_select") {
return Array.isArray(question.options) && question.options.length > 0;
}
return true;
}

View File

@@ -0,0 +1,222 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import type { PlanningQuestion } from "@fusion/core";
import { useCeSession, type CeSessionTransport, type CeSessionSubscribe } from "../useCeSession.js";
import type { CeSession } from "../../../session/session-store.js";
function mkSession(over: Partial<CeSession>): CeSession {
return {
id: "s1",
stage: "brainstorm",
status: "awaiting_input",
currentQuestion: null,
conversationHistory: [],
projectId: null,
artifactPath: null,
error: null,
turnIntervalMs: 1000,
lastActivityAt: Date.now(),
createdAt: "t",
updatedAt: "t",
...over,
};
}
const Q: PlanningQuestion = { id: "q1", type: "text", question: "go?" };
function Harness({ transport }: { transport: CeSessionTransport }) {
const s = useCeSession({ transport, pollIntervalMs: 5 });
return (
<div>
<span data-testid="status">{s.session?.status ?? "none"}</span>
<span data-testid="busy">{s.busy ? "busy" : "idle"}</span>
<span data-testid="err">{s.error ?? ""}</span>
<button onClick={() => void s.start("brainstorm", { projectId: "p1" })}>start</button>
<button onClick={() => void s.open("s2", { projectId: "p2" })}>open</button>
<button onClick={() => void s.answer("q1", "yes")}>answer</button>
<button onClick={() => void s.resume()}>resume</button>
<button onClick={() => s.reset()}>reset</button>
</div>
);
}
describe("useCeSession lifecycle", () => {
afterEach(() => {
// Ensure faked timers never leak into the next test.
vi.useRealTimers();
});
it("start → awaiting_input → answer → completed", async () => {
const transport: CeSessionTransport = {
start: vi.fn(async () => mkSession({ status: "awaiting_input", currentQuestion: Q })),
answer: vi.fn(async () => mkSession({ status: "completed", currentQuestion: null, artifactPath: "/a.md" })),
resume: vi.fn(async () => mkSession({})),
get: vi.fn(async () => mkSession({})),
};
render(<Harness transport={transport} />);
await act(async () => {
screen.getByText("start").click();
});
expect(screen.getByTestId("status")).toHaveTextContent("awaiting_input");
await act(async () => {
screen.getByText("answer").click();
});
expect(screen.getByTestId("status")).toHaveTextContent("completed");
// projectId from start() must thread through to answer() (FN: per-request
// store resolution selects the session's owning store/live handle).
expect(transport.answer).toHaveBeenCalledWith("s1", "q1", "yes", "p1");
});
it("threads the start projectId through resume and poll", async () => {
vi.useFakeTimers();
const get = vi.fn(async () => mkSession({ status: "active" }));
const transport: CeSessionTransport = {
start: vi.fn(async () => mkSession({ status: "interrupted", currentQuestion: Q })),
answer: vi.fn(),
resume: vi.fn(async () => mkSession({ status: "active" })),
get,
};
render(<Harness transport={transport} />);
await act(async () => {
screen.getByText("start").click();
});
await act(async () => {
screen.getByText("resume").click();
});
expect(transport.resume).toHaveBeenCalledWith("s1", "p1");
// The poll (active status) must also carry the projectId. Harness uses a 5ms
// interval; advance fake time deterministically past one tick.
await act(async () => {
await vi.advanceTimersByTimeAsync(20);
});
expect(get).toHaveBeenCalledWith("s1", "p1");
});
it("polls while active and stops once settled", async () => {
vi.useFakeTimers();
let calls = 0;
const get = vi.fn(async () => {
calls += 1;
return calls >= 2 ? mkSession({ status: "awaiting_input", currentQuestion: Q }) : mkSession({ status: "active" });
});
const transport: CeSessionTransport = {
start: vi.fn(async () => mkSession({ status: "active", currentQuestion: null })),
answer: vi.fn(),
resume: vi.fn(),
get,
};
render(<Harness transport={transport} />);
await act(async () => {
screen.getByText("start").click();
});
expect(screen.getByTestId("status")).toHaveTextContent("active");
// Advance fake time so the poll interval fires and converges to awaiting_input.
await act(async () => {
await vi.advanceTimersByTimeAsync(40);
});
expect(get).toHaveBeenCalled();
expect(screen.getByTestId("status")).toHaveTextContent("awaiting_input");
});
it("open() adopts an existing session and threads ITS projectId to later calls", async () => {
const transport: CeSessionTransport = {
start: vi.fn(),
answer: vi.fn(async () => mkSession({ id: "s2", status: "completed" })),
resume: vi.fn(),
get: vi.fn(async () => mkSession({ id: "s2", status: "awaiting_input", currentQuestion: Q })),
};
render(<Harness transport={transport} />);
await act(async () => {
screen.getByText("open").click();
});
expect(transport.get).toHaveBeenCalledWith("s2", "p2");
expect(screen.getByTestId("status")).toHaveTextContent("awaiting_input");
// Subsequent answer goes to the opened session with the opened projectId.
await act(async () => {
screen.getByText("answer").click();
});
expect(transport.answer).toHaveBeenCalledWith("s2", "q1", "yes", "p2");
});
it("surfaces a start error", async () => {
const transport: CeSessionTransport = {
start: vi.fn(async () => {
throw new Error("boom");
}),
answer: vi.fn(),
resume: vi.fn(),
get: vi.fn(),
};
render(<Harness transport={transport} />);
await act(async () => {
screen.getByText("start").click();
});
expect(screen.getByTestId("err")).toHaveTextContent("boom");
});
it("resume transitions an interrupted session", async () => {
const transport: CeSessionTransport = {
start: vi.fn(async () => mkSession({ status: "interrupted", currentQuestion: Q })),
answer: vi.fn(),
resume: vi.fn(async () => mkSession({ status: "awaiting_input", currentQuestion: Q })),
get: vi.fn(async () => mkSession({ status: "interrupted", currentQuestion: Q })),
};
render(<Harness transport={transport} />);
await act(async () => {
screen.getByText("start").click();
});
expect(screen.getByTestId("status")).toHaveTextContent("interrupted");
await act(async () => {
screen.getByText("resume").click();
});
expect(transport.resume).toHaveBeenCalledWith("s1", "p1");
expect(screen.getByTestId("status")).toHaveTextContent("awaiting_input");
});
it("refetches when a session event is pushed over the subscribe seam", async () => {
let fire: (() => void) | undefined;
const subscribe: CeSessionSubscribe = (_sessionId, _projectId, onSessionEvent) => {
fire = onSessionEvent;
return () => {
fire = undefined;
};
};
const get = vi.fn(async () => mkSession({ status: "completed", currentQuestion: null, artifactPath: "/a.md" }));
const transport: CeSessionTransport = {
// start returns an active (mid-turn) session; without a push or poll it stays active.
start: vi.fn(async () => mkSession({ status: "active", currentQuestion: null })),
answer: vi.fn(),
resume: vi.fn(),
get,
};
function PushHarness() {
const s = useCeSession({ transport, subscribe, pollIntervalMs: 100000 });
return (
<div>
<span data-testid="status">{s.session?.status ?? "none"}</span>
<button onClick={() => void s.start("brainstorm", { projectId: "p1" })}>start</button>
</div>
);
}
render(<PushHarness />);
await act(async () => {
screen.getByText("start").click();
});
expect(screen.getByTestId("status")).toHaveTextContent("active");
// A pushed event triggers an immediate refetch (no poll interval elapsed).
await act(async () => {
fire?.();
await Promise.resolve();
});
expect(get).toHaveBeenCalledWith("s1", "p1");
expect(screen.getByTestId("status")).toHaveTextContent("completed");
});
});

View File

@@ -0,0 +1,149 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { useCeSessions, type CeSessionsTransport, type CeSessionsSubscribe } from "../useCeSessions.js";
import type { CeSession } from "../../../session/session-store.js";
function mkSession(over: Partial<CeSession>): CeSession {
return {
id: "s1",
stage: "brainstorm",
status: "awaiting_input",
currentQuestion: null,
conversationHistory: [],
projectId: null,
artifactPath: null,
error: null,
turnIntervalMs: 1000,
lastActivityAt: Date.now(),
createdAt: "t",
updatedAt: "t",
...over,
};
}
function Harness({
transport,
subscribe,
}: {
transport: CeSessionsTransport;
subscribe?: CeSessionsSubscribe;
}) {
const s = useCeSessions({
projectId: "p1",
transport,
pollIntervalMs: 5,
...(subscribe ? { subscribe } : {}),
});
return (
<div>
<span data-testid="count">{s.sessions.length}</span>
<span data-testid="ids">{s.sessions.map((x) => x.id).join(",")}</span>
<span data-testid="err">{s.error ?? ""}</span>
<button onClick={() => void s.refresh()}>refresh</button>
<button onClick={() => void s.remove("s1")}>remove</button>
</div>
);
}
describe("useCeSessions (multi-session list)", () => {
afterEach(() => {
vi.useRealTimers();
});
it("lists all sessions on mount with the projectId", async () => {
const list = vi.fn(async () => [mkSession({ id: "s1" }), mkSession({ id: "s2", stage: "plan" })]);
const transport: CeSessionsTransport = { list, remove: vi.fn() };
render(<Harness transport={transport} />);
await act(async () => {});
expect(list).toHaveBeenCalledWith("p1");
expect(screen.getByTestId("count")).toHaveTextContent("2");
expect(screen.getByTestId("ids")).toHaveTextContent("s1,s2");
});
it("remove() deletes via the transport then refreshes the list", async () => {
let removed = false;
const transport: CeSessionsTransport = {
list: vi.fn(async () => (removed ? [mkSession({ id: "s2" })] : [mkSession({ id: "s1" }), mkSession({ id: "s2" })])),
remove: vi.fn(async () => {
removed = true;
}),
};
render(<Harness transport={transport} />);
await act(async () => {});
expect(screen.getByTestId("count")).toHaveTextContent("2");
await act(async () => {
screen.getByText("remove").click();
});
expect(transport.remove).toHaveBeenCalledWith("s1", "p1");
expect(screen.getByTestId("ids")).toHaveTextContent("s2");
});
it("refreshes when a push event fires", async () => {
let fire: (() => void) | undefined;
const subscribe: CeSessionsSubscribe = (onAnyEvent) => {
fire = onAnyEvent;
return () => {
fire = undefined;
};
};
let n = 1;
const transport: CeSessionsTransport = {
list: vi.fn(async () => Array.from({ length: n }, (_, i) => mkSession({ id: `s${i + 1}` }))),
remove: vi.fn(),
};
render(<Harness transport={transport} subscribe={subscribe} />);
await act(async () => {});
expect(screen.getByTestId("count")).toHaveTextContent("1");
n = 2;
await act(async () => {
fire?.();
await Promise.resolve();
});
expect(screen.getByTestId("count")).toHaveTextContent("2");
});
it("polls while any session is mid-turn and stops when all settle", async () => {
vi.useFakeTimers();
let calls = 0;
const transport: CeSessionsTransport = {
list: vi.fn(async () => {
calls += 1;
return [mkSession({ id: "s1", status: calls >= 3 ? "completed" : "active" })];
}),
remove: vi.fn(),
};
render(<Harness transport={transport} />);
await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
expect(screen.getByTestId("count")).toHaveTextContent("1");
await act(async () => {
await vi.advanceTimersByTimeAsync(50);
});
const settledCalls = calls;
expect(calls).toBeGreaterThanOrEqual(3);
// All settled → polling stops (no further list calls as time advances).
await act(async () => {
await vi.advanceTimersByTimeAsync(50);
});
expect(calls).toBe(settledCalls);
});
it("surfaces a list error without crashing", async () => {
const transport: CeSessionsTransport = {
list: vi.fn(async () => {
throw new Error("kaput");
}),
remove: vi.fn(),
};
render(<Harness transport={transport} />);
await act(async () => {});
expect(screen.getByTestId("err")).toHaveTextContent("kaput");
expect(screen.getByTestId("count")).toHaveTextContent("0");
});
});

View File

@@ -0,0 +1,117 @@
import type { DiscoveryResult } from "../../artifacts/discovery.js";
import type { CeSession } from "../../session/session-store.js";
const BASE = "/api/plugins/fusion-plugin-compound-engineering";
function qp(params: Record<string, string | undefined>): string {
const entries = Object.entries(params).filter(
([, v]) => typeof v === "string" && v.length > 0,
) as Array<[string, string]>;
if (entries.length === 0) return "";
return `?${entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&")}`;
}
async function request<T>(path: string, init?: RequestInit, responseType: "json" | "text" = "json"): Promise<T> {
const response = await fetch(`${BASE}${path}`, init);
if (!response.ok) {
let message = `${response.status} ${response.statusText}`;
try {
const data = (await response.json()) as { error?: string };
if (data.error) message = data.error;
} catch {
// ignore
}
throw new Error(message);
}
if (responseType === "text") return (await response.text()) as T;
return (await response.json()) as T;
}
export async function listArtifacts(projectId?: string): Promise<DiscoveryResult> {
return request<DiscoveryResult>(`/artifacts${qp({ projectId })}`);
}
export async function getArtifact(
id: string,
projectId?: string,
): Promise<{ content: string; name: string }> {
const data = await request<{ artifact: { name: string }; content: string }>(
`/artifacts/${encodeURIComponent(id)}${qp({ projectId })}`,
);
return { content: data.content, name: data.artifact.name };
}
export function getArtifactPreviewUrl(id: string, projectId?: string): string {
return `${BASE}/artifacts/${encodeURIComponent(id)}/preview.html${qp({ projectId })}`;
}
// --- Interactive CE session routes (polling transport, U5/U6) ---------------
/** Start a stage session. Returns the freshly-created session (after one turn). */
export async function startSession(
stage: string,
opts: { message?: string; projectId?: string } = {},
): Promise<CeSession> {
const data = await request<{ session: CeSession }>(`/sessions`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ stage, message: opts.message ?? "", projectId: opts.projectId }),
});
return data.session;
}
/**
* Submit an answer to the awaiting question and advance the session.
*
* `projectId` MUST match the one used at `startSession` — it selects the
* project-scoped store that holds the session row and its live in-process
* handle. Omitting it (or sending a different one) resolves a different store
* and the session won't be found.
*/
export async function answerSession(
sessionId: string,
questionId: string,
response: unknown,
projectId?: string,
): Promise<CeSession> {
const data = await request<{ session: CeSession }>(`/sessions/${encodeURIComponent(sessionId)}/answer`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ questionId, response, projectId }),
});
return data.session;
}
/** Resume an interrupted/error/awaiting session. `projectId` must match start (see answerSession). */
export async function resumeSession(sessionId: string, projectId?: string): Promise<CeSession> {
const data = await request<{ session: CeSession }>(`/sessions/${encodeURIComponent(sessionId)}/resume`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ projectId }),
});
return data.session;
}
/** List CE sessions, newest-activity first (optionally filtered by status/stage). */
export async function listSessions(
opts: { projectId?: string; status?: string; stage?: string } = {},
): Promise<CeSession[]> {
const data = await request<{ sessions: CeSession[] }>(
`/sessions${qp({ projectId: opts.projectId, status: opts.status, stage: opts.stage })}`,
);
return data.sessions;
}
/** Discard a session (disposes any live handle, deletes the row). `projectId` must match start. */
export async function deleteSession(sessionId: string, projectId?: string): Promise<void> {
await request<{ deleted: boolean }>(
`/sessions/${encodeURIComponent(sessionId)}${qp({ projectId })}`,
{ method: "DELETE" },
);
}
/** Poll the current persisted session state. `projectId` must match start (see answerSession). */
export async function getSession(sessionId: string, projectId?: string): Promise<CeSession> {
const data = await request<{ session: CeSession }>(`/sessions/${encodeURIComponent(sessionId)}${qp({ projectId })}`);
return data.session;
}

View File

@@ -0,0 +1,80 @@
import { useEffect, useMemo, useState } from "react";
import { listArtifacts } from "./api.js";
import type { DiscoveryResult } from "../../artifacts/discovery.js";
/**
* Short-TTL discovery cache keyed by `projectId` (the discovery scan has no
* per-row id, so the project is the cache unit). Mirrors the dashboard
* performance kit (docs/performance/dashboard-load.md): a 30s TTL balances
* freshness against repeated viewport-driven refetches.
*/
const CACHE_TTL_MS = 30_000;
const discoveryCache = new Map<string, { value: DiscoveryResult; expiresAt: number }>();
function cacheKey(projectId?: string): string {
return `discovery:${projectId ?? "__default__"}`;
}
/** Exposed for tests. */
export function __test_clearArtifactsCache(): void {
discoveryCache.clear();
}
export interface UseArtifactsResult {
result?: DiscoveryResult;
loading: boolean;
error?: string;
}
/**
* Discover CE artifacts for the active project. The fetch is viewport-gated via
* the `enabled` flag — when the CE view is offscreen/disabled it returns stable
* empty state and triggers no network request (performance kit). Results are
* served from a short-TTL cache to collapse repeated mounts.
*/
export function useArtifacts({
projectId,
enabled = true,
}: {
projectId?: string;
enabled?: boolean;
}): UseArtifactsResult {
const [result, setResult] = useState<DiscoveryResult | undefined>(() => {
const cached = discoveryCache.get(cacheKey(projectId));
return cached && cached.expiresAt > Date.now() ? cached.value : undefined;
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | undefined>();
useEffect(() => {
if (!enabled) return;
const key = cacheKey(projectId);
const cached = discoveryCache.get(key);
if (cached && cached.expiresAt > Date.now()) {
setResult(cached.value);
setError(undefined);
return;
}
const controller = new AbortController();
setLoading(true);
listArtifacts(projectId)
.then((value) => {
if (controller.signal.aborted) return;
discoveryCache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });
setResult(value);
setError(undefined);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
setError(err instanceof Error ? err.message : "Failed to load artifacts");
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [projectId, enabled]);
return useMemo(() => ({ result, loading, error }), [result, loading, error]);
}

View File

@@ -0,0 +1,217 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { CeSession, CeSessionStatus } from "../../session/session-store.js";
import {
answerSession as answerSessionApi,
getSession as getSessionApi,
resumeSession as resumeSessionApi,
startSession as startSessionApi,
} from "./api.js";
/**
* Injectable transport so component tests can drive the lifecycle without a
* network. Defaults to the real polling routes.
*/
export interface CeSessionTransport {
start(stage: string, opts: { message?: string; projectId?: string }): Promise<CeSession>;
answer(sessionId: string, questionId: string, response: unknown, projectId?: string): Promise<CeSession>;
resume(sessionId: string, projectId?: string): Promise<CeSession>;
get(sessionId: string, projectId?: string): Promise<CeSession>;
}
const defaultTransport: CeSessionTransport = {
start: (stage, opts) => startSessionApi(stage, opts),
answer: (id, qid, response, projectId) => answerSessionApi(id, qid, response, projectId),
resume: (id, projectId) => resumeSessionApi(id, projectId),
get: (id, projectId) => getSessionApi(id, projectId),
};
/** Statuses where no further polling is useful (settled or waiting on the user). */
const SETTLED: ReadonlySet<CeSessionStatus> = new Set([
"awaiting_input",
"completed",
"error",
"interrupted",
]);
/**
* Subscribe to live session push events. Called with the current sessionId +
* projectId and a callback to invoke when this session changes; returns an
* unsubscribe fn. Default is a no-op (polling-only) so the hook stays pure and
* node/jsdom tests don't touch the browser SSE bus; the dashboard view injects a
* real adapter built on the shared `/api/events` stream.
*/
export type CeSessionSubscribe = (
sessionId: string,
projectId: string | undefined,
onSessionEvent: () => void,
) => () => void;
const noopSubscribe: CeSessionSubscribe = () => () => {};
export interface UseCeSessionOptions {
/** Poll interval (ms) while a turn is running (status active/launching). */
pollIntervalMs?: number;
transport?: CeSessionTransport;
/** Live push subscription (default no-op = polling only). */
subscribe?: CeSessionSubscribe;
}
export interface UseCeSessionResult {
session?: CeSession;
/** True while a request (start/answer/resume) is in flight. */
busy: boolean;
error?: string;
start(stage: string, opts?: { message?: string; projectId?: string }): Promise<void>;
/** Adopt an EXISTING session (e.g. from the session list) as the active one. */
open(sessionId: string, opts?: { projectId?: string }): Promise<void>;
answer(questionId: string, response: unknown): Promise<void>;
resume(): Promise<void>;
reset(): void;
}
/**
* Drive a single CE stage session through its lifecycle: start → watch the
* live working output while the turn runs → render question → submit answer →
* continue → completed/error; resume an interrupted/error session.
*
* Turn execution is DETACHED server-side: start/answer/resume return as soon
* as the session row reflects the request (status `active`), and the client
* converges via push (subscribe) with polling as the fallback. While a turn is
* mid-flight, GET attaches `liveActivity` — the agent's streaming working
* output — so each refetch updates the live pane.
*/
export function useCeSession(options: UseCeSessionOptions = {}): UseCeSessionResult {
const transport = options.transport ?? defaultTransport;
const pollIntervalMs = options.pollIntervalMs ?? 1500;
const subscribe = options.subscribe ?? noopSubscribe;
const [session, setSession] = useState<CeSession | undefined>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | undefined>();
// Keep the live id for the polling effect without re-subscribing on every
// session field change.
const sessionIdRef = useRef<string | undefined>(undefined);
// The projectId used at start() selects the project-scoped store that owns the
// session row + live handle. Every later call (answer/resume/poll) MUST reuse
// it, or the request resolves a different store and the session isn't found.
const projectIdRef = useRef<string | undefined>(undefined);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const apply = useCallback((next: CeSession) => {
sessionIdRef.current = next.id;
if (mounted.current) setSession(next);
}, []);
const run = useCallback(
async (op: () => Promise<CeSession>) => {
setBusy(true);
setError(undefined);
try {
const next = await op();
apply(next);
} catch (err) {
if (mounted.current) setError(err instanceof Error ? err.message : String(err));
} finally {
if (mounted.current) setBusy(false);
}
},
[apply],
);
const start = useCallback(
(stage: string, opts: { message?: string; projectId?: string } = {}) => {
projectIdRef.current = opts.projectId;
return run(() => transport.start(stage, opts));
},
[run, transport],
);
// Adopt an existing session (started earlier, possibly in another view visit)
// as this hook's active session. Like start(), it pins the projectId used for
// every subsequent call — the session row lives in that project's store.
const open = useCallback(
(sessionId: string, opts: { projectId?: string } = {}) => {
projectIdRef.current = opts.projectId;
sessionIdRef.current = sessionId;
return run(() => transport.get(sessionId, opts.projectId));
},
[run, transport],
);
const answer = useCallback(
(questionId: string, response: unknown) => {
const id = sessionIdRef.current;
if (!id) return Promise.resolve();
return run(() => transport.answer(id, questionId, response, projectIdRef.current));
},
[run, transport],
);
const resume = useCallback(() => {
const id = sessionIdRef.current;
if (!id) return Promise.resolve();
return run(() => transport.resume(id, projectIdRef.current));
}, [run, transport]);
const reset = useCallback(() => {
sessionIdRef.current = undefined;
projectIdRef.current = undefined;
setSession(undefined);
setError(undefined);
setBusy(false);
}, []);
// Live push: when the host forwards a session event over SSE, refetch the
// persisted state immediately (lower latency than the poll interval). Polling
// below remains as a fallback when push isn't wired or an event is missed.
const sessionId = session?.id;
useEffect(() => {
if (!sessionId) return;
return subscribe(sessionId, projectIdRef.current, () => {
transport
.get(sessionId, projectIdRef.current)
.then((next) => {
if (mounted.current) apply(next);
})
.catch((err: unknown) => {
if (mounted.current) setError(err instanceof Error ? err.message : String(err));
});
});
}, [sessionId, subscribe, transport, apply]);
// Poll while a turn is mid-flight (active/launching) and we are not already
// issuing a request. Stops as soon as the session settles.
const status = session?.status;
useEffect(() => {
const id = sessionIdRef.current;
if (!id || busy) return;
if (!status || SETTLED.has(status)) return;
let cancelled = false;
const timer = setInterval(() => {
transport
.get(id, projectIdRef.current)
.then((next) => {
if (!cancelled) apply(next);
})
.catch((err: unknown) => {
if (!cancelled && mounted.current) {
setError(err instanceof Error ? err.message : String(err));
}
});
}, pollIntervalMs);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [status, busy, transport, apply, pollIntervalMs]);
return { session, busy, error, start, open, answer, resume, reset };
}

View File

@@ -0,0 +1,127 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { CeSession } from "../../session/session-store.js";
import { deleteSession as deleteSessionApi, listSessions as listSessionsApi } from "./api.js";
/**
* Injectable list transport so component tests can drive the session list
* without a network. Defaults to the real routes.
*/
export interface CeSessionsTransport {
list(projectId?: string): Promise<CeSession[]>;
remove(sessionId: string, projectId?: string): Promise<void>;
}
const defaultTransport: CeSessionsTransport = {
list: (projectId) => listSessionsApi({ projectId }),
remove: (id, projectId) => deleteSessionApi(id, projectId),
};
/**
* Subscribe to ANY CE plugin push event (no per-session filter — any session
* turn/question/complete should refresh the list). Returns an unsubscribe fn.
* Default no-op = polling only, same posture as useCeSession's subscribe.
*/
export type CeSessionsSubscribe = (onAnyEvent: () => void) => () => void;
export interface UseCeSessionsOptions {
projectId?: string;
/** Gate fetching (mirrors useArtifacts' viewport gating). Default true. */
enabled?: boolean;
/** Poll interval (ms) while any session has a turn in flight. */
pollIntervalMs?: number;
transport?: CeSessionsTransport;
subscribe?: CeSessionsSubscribe;
}
export interface UseCeSessionsResult {
sessions: CeSession[];
loading: boolean;
error?: string;
/** Re-fetch the list now (e.g. after launching or closing a session). */
refresh(): Promise<void>;
/** Discard a session and refresh the list. */
remove(sessionId: string): Promise<void>;
}
/** Statuses with an agent turn in flight — the list keeps polling while any exist. */
const IN_FLIGHT = new Set<CeSession["status"]>(["active", "launching"]);
/**
* Multi-session management list (server state is already multi-session: each
* row is an independent pipeline run with its own live handle). Refreshes on
* any plugin push event, and polls as a fallback while any session is
* mid-turn so progress made in another tab/process still shows up.
*/
export function useCeSessions(options: UseCeSessionsOptions = {}): UseCeSessionsResult {
const { projectId } = options;
const enabled = options.enabled ?? true;
const pollIntervalMs = options.pollIntervalMs ?? 5000;
const transport = options.transport ?? defaultTransport;
const subscribe = options.subscribe;
const [sessions, setSessions] = useState<CeSession[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | undefined>();
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const refresh = useCallback(async () => {
try {
const next = await transport.list(projectId);
if (mounted.current) {
setSessions(next);
setError(undefined);
}
} catch (err) {
if (mounted.current) setError(err instanceof Error ? err.message : String(err));
} finally {
if (mounted.current) setLoading(false);
}
}, [transport, projectId]);
// Initial fetch (and on project switch).
useEffect(() => {
if (!enabled) return;
setLoading(true);
void refresh();
}, [enabled, refresh]);
// Live push: any CE event means some session changed — refresh the list.
useEffect(() => {
if (!enabled || !subscribe) return;
return subscribe(() => {
void refresh();
});
}, [enabled, subscribe, refresh]);
// Poll fallback only while a turn is actually in flight somewhere.
const anyInFlight = sessions.some((s) => IN_FLIGHT.has(s.status));
useEffect(() => {
if (!enabled || !anyInFlight) return;
const timer = setInterval(() => {
void refresh();
}, pollIntervalMs);
return () => clearInterval(timer);
}, [enabled, anyInFlight, pollIntervalMs, refresh]);
const remove = useCallback(
async (sessionId: string) => {
try {
await transport.remove(sessionId, projectId);
} catch (err) {
if (mounted.current) setError(err instanceof Error ? err.message : String(err));
return;
}
await refresh();
},
[transport, projectId, refresh],
);
return { sessions, loading, error, refresh, remove };
}

Some files were not shown because too many files have changed in this diff Show More