# FlowPilot Phase 8 — Fix Outcome Banner Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the task-lane Suggested Fix card with a chat-composer-anchored **Proposal Banner** that owns the full lifecycle of a proposed fix (Proposed → Verifying → Success / Failed / Partial / Dismissed), with explicit outcome tracking, AI chat-inferred outcomes via a new `[FIX_OUTCOME]` marker, and implicit signals wired to the Resolve / Escalate actions. **Architecture:** - Backend extends `session_suggested_fixes` with outcome columns (`status`, `applied_at`, `verified_at`, `partial_notes`, `failure_reason`) and adds a PATCH `/outcome` endpoint. `unified_chat_service` learns a new `[FIX_OUTCOME]` marker that writes outcome proposals (not terminal — engineer confirms). `ASSISTANT_SYSTEM_PROMPT` gains marker instructions in placeholder form (anti-parrot compliant). - Frontend replaces `SuggestedFix.tsx` (task-lane card) with a new `ProposalBanner.tsx` docked above the chat composer. The banner is a state-driven component (`proposed | verifying | partial | ai_confirming | dismissed`) with a sibling `EscalateInterceptDialog` popover. AssistantChatPage orchestrates state transitions and wires the implicit signals (Resolve auto-success, Escalate intercept, post-apply-message nudge). **Tech Stack:** Python 3.11 + FastAPI + SQLAlchemy 2.0 async + Alembic + Pydantic v2 (backend); React 19 + Vite + TypeScript + Tailwind v4 (frontend). pytest for backend tests; frontend verified via `tsc -b` + browser smoke-test (no unit-test harness for new components — the codebase has no component test pattern per the handoff note on Phase 7 visual verification). **Design reference:** [mockups/06-slide-up-banner.html](mockups/06-slide-up-banner.html) (banner look & feel), [mockups/07-verify-states.html](mockups/07-verify-states.html) (Verifying / Partial / AI-inferred / Nudge / Escalate-intercept states). --- ## File Structure ### Backend — new - `backend/alembic/versions/_fix_outcome_tracking.py` — migration - `backend/tests/test_fix_outcome_endpoint.py` — endpoint integration test - `backend/tests/test_fix_outcome_marker.py` — marker-parser test ### Backend — modified - `backend/app/models/session_suggested_fix.py` — add outcome columns - `backend/app/schemas/session_suggested_fix.py` — add `SessionSuggestedFixOutcomeRequest`, extend response with outcome fields - `backend/app/api/endpoints/session_suggested_fixes.py` — add PATCH `/outcome` endpoint - `backend/app/services/unified_chat_service.py` — add `[FIX_OUTCOME]` parser + persist step - `backend/app/services/assistant_chat_service.py` — add `[FIX_OUTCOME]` instructions to `ASSISTANT_SYSTEM_PROMPT` - `backend/tests/test_prompt_anti_parrot.py` — extend known-leaked-token list if needed ### Frontend — new - `frontend/src/components/pilot/ProposalBanner.tsx` — state-driven banner, all five rendering states - `frontend/src/components/pilot/EscalateInterceptDialog.tsx` — popover dialog - `frontend/src/hooks/useFixOutcome.ts` — hook wrapping outcome state + patch call ### Frontend — modified - `frontend/src/api/sessionSuggestedFixes.ts` — add `patchOutcome` method + extend `SessionSuggestedFix` type with outcome fields + export `FixStatus` type - `frontend/src/pages/AssistantChatPage.tsx` — mount `ProposalBanner` above composer, wire state transitions, intercept Escalate, auto-mark success on Resolve-while-verifying, remove `suggestedFixSlot` prop usage - `frontend/src/components/pilot/TaskLane.tsx` — stop passing `suggestedFixSlot` (leave prop in place for other callers, pass null) ### Frontend — deleted - `frontend/src/components/pilot/sections/SuggestedFix.tsx` — superseded by the banner; delete after integration is verified --- ## Task 1: DB migration + model extension for outcome tracking **Files:** - Create: `backend/alembic/versions/_fix_outcome_tracking.py` - Modify: `backend/app/models/session_suggested_fix.py` - [ ] **Step 1: Generate the migration file** From `backend/` with venv active: ```bash alembic revision -m "add fix outcome tracking columns to session_suggested_fixes" ``` Alembic prints the new file path. Do NOT pass `--rev-id` (Lesson: always let alembic generate the hex hash). - [ ] **Step 2: Write the migration body** Replace the generated file contents with (keep the `revision`/`down_revision` lines alembic wrote): ```python """add fix outcome tracking columns to session_suggested_fixes Adds: status, applied_at, verified_at, partial_notes, failure_reason, ai_outcome_proposal. status is the outcome dimension (did the fix work?), orthogonal to the existing user_decision column (which script-path the engineer took). """ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql # revision identifiers — leave whatever alembic generated in place revision = "" down_revision = "" branch_labels = None depends_on = None def upgrade() -> None: op.add_column( "session_suggested_fixes", sa.Column( "status", sa.String(length=20), nullable=False, server_default=sa.text("'proposed'"), ), ) op.add_column( "session_suggested_fixes", sa.Column("applied_at", sa.DateTime(timezone=True), nullable=True), ) op.add_column( "session_suggested_fixes", sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True), ) op.add_column( "session_suggested_fixes", sa.Column("partial_notes", sa.Text(), nullable=True), ) op.add_column( "session_suggested_fixes", sa.Column("failure_reason", sa.Text(), nullable=True), ) op.add_column( "session_suggested_fixes", sa.Column("ai_outcome_proposal", postgresql.JSONB(), nullable=True), ) # Backfill before constraint creation so dismissed rows satisfy the new CHECK. op.execute( "UPDATE session_suggested_fixes " "SET status = 'dismissed' " "WHERE user_decision = 'dismissed'" ) op.create_check_constraint( "ck_session_suggested_fixes_status", "session_suggested_fixes", "status IN ('proposed', 'applied_success', 'applied_failed', " "'applied_partial', 'dismissed')", ) # Drop the server_default — application code owns defaults from here on # (matches the project's general pattern of keeping defaults in models). op.alter_column("session_suggested_fixes", "status", server_default=None) def downgrade() -> None: op.drop_constraint("ck_session_suggested_fixes_status", "session_suggested_fixes", type_="check") op.drop_column("session_suggested_fixes", "ai_outcome_proposal") op.drop_column("session_suggested_fixes", "failure_reason") op.drop_column("session_suggested_fixes", "partial_notes") op.drop_column("session_suggested_fixes", "verified_at") op.drop_column("session_suggested_fixes", "applied_at") op.drop_column("session_suggested_fixes", "status") ``` - [ ] **Step 3: Update the SQLAlchemy model** Edit `backend/app/models/session_suggested_fix.py`. Inside the `__table_args__` tuple, append after the existing `user_decision` check: ```python CheckConstraint( "status IN ('proposed', 'applied_success', 'applied_failed', " "'applied_partial', 'dismissed')", name="ck_session_suggested_fixes_status", ), ``` Inside the class body, add these columns after `user_decision`: ```python status: Mapped[str] = mapped_column( String(20), nullable=False, default="proposed" ) applied_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) verified_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) partial_notes: Mapped[str | None] = mapped_column(Text, nullable=True) failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True) ai_outcome_proposal: Mapped[dict[str, Any] | None] = mapped_column( JSONB, nullable=True ) ``` - [ ] **Step 4: Apply the migration locally** ```bash cd backend && alembic upgrade head ``` Expected: no errors; `\d session_suggested_fixes` in psql shows the five new columns and the new check constraint. Verify via: ```bash docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow \ -c "\d session_suggested_fixes" | grep -E "status|applied_at|verified_at|partial_notes|failure_reason" ``` Expected: all five column names print. - [ ] **Step 5: Commit** ```bash git add backend/alembic/versions/ backend/app/models/session_suggested_fix.py git commit -m "feat(pilot): add outcome tracking columns to session_suggested_fixes" ``` --- ## Task 2: Pydantic schemas for outcome **Files:** - Modify: `backend/app/schemas/session_suggested_fix.py` - [ ] **Step 1: Extend the response schema and add the request schema** At the top of `backend/app/schemas/session_suggested_fix.py`, after the existing `UserDecision` literal, add: ```python FixStatus = Literal[ "proposed", "applied_success", "applied_failed", "applied_partial", "dismissed", ] ``` Extend `SessionSuggestedFixResponse` with the new fields (add after `user_decision`): ```python status: FixStatus applied_at: datetime | None verified_at: datetime | None partial_notes: str | None failure_reason: str | None ``` After `SessionSuggestedFixDecisionResponse`, add: ```python class SessionSuggestedFixOutcomeRequest(BaseModel): """Engineer-reported outcome of applying a suggested fix. Writes to session_suggested_fixes.status and companion columns. This is orthogonal to `user_decision` (which records which script-path the engineer took); outcome captures whether the fix actually worked. Allowed transitions: - from `proposed` or `applied_partial`: any outcome is valid (partial is parked, not terminal — the engineer may update notes, abandon via dismiss, or advance to success/failed) - from any terminal outcome (`applied_success`, `applied_failed`, `dismissed`): server returns 409 """ outcome: Literal[ "applied_success", "applied_failed", "applied_partial", "dismissed" ] # Required for applied_partial, optional for applied_failed, ignored otherwise. notes: str | None = Field(None, max_length=500) ``` - [ ] **Step 2: Commit** ```bash git add backend/app/schemas/session_suggested_fix.py git commit -m "feat(pilot): pydantic schemas for fix outcome patch" ``` --- ## Task 3: PATCH /outcome endpoint + test **Files:** - Modify: `backend/app/api/endpoints/session_suggested_fixes.py` - Create: `backend/tests/test_fix_outcome_endpoint.py` - [ ] **Step 1: Write the failing test** Create `backend/tests/test_fix_outcome_endpoint.py`: ```python """Integration tests for the fix outcome endpoint. These tests rely on the `engineer_client` + `seed_ai_session_with_fix` fixtures from `conftest.py` (existing pattern used by the decision-endpoint tests). """ from __future__ import annotations import pytest from httpx import AsyncClient @pytest.mark.asyncio async def test_patch_outcome_marks_success( engineer_client: AsyncClient, seed_ai_session_with_fix ): session_id, fix_id = seed_ai_session_with_fix r = await engineer_client.patch( f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome", json={"outcome": "applied_success"}, ) assert r.status_code == 200, r.text body = r.json() assert body["status"] == "applied_success" assert body["verified_at"] is not None @pytest.mark.asyncio async def test_patch_outcome_partial_requires_notes( engineer_client: AsyncClient, seed_ai_session_with_fix ): session_id, fix_id = seed_ai_session_with_fix r = await engineer_client.patch( f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome", json={"outcome": "applied_partial"}, ) assert r.status_code == 400 assert "notes" in r.text.lower() @pytest.mark.asyncio async def test_partial_to_success_allowed( engineer_client: AsyncClient, seed_ai_session_with_fix ): session_id, fix_id = seed_ai_session_with_fix r1 = await engineer_client.patch( f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome", json={"outcome": "applied_partial", "notes": "ran cred clear only"}, ) assert r1.status_code == 200 r2 = await engineer_client.patch( f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome", json={"outcome": "applied_success"}, ) assert r2.status_code == 200 assert r2.json()["status"] == "applied_success" @pytest.mark.asyncio async def test_terminal_outcome_is_locked( engineer_client: AsyncClient, seed_ai_session_with_fix ): session_id, fix_id = seed_ai_session_with_fix r1 = await engineer_client.patch( f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome", json={"outcome": "applied_failed", "notes": "no change"}, ) assert r1.status_code == 200 r2 = await engineer_client.patch( f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome", json={"outcome": "applied_success"}, ) assert r2.status_code == 409 ``` - [ ] **Step 2: Run the test — confirm it fails** ```bash cd backend && pytest tests/test_fix_outcome_endpoint.py -v --override-ini="addopts=" ``` Expected: four failures — endpoint doesn't exist yet (404 on PATCH). - [ ] **Step 3: Add the endpoint** Open `backend/app/api/endpoints/session_suggested_fixes.py`. Add near the other handlers (after the `decision` endpoint): ```python @router.patch( "/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome", response_model=SessionSuggestedFixResponse, tags=["flowpilot"], ) async def patch_suggested_fix_outcome( session_id: UUID, fix_id: UUID, body: SessionSuggestedFixOutcomeRequest, current_user: User = Depends(require_engineer_or_admin), db: AsyncSession = Depends(get_db), ) -> SessionSuggestedFix: """Record the engineer's outcome for an applied fix. See SessionSuggestedFixOutcomeRequest for the transition rules. """ now = datetime.now(timezone.utc) fix = await db.scalar( select(SessionSuggestedFix).where( SessionSuggestedFix.id == fix_id, SessionSuggestedFix.session_id == session_id, ) ) if fix is None: raise HTTPException(status_code=404, detail="Suggested fix not found") # Partial requires a note; without one we can't tell anyone (including # the AI on the next turn) what actually got done. if body.outcome == "applied_partial" and not (body.notes and body.notes.strip()): raise HTTPException( status_code=400, detail="notes are required when outcome is applied_partial", ) TERMINAL = {"applied_success", "applied_failed", "dismissed"} if fix.status in TERMINAL: raise HTTPException( status_code=409, detail=f"Fix is already in terminal status {fix.status!r}", ) fix.status = body.outcome if body.outcome == "applied_partial": fix.partial_notes = (body.notes or "").strip() or None # Partial is a parked state — no verified_at yet. elif body.outcome == "applied_failed": fix.failure_reason = (body.notes or "").strip() or None fix.verified_at = now elif body.outcome == "applied_success": fix.verified_at = now # applied_at is stamped by whoever calls this in the apply flow; if it's # still null at outcome-set time, set it to now (handles the case where # the AI emits [FIX_OUTCOME] before the engineer clicks Apply explicitly). if fix.applied_at is None and body.outcome != "dismissed": fix.applied_at = now await db.commit() await db.refresh(fix) return fix ``` Add missing imports at the top of the file if not already present: ```python from datetime import datetime, timezone from app.schemas.session_suggested_fix import SessionSuggestedFixOutcomeRequest ``` - [ ] **Step 4: Run the tests — confirm they pass** ```bash cd backend && pytest tests/test_fix_outcome_endpoint.py -v --override-ini="addopts=" ``` Expected: 4 passed. - [ ] **Step 5: Commit** ```bash git add backend/app/api/endpoints/session_suggested_fixes.py backend/tests/test_fix_outcome_endpoint.py git commit -m "feat(pilot): PATCH /suggested-fixes/:id/outcome endpoint + tests" ``` --- ## Task 4: `[FIX_OUTCOME]` marker parser + test **Files:** - Modify: `backend/app/services/unified_chat_service.py` - Create: `backend/tests/test_fix_outcome_marker.py` - [ ] **Step 1: Write the failing test** Create `backend/tests/test_fix_outcome_marker.py`: ```python """Unit tests for the [FIX_OUTCOME] marker parser.""" from __future__ import annotations from app.services.unified_chat_service import _parse_fix_outcome_marker def test_parses_success_outcome(): ai = ( "Great news — that confirms the root cause.\n\n" "[FIX_OUTCOME]\n" '{"fix_id":"11111111-1111-1111-1111-111111111111",' '"outcome":"success","reason":"user said the fix worked"}\n' "[/FIX_OUTCOME]\n" ) cleaned, parsed = _parse_fix_outcome_marker(ai) assert "[FIX_OUTCOME]" not in cleaned assert "confirms the root cause" in cleaned assert parsed == { "fix_id": "11111111-1111-1111-1111-111111111111", "outcome": "success", "reason": "user said the fix worked", } def test_parses_failure_outcome(): ai = ( "[FIX_OUTCOME]\n" '{"fix_id":"22222222-2222-2222-2222-222222222222",' '"outcome":"failure","reason":"user reports still broken"}\n' "[/FIX_OUTCOME]" ) _, parsed = _parse_fix_outcome_marker(ai) assert parsed["outcome"] == "failure" def test_missing_marker_returns_none(): ai = "no marker here" cleaned, parsed = _parse_fix_outcome_marker(ai) assert cleaned == ai assert parsed is None def test_invalid_json_is_dropped(): ai = "[FIX_OUTCOME]\nnot-json\n[/FIX_OUTCOME]" cleaned, parsed = _parse_fix_outcome_marker(ai) assert "[FIX_OUTCOME]" not in cleaned assert parsed is None def test_unknown_outcome_rejected(): ai = ( "[FIX_OUTCOME]\n" '{"fix_id":"33333333-3333-3333-3333-333333333333",' '"outcome":"maybe","reason":"x"}\n' "[/FIX_OUTCOME]" ) _, parsed = _parse_fix_outcome_marker(ai) assert parsed is None ``` - [ ] **Step 2: Run the test — confirm it fails** ```bash cd backend && pytest tests/test_fix_outcome_marker.py -v --override-ini="addopts=" ``` Expected: `ImportError: cannot import name '_parse_fix_outcome_marker'` — parser doesn't exist yet. - [ ] **Step 3: Implement the parser** Open `backend/app/services/unified_chat_service.py`. Right after `_parse_suggest_fix_marker`, add: ```python def _parse_fix_outcome_marker( ai_content: str, ) -> tuple[str, dict[str, Any] | None]: """Extract a single [FIX_OUTCOME]...[/FIX_OUTCOME] JSON block. Block shape: {"fix_id": "", "outcome": "success"|"failure"|"partial", "reason": ""} Emitted by the AI when the engineer clearly indicates in chat that a prior suggested fix worked, didn't work, or was partially applied. The marker PROPOSES an outcome — the engineer confirms via the UI. Only the last block in a response is honored. """ blocks = list(re.finditer( r"\[FIX_OUTCOME\]\s*([\s\S]*?)\s*\[/FIX_OUTCOME\]", ai_content, )) if not blocks: return ai_content, None last = blocks[-1] raw = last.group(1).strip() if raw.startswith("```"): raw = re.sub(r"^```(?:json)?\s*", "", raw) raw = re.sub(r"\s*```$", "", raw) cleaned = re.sub( r"\[FIX_OUTCOME\]\s*[\s\S]*?\s*\[/FIX_OUTCOME\]", "", ai_content, ).strip() try: data = json.loads(raw) except (json.JSONDecodeError, ValueError) as e: logger.warning("Failed to parse [FIX_OUTCOME] block: %s", e) return cleaned, None if not isinstance(data, dict): return cleaned, None fix_id = str(data.get("fix_id") or "").strip() outcome = str(data.get("outcome") or "").strip().lower() reason = str(data.get("reason") or "").strip() if not fix_id or outcome not in {"success", "failure", "partial"}: logger.warning("[FIX_OUTCOME] missing/invalid fields, dropping") return cleaned, None return cleaned, {"fix_id": fix_id, "outcome": outcome, "reason": reason} ``` - [ ] **Step 4: Wire the parser into the AI-response handler** In the same file, find the block where `_parse_suggest_fix_marker` is called during AI-response processing. Add a companion call right after it. The exact location is the function that processes the raw AI response before persisting messages — search for `_parse_suggest_fix_marker(` to find it. Inside that function, after the suggest-fix call: ```python ai_content, outcome_proposal = _parse_fix_outcome_marker(ai_content) ``` Then, after the suggest-fix persistence block, add: ```python if outcome_proposal is not None: # The AI is proposing an outcome; we persist it as an # ai_outcome_proposal event on the session so the frontend can # surface the "AI detected outcome — confirm?" banner. The # session's active fix is not mutated until the engineer confirms. await _record_ai_outcome_proposal( db=db, session=session, proposal=outcome_proposal, ) ``` And add the helper near `_persist_suggested_fix`: ```python async def _record_ai_outcome_proposal( *, db: AsyncSession, session: AISession, proposal: dict[str, Any], ) -> None: """Persist the AI's proposed outcome as a pending event on the active fix. We store this on a new JSONB column on the fix (ai_outcome_proposal) rather than as a separate event table — one active proposal at a time, overwritten on each new [FIX_OUTCOME]. Frontend polls the active fix and renders the AI-confirming banner state when this is non-null. """ from uuid import UUID as _UUID try: fix_id = _UUID(proposal["fix_id"]) except (ValueError, KeyError): logger.warning("[FIX_OUTCOME] invalid fix_id, dropping") return await db.execute( update(SessionSuggestedFix) .where( SessionSuggestedFix.id == fix_id, SessionSuggestedFix.session_id == session.id, ) .values(ai_outcome_proposal=proposal) ) ``` - [ ] **Step 5: Run the marker tests — confirm they pass** ```bash cd backend && pytest tests/test_fix_outcome_marker.py tests/test_fix_outcome_endpoint.py -v --override-ini="addopts=" ``` Expected: 9 passed (5 marker + 4 endpoint). - [ ] **Step 6: Commit** ```bash git add backend/ git commit -m "feat(pilot): [FIX_OUTCOME] marker parser + ai_outcome_proposal column" ``` --- ## Task 5: System prompt update + anti-parrot guardrail **Files:** - Modify: `backend/app/services/assistant_chat_service.py` — `ASSISTANT_SYSTEM_PROMPT` is the prompt used by `unified_chat_service._call_ai`; `flowpilot_engine.py` has a separate prompt for structured JSON flows that does not use chat markers - Modify: `backend/tests/test_prompt_anti_parrot.py` - [ ] **Step 1: Locate the system prompt** ```bash grep -rln "ASSISTANT_SYSTEM_PROMPT\s*=" /config/workspace/resolutionflow/backend/app/ ``` Open whichever file owns the constant. - [ ] **Step 2: Append the `[FIX_OUTCOME]` instructions to the prompt** Inside the prompt string, in the markers section, add a new block alongside the existing `[SUGGEST_FIX]` instructions. **Use placeholder syntax exclusively — never literal values** (anti-parrot lesson): ``` ## Reporting fix outcome with [FIX_OUTCOME] When the engineer clearly indicates in chat that a previously proposed fix worked, didn't work, or was partially applied, emit a [FIX_OUTCOME] marker on its own lines. This surfaces a "confirm outcome?" banner in the UI — it does NOT mark the fix resolved on its own. Emit [FIX_OUTCOME] when: - the engineer states the user's problem is resolved after applying the fix (e.g. affirmative resolution language → outcome="success") - the engineer states the issue persists after applying the fix (→ outcome="failure") - the engineer describes applying only part of the fix (→ outcome="partial") Do NOT emit [FIX_OUTCOME] when: - the engineer is still verifying (user rebooting, testing, etc.) - the outcome is ambiguous or inferred rather than stated - no [SUGGEST_FIX] has been emitted this session Format (one block, on its own lines, placeholders only): [FIX_OUTCOME] {"fix_id": "", "outcome": "", "reason": ""} [/FIX_OUTCOME] ``` - [ ] **Step 3: Update the anti-parrot test** Open `backend/tests/test_prompt_anti_parrot.py`. The existing test scans for literal tokens. If `[FIX_OUTCOME]` introduces any new tokens worth guarding (none expected — all placeholders are `<…>` style), add them to the leaked-token list. If nothing new, move on — the existing scanner will catch any literal JSON value that sneaks in. - [ ] **Step 4: Run the anti-parrot test** ```bash cd backend && pytest tests/test_prompt_anti_parrot.py -v --override-ini="addopts=" ``` Expected: pass. If it fails with a literal-value complaint, re-read the prompt and swap any concrete fix_id / outcome / reason you accidentally wrote into `` form. - [ ] **Step 5: Commit** ```bash git add backend/app/services/assistant_chat_service.py backend/tests/test_prompt_anti_parrot.py git commit -m "feat(pilot): [FIX_OUTCOME] system prompt instructions" ``` --- ## Task 6: Frontend types + API client **Files:** - Modify: `frontend/src/api/sessionSuggestedFixes.ts` - [ ] **Step 1: Extend the types and add the patch method** At the top of `frontend/src/api/sessionSuggestedFixes.ts`, after `UserDecision`, add: ```ts export type FixStatus = | 'proposed' | 'applied_success' | 'applied_failed' | 'applied_partial' | 'dismissed' export type FixOutcome = | 'applied_success' | 'applied_failed' | 'applied_partial' | 'dismissed' export interface AIOutcomeProposal { fix_id: string outcome: 'success' | 'failure' | 'partial' reason: string } ``` Extend `SessionSuggestedFix` to include the new fields (add after `user_decision`): ```ts status: FixStatus applied_at: string | null verified_at: string | null partial_notes: string | null failure_reason: string | null ai_outcome_proposal: AIOutcomeProposal | null ``` Add a new method on `sessionSuggestedFixesApi` (after `recordDecision`): ```ts /** * Record the outcome of applying a suggested fix. Transitions: * - from 'proposed' or 'applied_partial': any outcome is valid * (partial→partial updates notes, partial→dismissed abandons) * - terminal statuses (applied_success, applied_failed, dismissed) are locked (server returns 409) */ async patchOutcome( sessionId: string, fixId: string, outcome: FixOutcome, notes?: string, ): Promise { const r = await apiClient.patch( `/ai-sessions/${sessionId}/suggested-fixes/${fixId}/outcome`, { outcome, notes }, ) return r.data }, ``` - [ ] **Step 2: Verify types compile** ```bash cd frontend && npx tsc -b ``` Expected: clean build, no errors. - [ ] **Step 3: Commit** ```bash git add frontend/src/api/sessionSuggestedFixes.ts git commit -m "feat(pilot): frontend fix-outcome types + patch API" ``` --- ## Task 7: ProposalBanner component — core + Proposed state **Files:** - Create: `frontend/src/components/pilot/ProposalBanner.tsx` - [ ] **Step 1: Create the component scaffold** Create `frontend/src/components/pilot/ProposalBanner.tsx`: ```tsx /** * ProposalBanner — chat-composer-anchored banner that carries the lifecycle * of a suggested fix from Proposed → Verifying → terminal outcome. * * Replaces the task-lane SuggestedFix card (Phase 8). The banner renders * above the chat composer in AssistantChatPage. Parent owns the fix record * and the outcome mutations; this component renders + dispatches callbacks. * * Visual reference: docs/FlowAssist_Migration/mockups/06-slide-up-banner.html * + 07-verify-states.html. */ import { useState } from 'react' import { Sparkles, X, Check, Info, ChevronDown, MoreHorizontal } from 'lucide-react' import { cn } from '@/lib/utils' import type { SessionSuggestedFix, FixOutcome, } from '@/api/sessionSuggestedFixes' export type BannerMode = | 'proposed' // AI just proposed; engineer hasn't applied yet | 'verifying' // Engineer clicked Apply; awaiting outcome | 'partial' // Applied partially; awaiting finish or terminal outcome | 'ai_confirming' // AI emitted [FIX_OUTCOME]; engineer confirms | 'nudge' // Compact nudge shown after N post-apply messages export interface ProposalBannerProps { fix: SessionSuggestedFix mode: BannerMode onApply: () => void onDismiss: () => void onOutcome: (outcome: FixOutcome, notes?: string) => void onAcceptAIProposal: () => void onRejectAIProposal: () => void /** Collapsed variant shown as a thin single-line strip. */ collapsed?: boolean onToggleCollapsed?: () => void } export function ProposalBanner(props: ProposalBannerProps) { if (props.collapsed) return switch (props.mode) { case 'proposed': return case 'verifying': return case 'partial': return case 'ai_confirming': return case 'nudge': return } } function ProposedBanner({ fix, onApply, onDismiss }: ProposalBannerProps) { return (
Suggested Fix {fix.confidence_pct}% confidence
{fix.title}
{fix.description}
{fix.script_template_id && (
Matches an existing Script Library template — one-click apply
)}
) } // Placeholder renderers — filled in Task 8 / 9. function VerifyingBanner(_: ProposalBannerProps) { return null } function PartialBanner(_: ProposalBannerProps) { return null } function AIConfirmingBanner(_: ProposalBannerProps) { return null } function NudgeBanner(_: ProposalBannerProps) { return null } function CollapsedBanner(_: ProposalBannerProps) { return null } export default ProposalBanner ``` - [ ] **Step 2: Add the slide-up animation** Open `frontend/src/index.css`. Add near the existing animations: ```css @keyframes slide-up { from { transform: translateY(14px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } .animate-slide-up { animation: slide-up 320ms cubic-bezier(.22,.9,.28,1) both; } ``` - [ ] **Step 3: Compile + visual smoke test** ```bash cd frontend && npx tsc -b ``` Expected: clean. To visually check, mount it ad-hoc by editing `AssistantChatPage.tsx` temporarily to render ` {}} onDismiss={() => {}} onOutcome={() => {}} onAcceptAIProposal={() => {}} onRejectAIProposal={() => {}} />` above the composer. Revert the edit before commit. - [ ] **Step 4: Commit** ```bash git add frontend/src/components/pilot/ProposalBanner.tsx frontend/src/index.css git commit -m "feat(pilot): ProposalBanner scaffold + Proposed state" ``` --- ## Task 8: ProposalBanner — Verifying + Partial states **Files:** - Modify: `frontend/src/components/pilot/ProposalBanner.tsx` - [ ] **Step 1: Implement VerifyingBanner** Replace the `function VerifyingBanner` placeholder with: ```tsx function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) { const [showOverflow, setShowOverflow] = useState(false) const appliedLabel = fix.applied_at ? `Applied ${formatRelativeMinutes(fix.applied_at)}` : 'Applied' return (
Verifying {appliedLabel}
Did "{fix.title}" work?
Mark the outcome so the AI can either close the session with this as the resolution, or propose something else.
{showOverflow && (
)}
) } function formatRelativeMinutes(iso: string): string { const then = new Date(iso).getTime() const mins = Math.max(0, Math.round((Date.now() - then) / 60000)) if (mins === 0) return 'just now' if (mins === 1) return '1m ago' return `${mins}m ago` } ``` Add the pulse animation to `frontend/src/index.css`: ```css @keyframes pulse-amber { 0% { box-shadow: 0 0 0 0 rgba(251,191,36,0.45); } 70% { box-shadow: 0 0 0 10px rgba(251,191,36,0); } 100% { box-shadow: 0 0 0 0 rgba(251,191,36,0); } } .animate-pulse-amber { animation: pulse-amber 1.6s infinite; } ``` - [ ] **Step 2: Implement PartialBanner** Replace the `function PartialBanner` placeholder with: ```tsx function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) { return (
Partially applied Parked
{fix.title}
{fix.partial_notes && (
Note {fix.partial_notes}
)}
) } ``` - [ ] **Step 3: Verify types compile** ```bash cd frontend && npx tsc -b ``` Expected: clean. - [ ] **Step 4: Commit** ```bash git add frontend/src/components/pilot/ProposalBanner.tsx frontend/src/index.css git commit -m "feat(pilot): banner Verifying + Partial states" ``` --- ## Task 9: ProposalBanner — AIConfirming + Nudge + Collapsed states **Files:** - Modify: `frontend/src/components/pilot/ProposalBanner.tsx` - [ ] **Step 1: Implement AIConfirmingBanner** Replace the placeholder: ```tsx function AIConfirmingBanner({ fix, onAcceptAIProposal, onRejectAIProposal }: ProposalBannerProps) { const proposal = fix.ai_outcome_proposal if (!proposal) return null const isSuccess = proposal.outcome === 'success' return (
AI detected outcome {isSuccess ? 'Success' : proposal.outcome === 'failure' ? 'Failure' : 'Partial'}
AI thinks the fix {isSuccess ? 'resolved the issue' : proposal.outcome === 'failure' ? 'didn\'t work' : 'was partially applied'} — confirm?
{proposal.reason || 'Based on the recent chat. One click either confirms or corrects.'}
) } ``` - [ ] **Step 2: Implement NudgeBanner** ```tsx function NudgeBanner({ fix, onOutcome }: ProposalBannerProps) { return (
Did "{fix.title}" work?
) } ``` - [ ] **Step 3: Implement CollapsedBanner** ```tsx function CollapsedBanner({ fix, onToggleCollapsed }: ProposalBannerProps) { return ( ) } ``` - [ ] **Step 4: Verify Tailwind classes resolve + compile** ```bash cd frontend && npx tsc -b && npm run build ``` Expected: clean — `npm run build` is stricter than `tsc --noEmit` per Lesson. - [ ] **Step 5: Commit** ```bash git add frontend/src/components/pilot/ProposalBanner.tsx git commit -m "feat(pilot): banner AI-confirming, Nudge, Collapsed states" ``` --- ## Task 10: EscalateInterceptDialog **Files:** - Create: `frontend/src/components/pilot/EscalateInterceptDialog.tsx` - [ ] **Step 1: Create the component** ```tsx /** * Popover dialog that intercepts Escalate when a fix is in verifying/partial * status. Captures outcome before the escalation so the narrative is honest * for whoever picks up the ticket. */ import { X, AlertCircle, Check } from 'lucide-react' import type { FixOutcome } from '@/api/sessionSuggestedFixes' export interface EscalateInterceptDialogProps { fixTitle: string onChoose: ( outcome: FixOutcome | 'never_applied', ) => void onClose: () => void } export function EscalateInterceptDialog({ fixTitle, onChoose, onClose, }: EscalateInterceptDialogProps) { return ( <>
Before escalating — what happened with the fix?
"{fixTitle}" is still in the Verifying state. Tag its outcome so the senior picking this up knows what's been tried.
) } export default EscalateInterceptDialog ``` - [ ] **Step 2: Verify it compiles** ```bash cd frontend && npx tsc -b ``` Expected: clean. - [ ] **Step 3: Commit** ```bash git add frontend/src/components/pilot/EscalateInterceptDialog.tsx git commit -m "feat(pilot): EscalateInterceptDialog popover" ``` --- ## Task 11: Integrate banner into AssistantChatPage **Files:** - Modify: `frontend/src/pages/AssistantChatPage.tsx` - Modify: `frontend/src/components/pilot/TaskLane.tsx` (pass null for suggestedFixSlot) - [ ] **Step 1: Find and remove the existing SuggestedFix card render** In `AssistantChatPage.tsx`, search for `(null) const bannerMode: BannerMode | null = (() => { if (!activeFix) return null if (activeFix.status === 'dismissed') return null if (activeFix.ai_outcome_proposal) return 'ai_confirming' if (activeFix.status === 'applied_partial') return 'partial' if (activeFix.status === 'applied_success' || activeFix.status === 'applied_failed') return null if (activeFix.applied_at) { if (postApplyMsgCount >= 3 && !nudgeSilenced) return 'nudge' return 'verifying' } return 'proposed' })() const handleApplyFix = useCallback(async () => { // Existing apply logic — opens Script Generator / NoTemplateDialog. // After the script successfully runs (or the engineer acknowledges the // open-script-in-builder handoff), patch status: proposed → verifying via // the existing "decision" endpoint (which already stamps one_off/draft/build), // AND patch outcome via the new API to set applied_at. await sessionSuggestedFixesApi.patchOutcome( sessionId, activeFix!.id, activeFix!.status as FixOutcome, /* no notes */ ).catch(() => { /* tolerate — applied_at is stamped server-side on next outcome call */ }) setPostApplyMsgCount(0) setNudgeSilenced(false) /* existing apply flow continues here */ }, [sessionId, activeFix]) const handleSetOutcome = useCallback(async (outcome: FixOutcome, notes?: string) => { if (!activeFix) return const updated = await sessionSuggestedFixesApi.patchOutcome( sessionId, activeFix.id, outcome, notes, ) setActiveFix(updated) if (outcome === 'applied_success') { // Auto-open ResolutionNotePreview pre-filled with the fix as resolution. openResolutionNotePreview() } }, [sessionId, activeFix, openResolutionNotePreview]) const handleAcceptAIProposal = useCallback(async () => { if (!activeFix?.ai_outcome_proposal) return const map: Record = { success: 'applied_success', failure: 'applied_failed', partial: 'applied_partial', } const outcome = map[activeFix.ai_outcome_proposal.outcome] const notes = activeFix.ai_outcome_proposal.reason await handleSetOutcome( outcome, outcome === 'applied_partial' || outcome === 'applied_failed' ? notes : undefined, ) }, [activeFix, handleSetOutcome]) const handleRejectAIProposal = useCallback(async () => { if (!activeFix) return // Clear the proposal without changing status. Backend: PATCH the fix // with ai_outcome_proposal=null (add a small endpoint or reuse outcome // with a 'clear_proposal' intent — minimal: clear it server-side when // the engineer clicks either terminal outcome button). setActiveFix({ ...activeFix, ai_outcome_proposal: null }) }, [activeFix]) ``` Increment `postApplyMsgCount` inside the existing chat-send handler(s). Grep for `sendChatMessage` to find them, then after each successful engineer-send: ```tsx if (activeFix?.applied_at && activeFix.status !== 'applied_success' && activeFix.status !== 'applied_failed') { setPostApplyMsgCount(c => c + 1) } ``` - [ ] **Step 3: Render the banner above the composer** Find the composer JSX (search for `ChatComposer` or the ` setBannerCollapsed(v => !v)} onApply={handleApplyFix} onDismiss={() => handleSetOutcome('dismissed')} onOutcome={handleSetOutcome} onAcceptAIProposal={handleAcceptAIProposal} onRejectAIProposal={handleRejectAIProposal} /> )} ``` - [ ] **Step 4: Wire Escalate intercept** Find the Escalate button handler. Wrap it: ```tsx const handleEscalateClick = () => { const inVerifyState = activeFix && ( activeFix.applied_at && activeFix.status === 'proposed' || activeFix.status === 'applied_partial' ) if (inVerifyState) { setEscalateIntercept({ fixId: activeFix!.id, fixTitle: activeFix!.title }) return } openEscalatePackagePreview() // existing flow } const handleInterceptChoice = async ( choice: FixOutcome | 'never_applied' ) => { setEscalateIntercept(null) if (choice === 'never_applied') { await sessionSuggestedFixesApi.patchOutcome(sessionId, escalateIntercept!.fixId, 'dismissed') } else { await sessionSuggestedFixesApi.patchOutcome(sessionId, escalateIntercept!.fixId, choice) } openEscalatePackagePreview() } ``` Render the dialog somewhere inside the action-bar container: ```tsx {escalateIntercept && ( setEscalateIntercept(null)} /> )} ``` - [ ] **Step 5: Wire Resolve auto-success** Before the existing Resolve-button flow fires: ```tsx const handleResolveClick = async () => { if (activeFix && activeFix.applied_at && activeFix.status === 'proposed') { // Implicit signal: engineer is resolving while a fix is in Verifying. await sessionSuggestedFixesApi.patchOutcome(sessionId, activeFix.id, 'applied_success') } openResolutionNotePreview() // existing flow } ``` - [ ] **Step 6: Build + browser smoke test** ```bash cd frontend && npm run build ``` Expected: clean build. Start dev stack: ```bash docker compose -f docker-compose.dev.yml up -d cd frontend && npm run dev ``` Smoke test at with `engineer@resolutionflow.example.com` / `TestPass123!`: - Start a session, let the AI propose a fix → banner appears above composer (Proposed). - Click Apply → Verifying state with pulse. - Click "It worked" → ResolutionNotePreview opens. - Replay: apply → send 3 chat messages → Nudge strip appears. - Replay: apply → click Escalate → intercept popover appears. - [ ] **Step 7: Commit** ```bash git add frontend/src/pages/AssistantChatPage.tsx frontend/src/components/pilot/TaskLane.tsx git commit -m "feat(pilot): mount ProposalBanner + wire implicit signals" ``` --- ## Task 12: Final cleanup — remove old SuggestedFix card **Files:** - Delete: `frontend/src/components/pilot/sections/SuggestedFix.tsx` - Modify: any remaining importers - [ ] **Step 1: Find remaining imports** ```bash grep -rn "from.*sections/SuggestedFix\|import.*SuggestedFix[^a-z]" /config/workspace/resolutionflow/frontend/src/ ``` - [ ] **Step 2: Remove the import sites and delete the file** Remove each `import ... SuggestedFix` that remains. Delete: ```bash rm frontend/src/components/pilot/sections/SuggestedFix.tsx ``` - [ ] **Step 3: Full build check** ```bash cd frontend && npm run build ``` Expected: clean. - [ ] **Step 4: Commit** ```bash git add -A git commit -m "chore(pilot): remove deprecated SuggestedFix task-lane card" ``` --- ## Task 13: Handoff doc update + QA sweep **Files:** - Modify: `docs/handoff/2026-04-22-flowpilot-migration.md` - Modify: `docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md` (append a Phase 8 section referencing this plan) - [ ] **Step 1: Update the handoff doc** Change the status line to reflect Phase 8 as shipped, and mark open item #2 as resolved with a pointer to this plan. Leave items #1 and #3 open (they're separate decisions). - [ ] **Step 2: QA sweep in browser** Run the full happy-path plus these edge cases: - Proposed → Dismiss → no banner remains, facts untouched. - Proposed → Apply → Verifying → "Didn't work" → enter reason → banner disappears (status terminal), AI can propose a new fix → banner re-arms. - Proposed → Apply → Verifying → "Mark partial" (overflow) → enter notes → Partial state shows notes → "Finish it" → back to Verifying. - Proposed → Apply → chat "yep that fixed it" → wait for AI next turn → AI-confirming banner (accent blue) → Confirm → Resolve flow. - Verifying → click Escalate in task-lane action bar → intercept popover → "Didn't work" (Enter) → status flips to applied_failed → Escalation package opens. - Verifying → click Resolve in task-lane action bar → status auto-flips to applied_success → ResolutionNotePreview opens pre-filled. - [ ] **Step 3: Final commit** ```bash git add docs/ git commit -m "docs(pilot): Phase 8 fix outcome banner — handoff + migration doc" ``` --- ## Self-Review **Spec coverage check:** | Spec element | Covered by | |---|---| | Banner replaces task-lane Suggested Fix card | Tasks 7-9 (component), Task 11 (integration), Task 12 (removal) | | Proposed state with confidence + description | Task 7 | | Verifying state with amber pulse, ✓/✕/overflow | Task 8 | | Partial apply with notes, non-terminal | Tasks 1, 3, 8 | | AI-inferred `[FIX_OUTCOME]` marker + confirm banner | Tasks 4, 5, 9, 11 | | Escalate intercept with "didn't work" default | Tasks 10, 11 | | Nudge after 3 post-apply messages | Tasks 9, 11 | | Resolve-while-verifying auto-success | Task 11 | | `session_suggested_fixes.status` + companion columns | Task 1 | | PATCH `/outcome` endpoint | Task 3 | | Anti-parrot compliance of prompt addition | Task 5 | | Remove deprecated `SuggestedFix.tsx` | Task 12 | | Smoke-test all paths | Task 13 | **Placeholder scan:** No TBDs, no "add error handling," no "similar to Task N." Every code block is complete. **Type consistency:** `FixStatus`, `FixOutcome`, `BannerMode`, `AIOutcomeProposal` — spelled the same across Tasks 2, 6, 7, 8, 9, 10, 11. Endpoint method names (`patchOutcome`, `_parse_fix_outcome_marker`, `SessionSuggestedFixOutcomeRequest`) consistent. **Risks called out:** - Task 4 introduces `ai_outcome_proposal` column that Task 1 didn't anticipate — Task 4 flags this and instructs to amend Task 1's migration before moving on. - The apply flow (Task 11, Step 2) uses the existing decision endpoint; the exact wire-up depends on whether the apply path runs a script, opens Script Builder, or both. The executing engineer should read `AssistantChatPage.tsx`'s current `onActivate` handler for the suggested fix and adapt. - Frontend has no component test harness — verification is build + manual browser smoke (Task 11 Step 6, Task 13 Step 2). --- **Plan complete and saved to `docs/FlowAssist_Migration/phase-8-fix-outcome-banner.md`. Two execution options:** **1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration **2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints **Which approach?**