Files
resolutionflow/docs/FlowAssist_Migration/phase-8-fix-outcome-banner.md
Michael Chihlas 2cde6673b0 feat(pilot): [FIX_OUTCOME] system prompt instructions
Tells the AI when + how to emit the [FIX_OUTCOME] marker that Task 4's
parser consumes. Placeholder-only per the anti-parrot pattern — no
literal UUIDs, outcomes, or reasons that could leak into unrelated
sessions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:17:21 -04:00

1674 lines
61 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<hash>_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/<hash>_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 = "<hash-alembic-generated>"
down_revision = "<parent>"
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": "<uuid>", "outcome": "success"|"failure"|"partial",
"reason": "<one-line>"}
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": "<uuid-of-the-active-suggested-fix>",
"outcome": "<success|failure|partial>",
"reason": "<one-line-quote-or-paraphrase-of-what-the-engineer-said>"}
[/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 `<placeholder>` 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<SessionSuggestedFix> {
const r = await apiClient.patch<SessionSuggestedFix>(
`/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 <CollapsedBanner {...props} />
switch (props.mode) {
case 'proposed': return <ProposedBanner {...props} />
case 'verifying': return <VerifyingBanner {...props} />
case 'partial': return <PartialBanner {...props} />
case 'ai_confirming': return <AIConfirmingBanner {...props} />
case 'nudge': return <NudgeBanner {...props} />
}
}
function ProposedBanner({ fix, onApply, onDismiss }: ProposalBannerProps) {
return (
<div className="relative border-t border-warning/30 bg-gradient-to-b from-warning-dim/40 to-warning-dim/20 px-5 py-3 animate-slide-up">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
<div className="flex items-start gap-3">
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-warning/30 bg-warning-dim flex items-center justify-center text-warning">
<Sparkles size={15} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-warning">
<span>Suggested Fix</span>
<span className="tabular-nums px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold">
{fix.confidence_pct}% confidence
</span>
</div>
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
{fix.title}
</div>
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed">
{fix.description}
</div>
{fix.script_template_id && (
<div className="mt-1.5 inline-flex items-center gap-1.5 text-[11.5px] text-success">
<Check size={11} />
Matches an existing Script Library template one-click apply
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0 pt-0.5">
<button
onClick={() => { /* hook: collapse */ }}
className="p-1.5 rounded text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
aria-label="Collapse"
>
<ChevronDown size={14} />
</button>
<button
onClick={onDismiss}
className="px-2.5 py-1.5 rounded text-[12.5px] text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
>
Dismiss
</button>
<button
onClick={onApply}
className="px-3.5 py-[9px] rounded-lg bg-warning text-[#1a1200] font-semibold text-[12.5px] hover:bg-[#ffce4f] inline-flex items-center gap-1.5"
>
Apply fix
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6" /></svg>
</button>
</div>
</div>
</div>
)
}
// 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 `<ProposalBanner fix={...mock} mode="proposed" onApply={() => {}} 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 (
<div className="relative border-t border-warning/30 bg-gradient-to-b from-warning-dim/40 to-warning-dim/20 px-5 py-3 animate-slide-up">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
<div className="flex items-start gap-3">
<div className="relative shrink-0 mt-0.5 w-7 h-7 rounded-md border border-warning/30 bg-warning-dim flex items-center justify-center text-warning">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
</svg>
<span className="absolute inset-[-3px] rounded-lg animate-pulse-amber" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-warning">
<span>Verifying</span>
<span className="px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold normal-case tracking-normal">
{appliedLabel}
</span>
</div>
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
Did "{fix.title}" work?
</div>
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed">
Mark the outcome so the AI can either close the session with this as the resolution, or propose something else.
</div>
</div>
<div className="flex items-center gap-2 shrink-0 pt-0.5 relative">
<button
onClick={() => setShowOverflow(v => !v)}
className="p-1.5 rounded text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
aria-label="More options"
>
<MoreHorizontal size={14} />
</button>
{showOverflow && (
<div className="absolute top-full right-0 mt-1 w-48 rounded-lg border border-white/10 bg-card shadow-xl py-1 z-10">
<button
onClick={() => {
setShowOverflow(false)
const notes = window.prompt('What did you run / skip?')
if (notes && notes.trim()) onOutcome('applied_partial', notes.trim())
}}
className="w-full text-left px-3 py-2 text-[12.5px] hover:bg-elevated text-primary"
>
Mark partial
</button>
</div>
)}
<button
onClick={() => {
const reason = window.prompt('Why didn\'t it work? (optional)')
onOutcome('applied_failed', reason?.trim() || undefined)
}}
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-[12.5px] font-medium hover:bg-danger-dim hover:border-danger inline-flex items-center gap-1.5"
>
<X size={12} strokeWidth={2.5} />
Didn't work
</button>
<button
onClick={() => onOutcome('applied_success')}
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-[12.5px] hover:brightness-110 inline-flex items-center gap-1.5"
>
<Check size={12} strokeWidth={2.5} />
It worked
</button>
</div>
</div>
</div>
)
}
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 (
<div className="relative border-t border-info/30 bg-gradient-to-b from-info-dim/40 to-info-dim/20 px-5 py-3 animate-slide-up">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-info" />
<div className="flex items-start gap-3">
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-info/30 bg-info-dim flex items-center justify-center text-info">
<Info size={15} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-info">
<span>Partially applied</span>
<span className="px-2 py-[2px] rounded-full bg-info/20 text-info text-[10.5px] font-bold normal-case tracking-normal">
Parked
</span>
</div>
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
{fix.title}
</div>
{fix.partial_notes && (
<div className="mt-1.5 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-info/[0.08] border border-info/30 text-[12px] italic text-primary">
<span className="not-italic font-bold text-info text-[10.5px] uppercase tracking-[0.6px]">Note</span>
<span>{fix.partial_notes}</span>
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0 pt-0.5">
<button
onClick={() => {
const reason = window.prompt('Why didn\'t it work? (optional)')
onOutcome('applied_failed', reason?.trim() || undefined)
}}
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-[12.5px] font-medium hover:bg-danger-dim hover:border-danger"
>
Didn't work
</button>
<button
onClick={onApply}
className="px-3 py-[9px] rounded-lg bg-card border border-white/10 text-primary text-[12.5px] font-medium hover:bg-elevated"
>
Finish it
</button>
<button
onClick={() => onOutcome('applied_success')}
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-[12.5px] hover:brightness-110"
>
It worked
</button>
</div>
</div>
</div>
)
}
```
- [ ] **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 (
<div className="relative border-t border-accent/30 bg-gradient-to-b from-accent-dim/40 to-accent-dim/20 px-5 py-3 animate-slide-up">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-accent" />
<div className="flex items-start gap-3">
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-accent/30 bg-accent-dim flex items-center justify-center text-accent">
<Sparkles size={15} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-accent">
<span>AI detected outcome</span>
<span className="px-2 py-[2px] rounded-full bg-accent/20 text-accent text-[10.5px] font-bold normal-case tracking-normal">
{isSuccess ? 'Success' : proposal.outcome === 'failure' ? 'Failure' : 'Partial'}
</span>
</div>
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
AI thinks the fix {isSuccess ? 'resolved the issue' : proposal.outcome === 'failure' ? 'didn\'t work' : 'was partially applied'} confirm?
</div>
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed">
{proposal.reason || 'Based on the recent chat. One click either confirms or corrects.'}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 pt-0.5">
<button
onClick={onRejectAIProposal}
className="px-3 py-[9px] rounded-lg text-muted-foreground text-[12.5px] hover:bg-white/[0.08] hover:text-primary"
>
Not yet
</button>
<button
onClick={onAcceptAIProposal}
className={cn(
'px-3 py-[9px] rounded-lg font-semibold text-[12.5px] inline-flex items-center gap-1.5 hover:brightness-110',
isSuccess ? 'bg-success text-[#0a1a12]' : 'bg-danger text-[#180808]',
)}
>
<Check size={12} strokeWidth={2.5} />
Confirm{isSuccess ? ' · Resolve' : ''}
</button>
</div>
</div>
</div>
)
}
```
- [ ] **Step 2: Implement NudgeBanner**
```tsx
function NudgeBanner({ fix, onOutcome }: ProposalBannerProps) {
return (
<div className="relative border-t border-warning/30 bg-warning-dim/60 px-5 py-2 flex items-center gap-3">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-warning shrink-0">
<circle cx="12" cy="12" r="10" /><path d="M12 8v4" /><path d="M12 16h.01" />
</svg>
<span className="flex-1 text-[12.5px] text-primary">
Did <strong className="text-heading">"{fix.title}"</strong> work?
</span>
<button
onClick={() => { /* silence for 3 more messages — handled by parent */ }}
className="px-2.5 py-1 rounded text-[12px] text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
>
Still checking
</button>
<button
onClick={() => {
const reason = window.prompt('Why didn\'t it work? (optional)')
onOutcome('applied_failed', reason?.trim() || undefined)
}}
className="px-2.5 py-1 rounded border border-danger/30 text-danger text-[12px] hover:bg-danger-dim"
>
No
</button>
<button
onClick={() => onOutcome('applied_success')}
className="px-2.5 py-1 rounded bg-success text-[#0a1a12] font-semibold text-[12px] hover:brightness-110"
>
Yes
</button>
</div>
)
}
```
- [ ] **Step 3: Implement CollapsedBanner**
```tsx
function CollapsedBanner({ fix, onToggleCollapsed }: ProposalBannerProps) {
return (
<button
onClick={onToggleCollapsed}
className="relative w-full border-t border-warning/30 bg-warning-dim/40 px-5 py-2 flex items-center gap-2.5 hover:bg-warning-dim/60 transition-colors text-left"
>
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
<Sparkles size={12} className="text-warning shrink-0" />
<span className="flex-1 text-[12px] font-medium text-heading truncate">{fix.title}</span>
<span className="px-1.5 py-[1px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold tabular-nums">
{fix.confidence_pct}%
</span>
<span className="text-muted-foreground text-[11px]"> expand</span>
</button>
)
}
```
- [ ] **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 (
<>
<div className="fixed inset-0 z-40" onClick={onClose} />
<div className="absolute bottom-full mb-2 left-0 z-50 w-[340px] rounded-lg border border-white/15 bg-card p-3.5 shadow-[0_18px_40px_rgba(0,0,0,0.55)]">
<div className="font-heading font-semibold text-[13px] text-heading mb-1">
Before escalating what happened with the fix?
</div>
<div className="text-[12px] text-muted-foreground leading-[1.5] mb-3">
"{fixTitle}" is still in the Verifying state. Tag its outcome so the senior picking this up knows what's been tried.
</div>
<div className="flex flex-col gap-1.5">
<button
onClick={() => onChoose('applied_failed')}
autoFocus
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-danger/30 bg-danger-dim text-[12.5px] text-primary hover:bg-danger-dim/80 hover:border-danger transition-colors text-left"
>
<X size={13} strokeWidth={2.5} className="text-danger" />
<span className="flex-1">The fix didn't work</span>
<span className="text-[10.5px] text-muted-foreground font-mono px-1.5 py-[2px] rounded bg-white/[0.05]"></span>
</button>
<button
onClick={() => onChoose('applied_success')}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-white/10 bg-elevated text-[12.5px] text-primary hover:bg-sidebar transition-colors text-left"
>
<Check size={13} strokeWidth={2} />
<span className="flex-1">It worked escalating for another reason</span>
</button>
<button
onClick={() => onChoose('never_applied')}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-white/10 bg-elevated text-[12.5px] text-primary hover:bg-sidebar transition-colors text-left"
>
<AlertCircle size={13} strokeWidth={2} />
<span className="flex-1">Never actually applied it</span>
</button>
</div>
</div>
</>
)
}
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 `<SuggestedFix` and the corresponding `suggestedFixSlot={...}` prop passed to `<TaskLane`. The card lives inside the task lane today; remove its usage — pass `suggestedFixSlot={null}` (keep the prop in TaskLane's API for now so other callers don't break) or delete the prop usage entirely if TaskLane is the only caller. Grep to verify:
```bash
grep -rn "suggestedFixSlot" /config/workspace/resolutionflow/frontend/src/
```
- [ ] **Step 2: Add banner state + handlers**
Near the top of the component body (after existing useState calls for the active fix — search for `activeFix`):
```tsx
const [bannerCollapsed, setBannerCollapsed] = useState(false)
const [postApplyMsgCount, setPostApplyMsgCount] = useState(0)
const [nudgeSilenced, setNudgeSilenced] = useState(false)
const [escalateIntercept, setEscalateIntercept] = useState<{ fixId: string; fixTitle: string } | null>(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<string, FixOutcome> = {
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 `<textarea`/input for chat). Just above it, insert:
```tsx
{activeFix && bannerMode && (
<ProposalBanner
fix={activeFix}
mode={bannerMode}
collapsed={bannerCollapsed && bannerMode !== 'nudge' && bannerMode !== 'ai_confirming'}
onToggleCollapsed={() => 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 && (
<EscalateInterceptDialog
fixTitle={escalateIntercept.fixTitle}
onChoose={handleInterceptChoice}
onClose={() => 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 <http://localhost:5173/pilot> 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?**