Merge branch 'main' into gsxdsm/fileschanged
This commit is contained in:
12
.changeset/compound-engineering-plugin-scaffold.md
Normal file
12
.changeset/compound-engineering-plugin-scaffold.md
Normal 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.
|
||||
9
.changeset/fix-stranded-done-feature-recovery.md
Normal file
9
.changeset/fix-stranded-done-feature-recovery.md
Normal 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.
|
||||
5
.changeset/fn-5937-clear-auto-pause-retry.md
Normal file
5
.changeset/fn-5937-clear-auto-pause-retry.md
Normal 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.
|
||||
5
.changeset/fn-5949-pr-conflict-resolution.md
Normal file
5
.changeset/fn-5949-pr-conflict-resolution.md
Normal 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.
|
||||
54
CONCEPTS.md
54
CONCEPTS.md
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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]`)
|
||||
|
||||
104
docs/goals-refinement-gate.md
Normal file
104
docs/goals-refinement-gate.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
114
docs/plugins/compound-engineering.md
Normal file
114
docs/plugins/compound-engineering.md
Normal 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.
|
||||
@@ -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`
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
59
packages/cli/src/__tests__/task-retry.test.ts
Normal file
59
packages/cli/src/__tests__/task-retry.test.ts
Normal 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);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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)`);
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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];
|
||||
|
||||
105
packages/core/src/__tests__/interactive-ai-session-seam.test.ts
Normal file
105
packages/core/src/__tests__/interactive-ai-session-seam.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`. */
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
258
packages/dashboard/src/pr-conflict-resolver.ts
Normal file
258
packages/dashboard/src/pr-conflict-resolver.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
192
packages/engine/src/__tests__/interactive-ai-session.test.ts
Normal file
192
packages/engine/src/__tests__/interactive-ai-session.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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.
|
||||
|
||||
349
packages/engine/src/interactive-ai-session.ts
Normal file
349
packages/engine/src/interactive-ai-session.ts
Normal 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 };
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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();
|
||||
|
||||
3
plugins/fusion-plugin-compound-engineering/.gitignore
vendored
Normal file
3
plugins/fusion-plugin-compound-engineering/.gitignore
vendored
Normal 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/
|
||||
191
plugins/fusion-plugin-compound-engineering/README.md
Normal file
191
plugins/fusion-plugin-compound-engineering/README.md
Normal 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.
|
||||
56
plugins/fusion-plugin-compound-engineering/manifest.json
Normal file
56
plugins/fusion-plugin-compound-engineering/manifest.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
37
plugins/fusion-plugin-compound-engineering/package.json
Normal file
37
plugins/fusion-plugin-compound-engineering/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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$/);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
34
plugins/fusion-plugin-compound-engineering/src/dashboard-interop.d.ts
vendored
Normal file
34
plugins/fusion-plugin-compound-engineering/src/dashboard-interop.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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'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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user