From 563bb1aa6f043e612315d660401fb8225410c381 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Fri, 24 Apr 2026 00:03:57 -0400 Subject: [PATCH] docs(pilot): Phase 9 implementation plan 14-task plan covering: - DB migration for origin + partial unique index on script_builder_sessions - Pydantic schemas for inline origin + PATCH /script - POST /script-builder/sessions idempotent for pilot_inline + auth - list_sessions + count_user_sessions filtered to standalone - PATCH /suggested-fixes/:id/script (bumps state_version, no applied_at) - Frontend API client additions - ChatTabStrip, ScriptBuilderTab (controller), InlineNoTemplateDialog - TemplateMatchPanel 'I ran this' action - EscalateInterceptDialog fourth 'partial' choice - AssistantChatPage integration + applyFix call-site relocation - Docs + handoff updates Paired with the spec at phase-9-script-builder-tab.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../phase-9-implementation-plan.md | 1897 +++++++++++++++++ 1 file changed, 1897 insertions(+) create mode 100644 docs/FlowAssist_Migration/phase-9-implementation-plan.md diff --git a/docs/FlowAssist_Migration/phase-9-implementation-plan.md b/docs/FlowAssist_Migration/phase-9-implementation-plan.md new file mode 100644 index 00000000..226f8ad7 --- /dev/null +++ b/docs/FlowAssist_Migration/phase-9-implementation-plan.md @@ -0,0 +1,1897 @@ +# FlowPilot Phase 9 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:** Ship the remaining two FlowPilot migration items (tabbed Script Builder inside the chat; NoTemplateDialog relocation) plus two Phase 8 cleanups (EscalateInterceptDialog partial choice; applied_at stamp semantics correction). + +**Architecture:** Adds `origin` to `script_builder_sessions` (plus a partial unique index for idempotent inline create), a new `PATCH /suggested-fixes/:id/script` endpoint, and a new `ScriptBuilderTab` controller in the chat region. `ScriptBuilderChat` stays presentational — the new controller owns session lifecycle + submit. `applyFix` call site moves from banner click to three explicit run-declaring surfaces. + +**Tech Stack:** Python 3.11 + FastAPI + SQLAlchemy 2.0 async + Alembic + Pydantic v2 (backend); React 19 + Vite + TypeScript + Tailwind v4 + Monaco (frontend). + +**Spec reference:** [docs/FlowAssist_Migration/phase-9-script-builder-tab.md](phase-9-script-builder-tab.md) + +--- + +## File Structure + +### Backend — new +- `backend/alembic/versions/_script_builder_origin.py` — migration +- `backend/tests/test_fix_script_endpoint.py` — PATCH /script tests +- `backend/tests/test_script_builder_inline.py` — inline session tests (idempotency, auth, list/count filter) + +### Backend — modified +- `backend/app/models/script_builder_session.py` — add `origin` column +- `backend/app/schemas/script_builder.py` — extend `ScriptBuilderCreateRequest` with `origin` + `ai_session_id` +- `backend/app/schemas/session_suggested_fix.py` — add `SessionSuggestedFixScriptRequest` +- `backend/app/api/endpoints/script_builder.py` — validate + resolve inline origin in `create_session` +- `backend/app/api/endpoints/session_suggested_fixes.py` — add PATCH /script endpoint +- `backend/app/services/script_builder_service.py` — persist `origin`; idempotent inline lookup; filter `list_sessions` / `count_user_sessions` to standalone + +### Frontend — new +- `frontend/src/components/pilot/ChatTabStrip.tsx` — `[Chat] [Script Builder ●]` tab strip +- `frontend/src/components/pilot/ScriptBuilderTab.tsx` — controller (session lifecycle + mode toggle + submit) +- `frontend/src/components/pilot/InlineNoTemplateDialog.tsx` — chat-region render wrapper for the existing `NoTemplateDialog` + +### Frontend — modified +- `frontend/src/api/sessionSuggestedFixes.ts` — `patchScript` method +- `frontend/src/api/scriptBuilder.ts` — `createSession(language, options?)` takes optional `origin` + `aiSessionId` +- `frontend/src/components/pilot/script/TemplateMatchPanel.tsx` — new "✓ I ran this" button; calls parent callback +- `frontend/src/components/pilot/EscalateInterceptDialog.tsx` — fourth "I applied some of it — partial" choice +- `frontend/src/pages/AssistantChatPage.tsx` — tab strip + `ScriptBuilderTab` mount + banner routing + applyFix call-site moves + inline `NoTemplateDialog` render +- `frontend/src/components/pilot/TaskLane.tsx` — remove `NoTemplateDialog` from `bottomSlot` + +### Frontend — unchanged (intentionally) +- `frontend/src/components/script-builder/ScriptBuilderChat.tsx` — presentational, stays pure +- `frontend/src/pages/ScriptBuilderPage.tsx` — standalone path preserved (defaults) +- `frontend/src/components/pilot/script/NoTemplateDialog.tsx` — existing three-card UX unchanged (new wrapper adjusts placement only) + +--- + +## Task 1: DB migration — add `origin` column + constraints + partial unique index + +**Files:** +- Create: `backend/alembic/versions/_script_builder_origin.py` (hash chosen by alembic) +- No tests in this task — exercised by Task 4 + Task 5 integration tests. + +- [ ] **Step 1: Generate migration** + +```bash +cd /config/workspace/resolutionflow/backend && source venv/bin/activate \ + && alembic revision -m "add origin discriminator + inline idempotency to script_builder_sessions" +``` + +(Do NOT pass `--rev-id` — alembic generates the hash.) + +- [ ] **Step 2: Write migration body** + +Open the newly-generated file. Replace the body (keep alembic's generated `revision` / `down_revision`): + +```python +"""add origin discriminator + inline idempotency to script_builder_sessions + +Adds: +- origin VARCHAR(20) NOT NULL DEFAULT 'standalone' with CHECK enum +- invariant: pilot_inline rows must have ai_session_id +- partial unique index: one pilot_inline session per (user, pilot session) +""" +from alembic import op +import sqlalchemy as sa + + +revision = "" +down_revision = "" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "script_builder_sessions", + sa.Column( + "origin", + sa.String(length=20), + nullable=False, + server_default=sa.text("'standalone'"), + ), + ) + op.create_check_constraint( + "ck_script_builder_sessions_origin", + "script_builder_sessions", + "origin IN ('standalone', 'pilot_inline')", + ) + op.create_check_constraint( + "ck_script_builder_sessions_origin_ai_session", + "script_builder_sessions", + "origin <> 'pilot_inline' OR ai_session_id IS NOT NULL", + ) + op.create_index( + "ux_script_builder_sessions_pilot_inline", + "script_builder_sessions", + ["user_id", "ai_session_id"], + unique=True, + postgresql_where=sa.text("origin = 'pilot_inline'"), + ) + # Drop the server_default — app code owns the default via model default. + op.alter_column("script_builder_sessions", "origin", server_default=None) + + +def downgrade() -> None: + op.drop_index( + "ux_script_builder_sessions_pilot_inline", + table_name="script_builder_sessions", + ) + op.drop_constraint( + "ck_script_builder_sessions_origin_ai_session", + "script_builder_sessions", + type_="check", + ) + op.drop_constraint( + "ck_script_builder_sessions_origin", + "script_builder_sessions", + type_="check", + ) + op.drop_column("script_builder_sessions", "origin") +``` + +- [ ] **Step 3: Apply + verify** + +```bash +cd /config/workspace/resolutionflow/backend && alembic upgrade head +``` + +Verify: + +```bash +docker exec -i resolutionflow_postgres psql -U postgres -d resolutionflow -tAc \ + "SELECT column_name FROM information_schema.columns WHERE table_name='script_builder_sessions' AND column_name='origin';" +# expected: origin +``` + +```bash +docker exec -i resolutionflow_postgres psql -U postgres -d resolutionflow -tAc \ + "SELECT indexname FROM pg_indexes WHERE indexname='ux_script_builder_sessions_pilot_inline';" +# expected: ux_script_builder_sessions_pilot_inline +``` + +- [ ] **Step 4: Round-trip downgrade/upgrade to confirm reversibility** + +```bash +cd /config/workspace/resolutionflow/backend && alembic downgrade -1 && alembic upgrade head +``` + +Expected: both clean, no errors. + +- [ ] **Step 5: Commit** + +```bash +cd /config/workspace/resolutionflow && git add backend/alembic/versions/ \ + && git commit -m "$(cat <<'EOF' +feat(pilot): add origin + inline idempotency to script_builder_sessions + +Phase 9 prep. Adds: +- origin VARCHAR(20) NOT NULL with CHECK ('standalone' | 'pilot_inline') +- invariant: pilot_inline rows must have ai_session_id +- partial unique index on (user_id, ai_session_id) WHERE origin='pilot_inline' + — backs get-or-create idempotency for the inline Script Builder tab. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: SQLAlchemy model — add `origin` column + +**Files:** +- Modify: `backend/app/models/script_builder_session.py` + +- [ ] **Step 1: Add the column to the model** + +Open the file. After the existing `ai_session_id` column (around line 59) and before `created_at`, add: + +```python + origin: Mapped[str] = mapped_column( + String(20), + nullable=False, + default="standalone", + comment=( + "Session origin — 'standalone' (from /script-builder) or " + "'pilot_inline' (from FlowPilot Script Builder tab). " + "Invariant: pilot_inline rows must have ai_session_id set." + ), + ) +``` + +- [ ] **Step 2: Sanity-check imports + run a quick parse** + +```bash +docker exec resolutionflow_backend sh -c "cd /app && python -c 'from app.models.script_builder_session import ScriptBuilderSession; print(ScriptBuilderSession.__table__.columns.keys())'" +``` + +Expected: output includes `origin` alongside existing columns. + +- [ ] **Step 3: Commit** + +```bash +cd /config/workspace/resolutionflow && git add backend/app/models/script_builder_session.py \ + && git commit -m "$(cat <<'EOF' +feat(pilot): script_builder_sessions.origin on SQLAlchemy model + +Mirrors the DB column added in the prior migration. App-level default +is 'standalone' so existing callers of ScriptBuilderSession(...) work +without code changes. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: Backend schemas — extend create request + add PATCH /script request + +**Files:** +- Modify: `backend/app/schemas/script_builder.py` +- Modify: `backend/app/schemas/session_suggested_fix.py` + +- [ ] **Step 1: Extend `ScriptBuilderCreateRequest`** + +Open `backend/app/schemas/script_builder.py`. At the top, confirm `Literal` is imported from `typing` (add if missing). The `UUID` import is already present. + +Replace the `ScriptBuilderCreateRequest` class with: + +```python +class ScriptBuilderCreateRequest(BaseModel): + """Request to start (or get-or-create, for inline origin) a builder session. + + When `origin='pilot_inline'`, `ai_session_id` is REQUIRED and must + reference a pilot session owned by the current user. The endpoint's + get-or-create semantics kick in: if a pilot_inline session already + exists for (user_id, ai_session_id), that row is returned instead of + creating a duplicate. + """ + language: str = Field( + default="powershell", + pattern=r"^(powershell|bash|python)$", + description="Script language", + ) + origin: Literal["standalone", "pilot_inline"] = "standalone" + ai_session_id: UUID | None = None +``` + +- [ ] **Step 2: Add `SessionSuggestedFixScriptRequest`** + +Open `backend/app/schemas/session_suggested_fix.py`. After `SessionSuggestedFixOutcomeRequest` (near the end of the "decision / outcome" section), add: + +```python +class SessionSuggestedFixScriptRequest(BaseModel): + """Engineer-submitted drafted script for a suggested fix. + + Called when the inline Script Builder tab's Submit action fires. The + fix must be non-terminal (still proposed/applied_partial). Setting + the script does NOT stamp applied_at — a draft is not an application. + """ + ai_drafted_script: str = Field(..., min_length=1, max_length=50_000) + ai_drafted_parameters: dict[str, Any] | None = None +``` + +(`Any` is already imported at the top of the file — confirmed during Phase 8.) + +- [ ] **Step 3: Verify parse** + +```bash +docker exec resolutionflow_backend sh -c "cd /app && python -c ' +from app.schemas.script_builder import ScriptBuilderCreateRequest +from app.schemas.session_suggested_fix import SessionSuggestedFixScriptRequest +print(\"OK\") +'" +``` + +Expected: `OK`. + +- [ ] **Step 4: Commit** + +```bash +cd /config/workspace/resolutionflow && git add backend/app/schemas/ \ + && git commit -m "$(cat <<'EOF' +feat(pilot): pydantic schemas for inline origin + script PATCH + +- ScriptBuilderCreateRequest gains origin ('standalone' | 'pilot_inline') + and optional ai_session_id. Handler-side validation (next task) enforces + pilot_inline ⇒ ai_session_id required + owned by caller. +- SessionSuggestedFixScriptRequest added for the new PATCH /script + endpoint (Phase 9 Task 6). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: `POST /script-builder/sessions` — idempotent pilot_inline + auth + service filter + +**Files:** +- Modify: `backend/app/api/endpoints/script_builder.py` +- Modify: `backend/app/services/script_builder_service.py` +- Create: `backend/tests/test_script_builder_inline.py` + +- [ ] **Step 1: Read existing handler + service to understand integration points** + +Read `backend/app/api/endpoints/script_builder.py` and `backend/app/services/script_builder_service.py` around the `create_session` / `list_sessions` / `count_user_sessions` functions. You're adding behavior, not restructuring — keep the existing call shape where possible. + +- [ ] **Step 2: Write the failing tests** + +Create `backend/tests/test_script_builder_inline.py`: + +```python +"""Integration tests for inline pilot_inline script_builder_session behavior. + +Covers: +- Idempotent get-or-create for (user, ai_session_id) on origin='pilot_inline' +- Authorization: ai_session_id must belong to current user +- list_sessions + count_user_sessions default-scope to 'standalone' +""" +from __future__ import annotations + +import pytest +from httpx import AsyncClient +from sqlalchemy import select, func +from uuid import uuid4 + +from app.models.ai_session import AISession +from app.models.script_builder_session import ScriptBuilderSession + + +async def _make_pilot_session(test_db, user) -> str: + """Helper: create a minimal pilot session owned by `user`. + + Matches the existing pattern used by test_fix_outcome_endpoint.py. + """ + session = AISession( + id=uuid4(), user_id=user.id, account_id=user.account_id, + session_type="tshoot", intake_type="ticket", + intake_content={}, title="QA", + status="active", confidence_tier="exploring", confidence_score=0.0, + ) + test_db.add(session) + await test_db.commit() + return str(session.id) + + +@pytest.mark.asyncio +async def test_inline_create_is_idempotent( + client: AsyncClient, test_user, auth_headers, test_db +): + """Second create with same (user, ai_session_id) returns the existing row.""" + ai_session_id = await _make_pilot_session(test_db, test_user) + + r1 = await client.post( + "/api/v1/script-builder/sessions", + json={"language": "powershell", "origin": "pilot_inline", + "ai_session_id": ai_session_id}, + headers=auth_headers, + ) + assert r1.status_code in (200, 201), r1.text + first_id = r1.json()["id"] + + r2 = await client.post( + "/api/v1/script-builder/sessions", + json={"language": "powershell", "origin": "pilot_inline", + "ai_session_id": ai_session_id}, + headers=auth_headers, + ) + assert r2.status_code in (200, 201) + assert r2.json()["id"] == first_id + + # DB confirms only one row + row_count = await test_db.scalar( + select(func.count()).select_from(ScriptBuilderSession).where( + ScriptBuilderSession.user_id == test_user.id, + ScriptBuilderSession.origin == "pilot_inline", + ) + ) + assert row_count == 1 + + +@pytest.mark.asyncio +async def test_inline_requires_ai_session_id( + client: AsyncClient, auth_headers +): + """origin='pilot_inline' without ai_session_id is rejected.""" + r = await client.post( + "/api/v1/script-builder/sessions", + json={"language": "powershell", "origin": "pilot_inline"}, + headers=auth_headers, + ) + assert r.status_code == 400 + assert "ai_session_id" in r.text.lower() + + +@pytest.mark.asyncio +async def test_inline_ai_session_must_belong_to_caller( + client: AsyncClient, test_user, auth_headers, test_db +): + """ai_session_id pointing at another user's session is rejected.""" + # Create pilot session owned by a DIFFERENT user + from app.models.user import User + from app.models.account import Account + other_account = Account(id=uuid4(), name="other", plan="free", is_active=True) + test_db.add(other_account) + await test_db.flush() + other_user = User( + id=uuid4(), email="other@example.com", + password_hash="x", name="Other", role="engineer", + is_super_admin=False, is_team_admin=False, is_active=True, + is_service_account=False, must_change_password=False, + account_id=other_account.id, account_role="engineer", + ) + test_db.add(other_user) + await test_db.flush() + other_session_id = await _make_pilot_session(test_db, other_user) + + r = await client.post( + "/api/v1/script-builder/sessions", + json={"language": "powershell", "origin": "pilot_inline", + "ai_session_id": other_session_id}, + headers=auth_headers, + ) + assert r.status_code in (403, 404), r.text + + +@pytest.mark.asyncio +async def test_list_sessions_excludes_inline( + client: AsyncClient, test_user, auth_headers, test_db +): + """GET /script-builder/sessions returns only standalone rows.""" + ai_session_id = await _make_pilot_session(test_db, test_user) + + # Create one inline session + await client.post( + "/api/v1/script-builder/sessions", + json={"language": "powershell", "origin": "pilot_inline", + "ai_session_id": ai_session_id}, + headers=auth_headers, + ) + # Create one standalone session + await client.post( + "/api/v1/script-builder/sessions", + json={"language": "powershell"}, + headers=auth_headers, + ) + + r = await client.get("/api/v1/script-builder/sessions", headers=auth_headers) + assert r.status_code == 200 + body = r.json() + # Depending on response shape, this may be a list or {"sessions": [...]}. + items = body if isinstance(body, list) else body.get("sessions", body.get("items", [])) + assert len(items) == 1 + assert items[0].get("origin", "standalone") == "standalone" + + +@pytest.mark.asyncio +async def test_inline_sessions_do_not_count_against_cap( + client: AsyncClient, test_user, auth_headers, test_db +): + """Creating 5 pilot_inline sessions does not block a subsequent standalone.""" + # Create 5 distinct pilot sessions and attach inline builder sessions to each + for _ in range(5): + ai_session_id = await _make_pilot_session(test_db, test_user) + r = await client.post( + "/api/v1/script-builder/sessions", + json={"language": "powershell", "origin": "pilot_inline", + "ai_session_id": ai_session_id}, + headers=auth_headers, + ) + assert r.status_code in (200, 201), r.text + + # A standalone create should still succeed — inline sessions don't count + r = await client.post( + "/api/v1/script-builder/sessions", + json={"language": "powershell"}, + headers=auth_headers, + ) + assert r.status_code in (200, 201), r.text +``` + +- [ ] **Step 3: Run tests — confirm failure** + +```bash +docker exec resolutionflow_backend sh -c "cd /app && pytest tests/test_script_builder_inline.py -v --override-ini='addopts='" +``` + +Expected: failures — endpoint doesn't yet accept `origin` / `ai_session_id`; list endpoint returns inline rows; the cap hits at 5. + +- [ ] **Step 4: Update the service — `list_sessions` and `count_user_sessions` filter to `origin='standalone'`** + +In `backend/app/services/script_builder_service.py`, find `list_sessions` and `count_user_sessions`. Add `include_inline: bool = False` keyword arg to each; when False (the default), add `.where(ScriptBuilderSession.origin == 'standalone')` to the query. + +Example shape: + +```python +async def list_sessions( + db: AsyncSession, user_id: uuid.UUID, *, include_inline: bool = False, +) -> list[ScriptBuilderSession]: + stmt = select(ScriptBuilderSession).where( + ScriptBuilderSession.user_id == user_id, + ScriptBuilderSession.deleted_at.is_(None), # or whatever existing guard + ) + if not include_inline: + stmt = stmt.where(ScriptBuilderSession.origin == "standalone") + stmt = stmt.order_by(ScriptBuilderSession.updated_at.desc()) + result = await db.execute(stmt) + return list(result.scalars()) + +async def count_user_sessions( + db: AsyncSession, user_id: uuid.UUID, *, include_inline: bool = False, +) -> int: + stmt = select(func.count()).select_from(ScriptBuilderSession).where( + ScriptBuilderSession.user_id == user_id, + ScriptBuilderSession.deleted_at.is_(None), + ) + if not include_inline: + stmt = stmt.where(ScriptBuilderSession.origin == "standalone") + return await db.scalar(stmt) or 0 +``` + +(Adapt to match the existing function signatures — `db` may be first or last positional arg; copy whatever pattern is already there.) + +- [ ] **Step 5: Update the endpoint — auth + idempotency** + +In `backend/app/api/endpoints/script_builder.py`, in the `create_session` handler, before calling the service: + +```python + # Phase 9: inline origin validation + authorization + if body.origin == "pilot_inline": + if body.ai_session_id is None: + raise HTTPException( + status_code=400, + detail="ai_session_id is required when origin='pilot_inline'", + ) + # Ownership check: the pilot session must belong to the current user. + # Reuse the existing _load_session_or_404 helper used elsewhere. + await _load_session_or_404(db, body.ai_session_id) + + # Idempotent get-or-create: if a pilot_inline row exists for this + # (user, ai_session_id), return it. + existing = await db.scalar( + select(ScriptBuilderSession).where( + ScriptBuilderSession.user_id == current_user.id, + ScriptBuilderSession.ai_session_id == body.ai_session_id, + ScriptBuilderSession.origin == "pilot_inline", + ) + ) + if existing is not None: + return existing +``` + +After the ownership/idempotency short-circuit, continue into the service's existing create path. Pass `origin` + `ai_session_id` to the service's row creation. If the service has a helper that builds the row, add both kwargs there. + +For the service creation path, handle the race (concurrent POSTs) via an integrity-error catch: + +```python + try: + session = await script_builder_service.create_session( + db, user_id=current_user.id, language=body.language, + origin=body.origin, ai_session_id=body.ai_session_id, + ) + except IntegrityError: + await db.rollback() + # Race: another request won the unique index. Re-read. + existing = await db.scalar( + select(ScriptBuilderSession).where( + ScriptBuilderSession.user_id == current_user.id, + ScriptBuilderSession.ai_session_id == body.ai_session_id, + ScriptBuilderSession.origin == "pilot_inline", + ) + ) + if existing is None: + raise + return existing +``` + +Add imports as needed: `from sqlalchemy.exc import IntegrityError`, `from sqlalchemy import select`, the model import, `_load_session_or_404` (grep for where other pilot endpoints import it). + +Also: update the cap-check in `create_session` to pass `include_inline=False` (which is the default now, but be explicit for future readers). + +- [ ] **Step 6: Run tests — expect passing** + +```bash +docker exec resolutionflow_backend sh -c "cd /app && pytest tests/test_script_builder_inline.py -v --override-ini='addopts='" +``` + +Expected: 5 passed. + +- [ ] **Step 7: Sanity-check that unrelated tests still pass** + +```bash +docker exec resolutionflow_backend sh -c "cd /app && pytest tests/test_script_builder.py tests/test_session_suggested_fixes_api.py tests/test_fix_outcome_endpoint.py tests/test_fix_outcome_marker.py -v --override-ini='addopts='" +``` + +Expected: pass except for the pre-existing `test_record_decision_persists_and_bumps_state_version` (documented as Phase 8 Issue #4). + +- [ ] **Step 8: Commit** + +```bash +cd /config/workspace/resolutionflow && git add backend/ \ + && git commit -m "$(cat <<'EOF' +feat(pilot): inline Script Builder session — idempotent create + auth + filtered list + +POST /script-builder/sessions now supports origin='pilot_inline': +- Requires ai_session_id; validates it against current user ownership. +- Get-or-create: returns existing row for (user, ai_session_id) pair. +- Partial unique index on the DB backs the invariant; races resolve to + the single winner row. + +list_sessions + count_user_sessions default-scope to origin='standalone' +so inline scratch sessions don't pollute the /script-builder dashboard +or count against the 5-session cap. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: `PATCH /suggested-fixes/:id/script` — new endpoint + +**Files:** +- Modify: `backend/app/api/endpoints/session_suggested_fixes.py` +- Create: `backend/tests/test_fix_script_endpoint.py` + +- [ ] **Step 1: Write the failing tests** + +Create `backend/tests/test_fix_script_endpoint.py`: + +```python +"""Integration tests for PATCH /ai-sessions/{sid}/suggested-fixes/{fid}/script.""" +from __future__ import annotations + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from uuid import uuid4 +from datetime import datetime, timezone + +from app.models.ai_session import AISession +from app.models.session_suggested_fix import SessionSuggestedFix + + +async def _make_session_with_fix( + test_db, user, *, status: str = "proposed", with_script: bool = False, +) -> tuple[str, str]: + """Create a pilot session + suggested fix for tests. Returns (sid, fid).""" + session = AISession( + id=uuid4(), user_id=user.id, account_id=user.account_id, + session_type="tshoot", intake_type="ticket", + intake_content={}, title="QA", + status="active", confidence_tier="exploring", confidence_score=0.0, + ) + test_db.add(session) + await test_db.flush() + fix = SessionSuggestedFix( + id=uuid4(), session_id=session.id, account_id=user.account_id, + title="QA: test fix", description="desc", confidence_pct=80, + status=status, + ai_drafted_script="pre-existing" if with_script else None, + ) + test_db.add(fix) + await test_db.commit() + return str(session.id), str(fix.id) + + +@pytest.mark.asyncio +async def test_patch_script_happy_path( + client: AsyncClient, test_user, auth_headers, test_db +): + sid, fid = await _make_session_with_fix(test_db, test_user) + r = await client.patch( + f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script", + json={"ai_drafted_script": "Write-Host 'hello'"}, + headers=auth_headers, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["ai_drafted_script"] == "Write-Host 'hello'" + assert body["applied_at"] is None # draft ≠ apply + assert body["status"] == "proposed" + + +@pytest.mark.asyncio +async def test_patch_script_bumps_state_version( + client: AsyncClient, test_user, auth_headers, test_db +): + sid, fid = await _make_session_with_fix(test_db, test_user) + before = await test_db.scalar( + select(AISession.state_version).where(AISession.id == uuid4()).where( + AISession.id == __import__("uuid").UUID(sid) + ) + ) + r = await client.patch( + f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script", + json={"ai_drafted_script": "echo hi"}, + headers=auth_headers, + ) + assert r.status_code == 200 + after = await test_db.scalar( + select(AISession.state_version).where( + AISession.id == __import__("uuid").UUID(sid) + ) + ) + assert after == (before or 0) + 1 + + +@pytest.mark.asyncio +async def test_patch_script_rejects_terminal_fix( + client: AsyncClient, test_user, auth_headers, test_db +): + sid, fid = await _make_session_with_fix(test_db, test_user, status="applied_success") + r = await client.patch( + f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script", + json={"ai_drafted_script": "echo hi"}, + headers=auth_headers, + ) + assert r.status_code == 409 + + +@pytest.mark.asyncio +async def test_patch_script_rejects_empty_body( + client: AsyncClient, test_user, auth_headers, test_db +): + sid, fid = await _make_session_with_fix(test_db, test_user) + r = await client.patch( + f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script", + json={"ai_drafted_script": ""}, + headers=auth_headers, + ) + assert r.status_code == 422 # pydantic min_length=1 + + +@pytest.mark.asyncio +async def test_patch_script_404_on_wrong_session( + client: AsyncClient, test_user, auth_headers, test_db +): + _, fid = await _make_session_with_fix(test_db, test_user) + wrong_sid = str(uuid4()) + r = await client.patch( + f"/api/v1/ai-sessions/{wrong_sid}/suggested-fixes/{fid}/script", + json={"ai_drafted_script": "echo hi"}, + headers=auth_headers, + ) + assert r.status_code == 404 +``` + +- [ ] **Step 2: Run — confirm 404/405 (endpoint doesn't exist)** + +```bash +docker exec resolutionflow_backend sh -c "cd /app && pytest tests/test_fix_script_endpoint.py -v --override-ini='addopts='" +``` + +Expected: 5 failures, likely 404 or 405 on PATCH. + +- [ ] **Step 3: Add the endpoint** + +In `backend/app/api/endpoints/session_suggested_fixes.py`, near the existing `patch_suggested_fix_outcome` handler, add: + +```python +@router.patch( + "/ai-sessions/{session_id}/suggested-fixes/{fix_id}/script", + response_model=SessionSuggestedFixResponse, +) +async def patch_suggested_fix_script( + session_id: UUID, + fix_id: UUID, + body: SessionSuggestedFixScriptRequest, + current_user: User = Depends(require_engineer_or_admin), + db: AsyncSession = Depends(get_db), +) -> SessionSuggestedFixResponse: + """Attach an engineer-drafted script to a suggested fix. + + Called by the inline Script Builder tab on Submit. Does NOT stamp + applied_at — a draft is not an application. Bumps state_version so + the Resolve/Escalate preview bundles regenerate. + """ + await _load_session_or_404(db, session_id) + + 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") + + 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.ai_drafted_script = body.ai_drafted_script + fix.ai_drafted_parameters = body.ai_drafted_parameters + + # Bump state_version on the parent session — previews cached by + # (session_id, state_version) must regenerate to reflect the new draft. + await db.execute( + update(AISession) + .where(AISession.id == session_id) + .values(state_version=AISession.state_version + 1) + ) + + await db.commit() + await db.refresh(fix) + return SessionSuggestedFixResponse.model_validate(fix) +``` + +Add imports as needed: `SessionSuggestedFixScriptRequest`, `AISession` (if not already imported), and `update` from sqlalchemy. + +- [ ] **Step 4: Run — expect 5 passing** + +```bash +docker exec resolutionflow_backend sh -c "cd /app && pytest tests/test_fix_script_endpoint.py -v --override-ini='addopts='" +``` + +Expected: 5 passed. + +- [ ] **Step 5: Commit** + +```bash +cd /config/workspace/resolutionflow && git add backend/ \ + && git commit -m "$(cat <<'EOF' +feat(pilot): PATCH /suggested-fixes/:id/script endpoint + +Called by the inline Script Builder tab on Submit. Writes +ai_drafted_script + ai_drafted_parameters to the fix without stamping +applied_at (a draft is not an application — that's §5 of the Phase 9 +spec). Bumps state_version so Resolve/Escalate preview bundles +regenerate. + +409 on terminal fix status. 404 on wrong session. 422 on empty script. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: Frontend API client — `patchScript` + extended `createSession` + +**Files:** +- Modify: `frontend/src/api/sessionSuggestedFixes.ts` +- Modify: `frontend/src/api/scriptBuilder.ts` (or equivalent — check the existing file name) + +- [ ] **Step 1: Add `patchScript` to `sessionSuggestedFixesApi`** + +In `frontend/src/api/sessionSuggestedFixes.ts`, after the `patchOutcome` method (and before `clearAIProposal`), add: + +```ts + /** + * Attach an engineer-drafted script to a suggested fix (inline Script + * Builder Submit path). Does NOT stamp applied_at — the server treats + * a draft as non-terminal progress. Bumps state_version so the + * Resolve/Escalate preview regenerates. + */ + async patchScript( + sessionId: string, + fixId: string, + aiDraftedScript: string, + aiDraftedParameters?: Record, + ): Promise { + const r = await apiClient.patch( + `/ai-sessions/${sessionId}/suggested-fixes/${fixId}/script`, + { + ai_drafted_script: aiDraftedScript, + ai_drafted_parameters: aiDraftedParameters, + }, + ) + return r.data + }, +``` + +- [ ] **Step 2: Extend `createSession` with inline-origin args** + +Find the script-builder API client. Grep: +```bash +grep -rn "script-builder/sessions" /config/workspace/resolutionflow/frontend/src/api/ +``` + +In that file (likely `frontend/src/api/scriptBuilder.ts`), update `createSession` to accept an optional options bag: + +```ts +export interface CreateSessionOptions { + origin?: 'standalone' | 'pilot_inline' + aiSessionId?: string +} + +export async function createSession( + language: string, + options?: CreateSessionOptions, +): Promise { + const r = await apiClient.post( + '/script-builder/sessions', + { + language, + origin: options?.origin, + ai_session_id: options?.aiSessionId, + }, + ) + return r.data +} +``` + +Existing callers pass only `language` — they continue to work (origin defaults to `'standalone'` on the server). + +- [ ] **Step 3: Verify types** + +```bash +docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b" +``` + +Expected: clean. + +- [ ] **Step 4: Commit** + +```bash +cd /config/workspace/resolutionflow && git add frontend/src/api/ \ + && git commit -m "$(cat <<'EOF' +feat(pilot): frontend API client — patchScript + inline createSession + +sessionSuggestedFixesApi.patchScript(sessionId, fixId, script, params?) +hits the new PATCH /script endpoint. + +scriptBuilder.createSession accepts an optional options bag with +origin + aiSessionId, defaulting to standalone when omitted so legacy +callers stay behavior-preserving. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: `ChatTabStrip` component + +**Files:** +- Create: `frontend/src/components/pilot/ChatTabStrip.tsx` + +- [ ] **Step 1: Write the component** + +```tsx +/** + * ChatTabStrip — two-tab strip at the top of the chat region: + * [Chat] [Script Builder ●] + * + * Visibility is controlled by the parent (AssistantChatPage) — this + * component renders whenever it's mounted. The parent decides whether + * to mount it based on fix state. + * + * Tab switching uses onChange; the parent toggles display:none on the + * tab contents so state is preserved across switches. + */ +import { cn } from '@/lib/utils' + +export type ChatTab = 'chat' | 'script_builder' + +export interface ChatTabStripProps { + active: ChatTab + onChange: (tab: ChatTab) => void + /** When true, shows the amber indicator dot on the Script Builder tab. */ + scriptBuilderHasProgress?: boolean +} + +export function ChatTabStrip({ + active, onChange, scriptBuilderHasProgress, +}: ChatTabStripProps) { + return ( +
+ onChange('chat')} + /> + onChange('script_builder')} + indicator={scriptBuilderHasProgress} + /> +
+ ) +} + +function TabButton({ + label, active, onClick, indicator, +}: { + label: string + active: boolean + onClick: () => void + indicator?: boolean +}) { + return ( + + ) +} + +export default ChatTabStrip +``` + +- [ ] **Step 2: Build** + +```bash +docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b" +``` + +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +cd /config/workspace/resolutionflow && git add frontend/src/components/pilot/ChatTabStrip.tsx \ + && git commit -m "$(cat <<'EOF' +feat(pilot): ChatTabStrip component — [Chat] [Script Builder ●] + +Two-tab strip for the chat region. Parent controls mounting (strip only +appears when the fix needs a script drafted). Indicator dot signals +in-progress draft state. Tab switching via onChange callback; parent +handles display:none toggling so tab contents preserve state. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 8: `ScriptBuilderTab` controller + +**Files:** +- Create: `frontend/src/components/pilot/ScriptBuilderTab.tsx` + +- [ ] **Step 1: Inspect existing pieces we're stitching together** + +Read these files to understand the surfaces: +- `frontend/src/components/script-builder/ScriptBuilderChat.tsx` — the presentational chat (takes `messages`, `language`, `onViewScript`, `onSaveScript`, `isLoading`). +- `frontend/src/pages/ScriptBuilderPage.tsx` — how `ScriptBuilderChat` is wired today; see how messages are sent via `sendMessage` / how `onSaveScript` is handled. +- `frontend/src/components/tree-editor/code-mode/CodeModeEditor.tsx` — the existing Monaco wrapper. +- `frontend/src/api/scriptBuilder.ts` — existing API functions. + +Take note of: +- The message-send function signature. +- The "latest script" field from a session (likely `latest_script` + `latest_script_filename`). +- How `CodeModeEditor` is mounted (props, theme, language). + +- [ ] **Step 2: Write the controller** + +```tsx +/** + * ScriptBuilderTab — inline Script Builder controller mounted in the + * FlowPilot chat region when a fix needs a script drafted. + * + * Owns: + * - The inline `script_builder_sessions` row (get-or-create via the + * POST endpoint with origin='pilot_inline' + ai_session_id). + * - AI message state (reuses existing ScriptBuilderChat + sendMessage API). + * - Monaco buffer for the "Write it myself" mode. + * - Submit → PATCH /suggested-fixes/:id/script (no applied_at stamp). + * + * ScriptBuilderChat stays a pure display component — this controller + * wires its onSaveScript to the inline submit path. + */ +import { useEffect, useRef, useState } from 'react' +import { Sparkles, Pencil } from 'lucide-react' +import { cn } from '@/lib/utils' +import { ScriptBuilderChat } from '@/components/script-builder/ScriptBuilderChat' +import { sessionSuggestedFixesApi, type SessionSuggestedFix } from '@/api/sessionSuggestedFixes' +import { + createSession as createScriptBuilderSession, + sendMessage as sendScriptBuilderMessage, + listMessages as listScriptBuilderMessages, +} from '@/api/scriptBuilder' +import { CodeModeEditor } from '@/components/tree-editor/code-mode/CodeModeEditor' +import type { ScriptBuilderMessage } from '@/types' + +export interface ScriptBuilderTabProps { + fix: SessionSuggestedFix + pilotSessionId: string + /** Fires whenever in-progress state changes (for the ChatTabStrip dot). */ + onProgressChange: (hasProgress: boolean) => void + /** Fires on successful submit; parent uses this to refresh the fix and hide the tab. */ + onScriptDrafted: (updated: SessionSuggestedFix) => void +} + +type Mode = 'ai' | 'editor' + +export function ScriptBuilderTab({ + fix, pilotSessionId, onProgressChange, onScriptDrafted, +}: ScriptBuilderTabProps) { + const [builderSessionId, setBuilderSessionId] = useState(null) + const [mode, setMode] = useState('ai') + const [messages, setMessages] = useState([]) + const [editorBuffer, setEditorBuffer] = useState( + () => scaffoldForLanguage('powershell', fix.description), + ) + const [aiLoading, setAiLoading] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const [latestScript, setLatestScript] = useState(null) + + // Track indicator-dot state and relay to parent. + useEffect(() => { + const hasProgress = messages.length > 0 || editorBuffer.trim() !== scaffoldForLanguage('powershell', fix.description).trim() + onProgressChange(hasProgress) + }, [messages.length, editorBuffer, fix.description, onProgressChange]) + + // Get-or-create the inline session on mount. + useEffect(() => { + let cancelled = false + ;(async () => { + try { + const s = await createScriptBuilderSession('powershell', { + origin: 'pilot_inline', + aiSessionId: pilotSessionId, + }) + if (cancelled) return + setBuilderSessionId(s.id) + // Fetch any existing messages for resume. + const existing = await listScriptBuilderMessages(s.id) + if (cancelled) return + setMessages(existing) + if (s.latest_script) setLatestScript(s.latest_script) + } catch (e) { + if (!cancelled) setError('Failed to start Script Builder session.') + } + })() + return () => { cancelled = true } + }, [pilotSessionId]) + + const handleSendMessage = async (content: string) => { + if (!builderSessionId) return + setAiLoading(true) + try { + const reply = await sendScriptBuilderMessage(builderSessionId, content) + setMessages((prev) => [...prev, ...reply.messages]) + if (reply.latest_script) setLatestScript(reply.latest_script) + } finally { + setAiLoading(false) + } + } + + const handleSubmit = async (script: string) => { + if (!script.trim()) return + setSubmitting(true) + setError(null) + try { + const updated = await sessionSuggestedFixesApi.patchScript( + pilotSessionId, fix.id, script, + ) + onScriptDrafted(updated) + } catch { + setError('Failed to save the drafted script.') + } finally { + setSubmitting(false) + } + } + + // AI mode uses whatever latestScript the AI produced as the body on save. + const handleAiSave = () => { + if (latestScript) void handleSubmit(latestScript) + } + + return ( +
+
+
+ Script Builder · {fix.title} +
+
+ {mode === 'ai' ? ( + + ) : ( + + )} +
+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ { /* optional future: inline preview */ }} + onSaveScript={handleAiSave} + isLoading={aiLoading} + /> +
+
+ +
+
+ + {mode === 'editor' && ( +
+ +
+ )} +
+ ) +} + +function scaffoldForLanguage(language: string, fixDescription: string): string { + if (language === 'bash' || language === 'python') { + return `# ${fixDescription}\n\n` + } + return `# ${fixDescription}\r\n\r\n` +} + +export default ScriptBuilderTab +``` + +**Note on shape drift:** The actual signatures of `sendScriptBuilderMessage` / `listScriptBuilderMessages` / the returned `ScriptBuilderSession` may differ from what I wrote above (field names, shape of the reply). Read the existing `frontend/src/api/scriptBuilder.ts` + `ScriptBuilderPage.tsx` and adapt the controller's calls to match — the structure of the component is what matters, not the exact wire names. + +If any function (e.g. `listMessages`) doesn't exist in the current API client, check how `ScriptBuilderPage` fetches existing messages and copy that pattern. + +- [ ] **Step 3: Build** + +```bash +docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b && npm run build" +``` + +Expected: clean. Adapt to any real-world API mismatches revealed by the build. + +- [ ] **Step 4: Commit** + +```bash +cd /config/workspace/resolutionflow && git add frontend/src/components/pilot/ScriptBuilderTab.tsx \ + && git commit -m "$(cat <<'EOF' +feat(pilot): ScriptBuilderTab controller + +Owns the inline Script Builder session lifecycle: +- Get-or-create (origin='pilot_inline', ai_session_id) on mount. +- Renders ScriptBuilderChat in AI mode and CodeModeEditor (Monaco) in + 'Write it myself' mode. Mode toggles via display:none so buffer and + messages persist across switches. +- Submit → sessionSuggestedFixesApi.patchScript; emits onScriptDrafted + to parent, which refreshes the fix and hides the tab strip. +- Relays in-progress state to the parent via onProgressChange for the + ChatTabStrip's indicator dot. + +ScriptBuilderChat is untouched (stays presentational). Persistence +semantics live on the controller, not the display component. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 9: `InlineNoTemplateDialog` wrapper — chat-region placement + +**Files:** +- Create: `frontend/src/components/pilot/InlineNoTemplateDialog.tsx` + +- [ ] **Step 1: Inspect the existing dialog + decide reuse strategy** + +Read `frontend/src/components/pilot/script/NoTemplateDialog.tsx`. The three-card UX is already built. We need to render it in the chat region (above the composer, like `ProposalBanner`), not inside the TaskLane's narrow `bottomSlot`. + +Strategy: a thin wrapper that positions the existing component appropriately. `NoTemplateDialog`'s internal grid is `grid-cols-1 sm:grid-cols-3`; in the chat region the `sm:` breakpoint will engage at its viewport-based trigger, and the chat region's width is sufficient for three cards. No internal change to `NoTemplateDialog` is required. + +- [ ] **Step 2: Write the wrapper** + +```tsx +/** + * InlineNoTemplateDialog — chat-region placement wrapper for + * NoTemplateDialog. Renders above the composer (slide-up animation + * matching ProposalBanner), using the full chat-region width so the + * three decision cards fit side-by-side. + */ +import { NoTemplateDialog } from '@/components/pilot/script/NoTemplateDialog' +import type { ComponentProps } from 'react' + +type Props = ComponentProps + +export function InlineNoTemplateDialog(props: Props) { + return ( +
+
+ +
+
+ ) +} + +export default InlineNoTemplateDialog +``` + +- [ ] **Step 3: Build** + +```bash +docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b" +``` + +Expected: clean. + +- [ ] **Step 4: Commit** + +```bash +cd /config/workspace/resolutionflow && git add frontend/src/components/pilot/InlineNoTemplateDialog.tsx \ + && git commit -m "$(cat <<'EOF' +feat(pilot): InlineNoTemplateDialog — chat-region placement wrapper + +Slide-up wrapper around the existing NoTemplateDialog for rendering +in the chat region above the composer (parallel to ProposalBanner). +The chat region's width lets grid-cols-3 finally work as intended. + +No change to NoTemplateDialog itself; decision callbacks and card +copy stay identical. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 10: `TemplateMatchPanel` — new "I ran this" action + +**Files:** +- Modify: `frontend/src/components/pilot/script/TemplateMatchPanel.tsx` + +- [ ] **Step 1: Read the current component to find the existing button row** + +Open `frontend/src/components/pilot/script/TemplateMatchPanel.tsx`. Locate the section that renders the Generate / Copy / Edit Parameters buttons — you'll add a new primary button adjacent to these. + +- [ ] **Step 2: Add a new prop + button** + +Extend the props interface: + +```ts +export interface TemplateMatchPanelProps { + // ... existing props ... + /** Fires when the engineer declares the script was run. Parent calls + * applyFix() to stamp applied_at. */ + onMarkRun?: () => void +} +``` + +Find the button-row JSX. Add a new button, styled as accent-colored primary: + +```tsx +{onMarkRun && ( + +)} +``` + +Position it as the right-most action (prominent terminal action) in the existing row. + +- [ ] **Step 3: Destructure the new prop** + +Make sure the component destructures `onMarkRun` from its props, or update the props pattern consistently with existing props. + +- [ ] **Step 4: Build** + +```bash +docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b" +``` + +Expected: clean (no call sites provide `onMarkRun` yet, but it's optional). + +- [ ] **Step 5: Commit** + +```bash +cd /config/workspace/resolutionflow && git add frontend/src/components/pilot/script/TemplateMatchPanel.tsx \ + && git commit -m "$(cat <<'EOF' +feat(pilot): TemplateMatchPanel — explicit 'I ran this' action + +Generate and Copy alone don't declare a run — the engineer can walk +away after copying. Phase 9 §5 defines an explicit run-declaration +affordance so applied_at only stamps on the engineer's positive +commitment. Wiring from AssistantChatPage lands in Task 13. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 11: `EscalateInterceptDialog` — fourth "partial" choice + +**Files:** +- Modify: `frontend/src/components/pilot/EscalateInterceptDialog.tsx` + +- [ ] **Step 1: Add the fourth button** + +Open the file. Locate the three existing option buttons ("The fix didn't work", "It worked — escalating for another reason", "Never actually applied it"). Add a fourth option button after the "didn't work" autofocus button: + +```tsx + +``` + +The `InterceptChoice` type already includes `'applied_partial'` via its `FixOutcome | 'never_applied'` definition — no type changes needed. + +- [ ] **Step 2: Build** + +```bash +docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b" +``` + +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +cd /config/workspace/resolutionflow && git add frontend/src/components/pilot/EscalateInterceptDialog.tsx \ + && git commit -m "$(cat <<'EOF' +feat(pilot): EscalateInterceptDialog — fourth 'partial' choice + +Closes the gap Phase 8 final review flagged. When a fix is in +applied_partial state and the engineer escalates, the intercept no +longer forces them to approximate with didn't-work/worked/never-applied. + +AssistantChatPage's handleInterceptChoice (Task 13) already dispatches +to patchOutcome for any FixOutcome value, so no handler change is +needed — the type already supports applied_partial. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 12: `AssistantChatPage` — tab strip + tab mount + banner routing + +**Files:** +- Modify: `frontend/src/pages/AssistantChatPage.tsx` + +This task scaffolds the new UI surface in the parent. Task 13 handles the trickier call-site moves. + +- [ ] **Step 1: Add imports + state** + +Open `frontend/src/pages/AssistantChatPage.tsx`. Add imports: + +```tsx +import { ChatTabStrip, type ChatTab } from '@/components/pilot/ChatTabStrip' +import { ScriptBuilderTab } from '@/components/pilot/ScriptBuilderTab' +import { InlineNoTemplateDialog } from '@/components/pilot/InlineNoTemplateDialog' +``` + +Near the other Phase 8 state declarations (e.g., `bannerCollapsed`, `postApplyMsgCount`), add: + +```tsx +const [chatTab, setChatTab] = useState('chat') +const [scriptBuilderHasProgress, setScriptBuilderHasProgress] = useState(false) +``` + +Update `resetSessionDerivedState`: + +```tsx +setChatTab('chat') +setScriptBuilderHasProgress(false) +``` + +- [ ] **Step 2: Derive `showTabStrip` from fix state** + +Near the other derived values (probably close to `bannerMode`): + +```tsx +const showTabStrip = + activeFix != null + && activeFix.status !== 'dismissed' + && activeFix.status !== 'applied_success' + && activeFix.status !== 'applied_failed' + && !activeFix.script_template_id + && !activeFix.ai_drafted_script + +// Defensive: if the strip hides, snap back to the Chat tab. +useEffect(() => { + if (!showTabStrip && chatTab === 'script_builder') setChatTab('chat') +}, [showTabStrip, chatTab]) +``` + +- [ ] **Step 3: Mount the strip + tab content in the chat region** + +Find the chat region JSX (the scrollable messages list + composer). Wrap it so the tab strip renders above the messages area and the Script Builder tab renders beside (via `display:none` toggling): + +```tsx +{showTabStrip && ( + +)} + +
+ {/* existing chat messages + composer JSX remains here unchanged */} +
+ +{showTabStrip && activeFix && activeChatId && ( +
+ { + setActiveFix(updated) + setChatTab('chat') + setScriptBuilderHasProgress(false) + }} + /> +
+)} +``` + +- [ ] **Step 4: Banner Apply routing — route no-draft case to the Script Builder tab** + +Find `handleApplyFix`. Update the routing: + +```tsx +const handleApplyFix = useCallback(() => { + if (!activeFix) return + if (activeFix.script_template_id) { + setScriptPanelOpen(true) // existing TemplateMatchPanel flow, task lane + return + } + if (activeFix.ai_drafted_script) { + setScriptPanelOpen(true) // existing dialog flow, NOW rendering in chat region (Step 5) + return + } + // No draft, no template — route to the Script Builder tab. + setChatTab('script_builder') +}, [activeFix]) +``` + +**IMPORTANT:** do NOT call `sessionSuggestedFixesApi.applyFix(...)` here anymore. That move happens in Task 13. + +- [ ] **Step 5: Render `InlineNoTemplateDialog` in the chat region** + +Today's dialog renders inside `TaskLane.bottomSlot` (for both draft and template cases). For the drafted-script case, render it in the chat region instead. Find the existing `NoTemplateDialog` render (likely near the task-lane bottomSlot). Wrap the draft case and move it outside the TaskLane slot: + +```tsx +{/* In the chat region, just above the composer */} +{scriptPanelOpen && activeFix && activeChatId && !activeFix.script_template_id && activeFix.ai_drafted_script && ( + setScriptPanelOpen(false)} + onDecide={handleScriptDecision} + busy={scriptDecisionBusy} + /> +)} +``` + +In the existing `TaskLane.bottomSlot` render, REMOVE the `NoTemplateDialog` branch (the no-template-but-drafted case). Keep the `TemplateMatchPanel` branch there — it stays in the task lane. + +- [ ] **Step 6: Build + smoke-test** + +```bash +docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b && npm run build" +``` + +Expected: clean. + +Start the dev stack and smoke-test: +- Open a session where the AI has emitted `[SUGGEST_FIX]` without a drafted script. Banner appears. +- Click Apply → the tab strip shows, Script Builder tab is active, tab contents render (you can verify the get-or-create endpoint fires in the Network panel). +- Switch between Chat and Script Builder tabs — chat scroll position preserved. + +- [ ] **Step 7: Commit** + +```bash +cd /config/workspace/resolutionflow && git add frontend/src/pages/AssistantChatPage.tsx \ + && git commit -m "$(cat <<'EOF' +feat(pilot): mount ChatTabStrip + ScriptBuilderTab + InlineNoTemplateDialog + +Wires the three new components into AssistantChatPage: +- ChatTabStrip renders when the active fix needs a script drafted. +- ScriptBuilderTab sits alongside chat via display:none toggling so + chat scroll position + builder state both persist. +- InlineNoTemplateDialog replaces the task-lane bottomSlot render for + the drafted-script evaluation case; three cards finally fit. +- Banner Apply routing updated: no-draft/no-template → Script Builder + tab; drafted → InlineNoTemplateDialog; template → unchanged path. + +applyFix() call site moves land in the next task. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 13: `applyFix` call-site relocation — stamp only on run-declaring actions + +**Files:** +- Modify: `frontend/src/pages/AssistantChatPage.tsx` + +- [ ] **Step 1: Remove the `applyFix` call from `handleApplyFix`** + +Open `frontend/src/pages/AssistantChatPage.tsx`. Locate `handleApplyFix`. Remove the line that calls `sessionSuggestedFixesApi.applyFix(...)` (it was added in Phase 8 Issue #2). The handler should now only route to the right surface (you already did this in Task 12 Step 4). + +- [ ] **Step 2: Add `applyFix` to `handleScriptDecision` for `one_off` and `draft_template`** + +Find `handleScriptDecision`. Both `one_off` and `draft_template` declare a run (their labels: "Run now, no template" and "Run now, templatize after"). Stamp `applied_at` in both branches: + +```tsx +const handleScriptDecision = useCallback( + async (decision: UserDecision, options?: { editedScript?: string; parametersUsed?: Record }) => { + if (!activeFix || !activeChatId) return + setScriptDecisionBusy(true) + try { + const result = await sessionSuggestedFixesApi.recordDecision( + activeChatId, activeFix.id, decision, options, + ) + // Phase 9 §5: one_off and draft_template declare a run. Stamp applied_at + // to transition the fix into Verifying. build_template does NOT run. + if (decision === 'one_off' || decision === 'draft_template') { + try { + const updated = await sessionSuggestedFixesApi.applyFix( + activeChatId, activeFix.id, + ) + setActiveFix(updated) + } catch { /* non-fatal: engineer can still mark outcome later */ } + } + // ... existing side-effects (redirect to script builder for build_template, etc.) ... + } finally { + setScriptDecisionBusy(false) + } + }, + [activeFix, activeChatId], +) +``` + +(Keep whatever existing post-recordDecision logic is already in the handler — this diff adds the stamp, doesn't replace anything else.) + +- [ ] **Step 3: Wire `onMarkRun` on `TemplateMatchPanel`** + +Find the `TemplateMatchPanel` render site. Add the `onMarkRun` prop: + +```tsx + setScriptPanelOpen(false)} + onMarkRun={async () => { + try { + const updated = await sessionSuggestedFixesApi.applyFix( + activeChatId, activeFix.id, + ) + setActiveFix(updated) + setScriptPanelOpen(false) + } catch { /* non-fatal */ } + }} +/> +``` + +- [ ] **Step 4: Build + smoke test** + +```bash +docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b && npm run build" +``` + +Manual browser QA: +1. Fix with `script_template_id` → Apply → `TemplateMatchPanel` opens, banner stays on Proposed state. Click "I ran this" → banner flips to Verifying. +2. Fix with `ai_drafted_script` → Apply → `InlineNoTemplateDialog` opens, banner stays on Proposed. Click `one_off` card → banner flips to Verifying. (Repeat with `draft_template` — same result.) Click `build_template` → navigates; banner stays on Proposed. +3. Fix with neither → Apply → Script Builder tab opens, banner stays on Proposed. Draft + submit → tab disappears. Engineer clicks Apply again → `InlineNoTemplateDialog` opens. Pick `one_off` → banner flips to Verifying. + +- [ ] **Step 5: Commit** + +```bash +cd /config/workspace/resolutionflow && git add frontend/src/pages/AssistantChatPage.tsx \ + && git commit -m "$(cat <<'EOF' +fix(pilot): applied_at stamps on run-declaring actions, not Apply click + +Per Phase 9 §5. Before: banner Apply click stamped applied_at +regardless of whether the engineer had committed to running anything, +starting the Verifying timer prematurely. After: + +- handleApplyFix no longer calls applyFix(). It just routes to the + right surface (TemplateMatchPanel / InlineNoTemplateDialog / Script + Builder tab). +- handleScriptDecision stamps applied_at for one_off + draft_template + (both labels are 'Run now, …' — the click is the declaration). + build_template does not stamp. +- TemplateMatchPanel's new 'I ran this' button calls applyFix via a + new onMarkRun prop. +- Script Builder tab Submit does not stamp (a draft is not a run). + +No backend change — the /apply endpoint is unchanged. Only call sites +move. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 14: Cleanup — TaskLane `bottomSlot` audit + handoff docs + +**Files:** +- Modify: `frontend/src/components/pilot/TaskLane.tsx` +- Modify: `docs/handoff/2026-04-22-flowpilot-migration.md` +- Modify: `docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md` + +- [ ] **Step 1: Verify TaskLane `bottomSlot` is still needed** + +Grep: +```bash +grep -n "bottomSlot" /config/workspace/resolutionflow/frontend/src/components/pilot/TaskLane.tsx \ + /config/workspace/resolutionflow/frontend/src/pages/AssistantChatPage.tsx +``` + +If `AssistantChatPage` now only passes `bottomSlot` with the `TemplateMatchPanel` branch (no `NoTemplateDialog`), the slot is still used but narrower in purpose. Leave the prop definition alone (keep API stable). + +If `AssistantChatPage` no longer passes `bottomSlot` at all, remove the prop from `TaskLane.tsx`'s interface + destructure + render site. + +- [ ] **Step 2: Update the handoff doc** + +`docs/handoff/2026-04-22-flowpilot-migration.md`: +- `last_commit` bump to the Phase 9 tip. +- Update frontmatter status line to reflect Phase 9 shipped ("Sprint 9/9 phases…" or equivalent). +- Add a Phase 9 row to the "What shipped" table listing the tab strip, ScriptBuilderTab, PATCH /script endpoint, migration, and the applied_at semantics correction. +- Mark open items #1 (NoTemplateDialog narrow-lane) and #3 (Tabbed Script Builder) as RESOLVED with a pointer to this plan + the spec. +- Loose ends: `/ultrareview` still not run on the final branch; PR still not opened; browser QA of Phase 9 states not done from this branch. + +- [ ] **Step 3: Update the migration spec** + +`docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md`: +- Status line + "last updated" header bumped. +- New Phase 9 section (5–10 lines) describing: tab strip + ScriptBuilderTab, InlineNoTemplateDialog relocation, PATCH /script endpoint, EscalateInterceptDialog partial choice, applied_at semantics correction. Link to the spec + implementation plan files. + +- [ ] **Step 4: Run full test suite as a final guard** + +```bash +docker exec resolutionflow_backend sh -c "cd /app && pytest tests/ --override-ini='addopts=' -q" +``` + +Expected: everything passes except the pre-existing `test_record_decision_persists_and_bumps_state_version` failure (Phase 8 Issue #4, separately documented). + +- [ ] **Step 5: Commit** + +```bash +cd /config/workspace/resolutionflow && git add -A \ + && git commit -m "$(cat <<'EOF' +docs(pilot): Phase 9 handoff + migration spec update + +Marks open items #1 (NoTemplateDialog narrow-lane) and #3 (Tabbed +Script Builder) as resolved. Records the applied_at semantics +correction as shipped. Final Phase 9 row added to the 'What shipped' +table. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Self-Review + +**Spec coverage:** + +| Spec section | Covered by | +|---|---| +| §1 Chat region tab strip | Tasks 7, 12 | +| §2 Script Builder tab content + controller | Tasks 8, 12 | +| §3 NoTemplateDialog relocation | Tasks 9, 12 | +| §4 Banner Apply routing (updated) | Task 12 step 4 | +| §5 Apply lifecycle (`applied_at` correction) | Tasks 10, 13 | +| §6 EscalateInterceptDialog partial | Task 11 | +| New migration (origin + partial unique index) | Task 1 | +| Model column | Task 2 | +| ScriptBuilderCreateRequest extended | Task 3 | +| SessionSuggestedFixScriptRequest | Task 3 | +| POST /script-builder/sessions idempotent + auth | Task 4 | +| list/count filter | Task 4 step 4 | +| PATCH /script endpoint + tests | Task 5 | +| Frontend API client | Task 6 | +| ChatTabStrip | Task 7 | +| ScriptBuilderTab | Task 8 | +| InlineNoTemplateDialog wrapper | Task 9 | +| TemplateMatchPanel "I ran this" | Task 10 | +| EscalateInterceptDialog fourth choice | Task 11 | +| `applied_at` call-site moves | Task 13 | +| Task-lane cleanup + docs | Task 14 | + +No gaps. + +**Placeholder scan:** No TBD/TODO entries; no "add error handling" placeholders; every code step has full code. One intentional placeholder: `` in Task 1 (standard Alembic workflow). The Task 8 code contains a paragraph acknowledging that `sendScriptBuilderMessage` / `listScriptBuilderMessages` API shapes may need adaptation — that's a knowable-by-reading note, not a "TBD" the implementer has to invent. + +**Type consistency:** `SessionSuggestedFix` (interface), `FixOutcome` (type), `InterceptChoice` (type), `ChatTab` (type), `ScriptBuilderSession` (interface), `ChatTabStripProps` / `ScriptBuilderTabProps` / `TemplateMatchPanelProps` — all spelled identically across tasks. `patchScript` / `applyFix` / `createSession` / `patchOutcome` method names match between API client tasks and consumer tasks. + +**Risks called out inline:** +- Task 4 Step 5 may need minor adaptation if `_load_session_or_404`'s signature has drifted or if the existing service has a subtle ownership check elsewhere. +- Task 8's controller assumes API-client function names that might not exactly match the existing codebase — the task includes an explicit adapt-to-reality instruction. +- Task 12 Step 5's "remove the draft case from TaskLane bottomSlot" is the only destructive change; guarded by the preceding render moving first. + +--- + +**Plan complete and saved to `docs/FlowAssist_Migration/phase-9-implementation-plan.md`. Two execution options:** + +**1. Subagent-Driven (recommended)** — 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?**