# 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?**