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) <noreply@anthropic.com>
68 KiB
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
File Structure
Backend — new
backend/alembic/versions/<hash>_script_builder_origin.py— migrationbackend/tests/test_fix_script_endpoint.py— PATCH /script testsbackend/tests/test_script_builder_inline.py— inline session tests (idempotency, auth, list/count filter)
Backend — modified
backend/app/models/script_builder_session.py— addorigincolumnbackend/app/schemas/script_builder.py— extendScriptBuilderCreateRequestwithorigin+ai_session_idbackend/app/schemas/session_suggested_fix.py— addSessionSuggestedFixScriptRequestbackend/app/api/endpoints/script_builder.py— validate + resolve inline origin increate_sessionbackend/app/api/endpoints/session_suggested_fixes.py— add PATCH /script endpointbackend/app/services/script_builder_service.py— persistorigin; idempotent inline lookup; filterlist_sessions/count_user_sessionsto standalone
Frontend — new
frontend/src/components/pilot/ChatTabStrip.tsx—[Chat] [Script Builder ●]tab stripfrontend/src/components/pilot/ScriptBuilderTab.tsx— controller (session lifecycle + mode toggle + submit)frontend/src/components/pilot/InlineNoTemplateDialog.tsx— chat-region render wrapper for the existingNoTemplateDialog
Frontend — modified
frontend/src/api/sessionSuggestedFixes.ts—patchScriptmethodfrontend/src/api/scriptBuilder.ts—createSession(language, options?)takes optionalorigin+aiSessionIdfrontend/src/components/pilot/script/TemplateMatchPanel.tsx— new "✓ I ran this" button; calls parent callbackfrontend/src/components/pilot/EscalateInterceptDialog.tsx— fourth "I applied some of it — partial" choicefrontend/src/pages/AssistantChatPage.tsx— tab strip +ScriptBuilderTabmount + banner routing + applyFix call-site moves + inlineNoTemplateDialogrenderfrontend/src/components/pilot/TaskLane.tsx— removeNoTemplateDialogfrombottomSlot
Frontend — unchanged (intentionally)
frontend/src/components/script-builder/ScriptBuilderChat.tsx— presentational, stays purefrontend/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/<hash>_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
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):
"""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 = "<hash-alembic-generated>"
down_revision = "<parent>"
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
cd /config/workspace/resolutionflow/backend && alembic upgrade head
Verify:
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
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
cd /config/workspace/resolutionflow/backend && alembic downgrade -1 && alembic upgrade head
Expected: both clean, no errors.
- Step 5: Commit
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) <noreply@anthropic.com>
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:
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
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
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) <noreply@anthropic.com>
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:
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:
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
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
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) <noreply@anthropic.com>
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:
"""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
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_sessionsandcount_user_sessionsfilter toorigin='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:
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:
# 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:
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
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
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
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) <noreply@anthropic.com>
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:
"""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)
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:
@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
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
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) <noreply@anthropic.com>
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
patchScripttosessionSuggestedFixesApi
In frontend/src/api/sessionSuggestedFixes.ts, after the patchOutcome method (and before clearAIProposal), add:
/**
* 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<string, unknown>,
): Promise<SessionSuggestedFix> {
const r = await apiClient.patch<SessionSuggestedFix>(
`/ai-sessions/${sessionId}/suggested-fixes/${fixId}/script`,
{
ai_drafted_script: aiDraftedScript,
ai_drafted_parameters: aiDraftedParameters,
},
)
return r.data
},
- Step 2: Extend
createSessionwith inline-origin args
Find the script-builder API client. Grep:
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:
export interface CreateSessionOptions {
origin?: 'standalone' | 'pilot_inline'
aiSessionId?: string
}
export async function createSession(
language: string,
options?: CreateSessionOptions,
): Promise<ScriptBuilderSession> {
const r = await apiClient.post<ScriptBuilderSession>(
'/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
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b"
Expected: clean.
- Step 4: Commit
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) <noreply@anthropic.com>
EOF
)"
Task 7: ChatTabStrip component
Files:
-
Create:
frontend/src/components/pilot/ChatTabStrip.tsx -
Step 1: Write the component
/**
* 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 (
<div
role="tablist"
className="flex gap-1 px-4 pt-2 border-b border-default bg-bg-sidebar"
>
<TabButton
label="Chat"
active={active === 'chat'}
onClick={() => onChange('chat')}
/>
<TabButton
label="Script Builder"
active={active === 'script_builder'}
onClick={() => onChange('script_builder')}
indicator={scriptBuilderHasProgress}
/>
</div>
)
}
function TabButton({
label, active, onClick, indicator,
}: {
label: string
active: boolean
onClick: () => void
indicator?: boolean
}) {
return (
<button
role="tab"
aria-selected={active}
onClick={onClick}
className={cn(
'relative px-3 py-[7px] text-[12.5px] font-medium rounded-t-md transition-colors',
'border-b-2 -mb-px',
active
? 'text-heading border-accent bg-bg-page'
: 'text-muted-foreground border-transparent hover:text-primary hover:bg-white/[0.04]',
)}
>
{label}
{indicator && (
<span
className="ml-1.5 inline-block w-1.5 h-1.5 rounded-full bg-warning align-middle"
aria-label="unsaved progress"
/>
)}
</button>
)
}
export default ChatTabStrip
- Step 2: Build
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b"
Expected: clean.
- Step 3: Commit
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) <noreply@anthropic.com>
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 (takesmessages,language,onViewScript,onSaveScript,isLoading).frontend/src/pages/ScriptBuilderPage.tsx— howScriptBuilderChatis wired today; see how messages are sent viasendMessage/ howonSaveScriptis 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
CodeModeEditoris mounted (props, theme, language). -
Step 2: Write the controller
/**
* 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<string | null>(null)
const [mode, setMode] = useState<Mode>('ai')
const [messages, setMessages] = useState<ScriptBuilderMessage[]>([])
const [editorBuffer, setEditorBuffer] = useState<string>(
() => scaffoldForLanguage('powershell', fix.description),
)
const [aiLoading, setAiLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [latestScript, setLatestScript] = useState<string | null>(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 (
<div className="flex flex-col h-full bg-bg-page">
<div className="flex items-center justify-between px-4 py-2 border-b border-default">
<div className="text-[12.5px] text-heading font-medium">
Script Builder · {fix.title}
</div>
<div className="flex items-center gap-2">
{mode === 'ai' ? (
<button
onClick={() => setMode('editor')}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-[11.5px] text-muted-foreground border border-default hover:text-primary hover:border-hover"
>
<Pencil size={11} />
Write it myself
</button>
) : (
<button
onClick={() => setMode('ai')}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-[11.5px] text-muted-foreground border border-default hover:text-primary hover:border-hover"
>
<Sparkles size={11} />
Back to AI
</button>
)}
</div>
</div>
{error && (
<div className="mx-4 mt-2 text-[11.5px] text-danger bg-danger-dim border border-danger/30 rounded px-2 py-1">
{error}
</div>
)}
<div className="flex-1 min-h-0">
<div className={cn('h-full', mode === 'ai' ? 'block' : 'hidden')}>
<ScriptBuilderChat
messages={messages}
language="powershell"
onViewScript={() => { /* optional future: inline preview */ }}
onSaveScript={handleAiSave}
isLoading={aiLoading}
/>
</div>
<div className={cn('h-full', mode === 'editor' ? 'block' : 'hidden')}>
<CodeModeEditor
value={editorBuffer}
onChange={setEditorBuffer}
language="powershell"
readOnly={false}
/>
</div>
</div>
{mode === 'editor' && (
<div className="px-4 py-2 border-t border-default flex justify-end">
<button
onClick={() => handleSubmit(editorBuffer)}
disabled={submitting || !editorBuffer.trim()}
className={cn(
'px-3.5 py-[7px] rounded text-[12.5px] font-semibold',
'bg-accent text-[#0a0d14] hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
{submitting ? 'Saving…' : 'Submit script'}
</button>
</div>
)}
</div>
)
}
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
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
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) <noreply@anthropic.com>
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
/**
* 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<typeof NoTemplateDialog>
export function InlineNoTemplateDialog(props: Props) {
return (
<div className="border-t border-default bg-bg-page animate-slide-up">
<div className="px-5 py-3">
<NoTemplateDialog {...props} />
</div>
</div>
)
}
export default InlineNoTemplateDialog
- Step 3: Build
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b"
Expected: clean.
- Step 4: Commit
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) <noreply@anthropic.com>
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:
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:
{onMarkRun && (
<button
onClick={onMarkRun}
className="inline-flex items-center gap-1.5 px-3 py-[7px] rounded text-[12px] font-semibold bg-accent text-[#0a0d14] hover:brightness-110"
aria-label="Mark the script as applied to start verifying the fix"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
I ran this
</button>
)}
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
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
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) <noreply@anthropic.com>
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:
<button
onClick={() => onChoose('applied_partial')}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-white/10 bg-elevated text-[12.5px] text-primary hover:bg-sidebar transition-colors text-left"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span className="flex-1">I applied some of it — partial</span>
</button>
The InterceptChoice type already includes 'applied_partial' via its FixOutcome | 'never_applied' definition — no type changes needed.
- Step 2: Build
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b"
Expected: clean.
- Step 3: Commit
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) <noreply@anthropic.com>
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:
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:
const [chatTab, setChatTab] = useState<ChatTab>('chat')
const [scriptBuilderHasProgress, setScriptBuilderHasProgress] = useState(false)
Update resetSessionDerivedState:
setChatTab('chat')
setScriptBuilderHasProgress(false)
- Step 2: Derive
showTabStripfrom fix state
Near the other derived values (probably close to bannerMode):
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):
{showTabStrip && (
<ChatTabStrip
active={chatTab}
onChange={setChatTab}
scriptBuilderHasProgress={scriptBuilderHasProgress}
/>
)}
<div className={cn('flex-1 min-h-0', chatTab === 'chat' ? 'block' : 'hidden')}>
{/* existing chat messages + composer JSX remains here unchanged */}
</div>
{showTabStrip && activeFix && activeChatId && (
<div className={cn('flex-1 min-h-0', chatTab === 'script_builder' ? 'block' : 'hidden')}>
<ScriptBuilderTab
fix={activeFix}
pilotSessionId={activeChatId}
onProgressChange={setScriptBuilderHasProgress}
onScriptDrafted={(updated) => {
setActiveFix(updated)
setChatTab('chat')
setScriptBuilderHasProgress(false)
}}
/>
</div>
)}
- Step 4: Banner Apply routing — route no-draft case to the Script Builder tab
Find handleApplyFix. Update the routing:
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
InlineNoTemplateDialogin 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:
{/* In the chat region, just above the composer */}
{scriptPanelOpen && activeFix && activeChatId && !activeFix.script_template_id && activeFix.ai_drafted_script && (
<InlineNoTemplateDialog
fix={activeFix}
onClose={() => 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
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
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) <noreply@anthropic.com>
EOF
)"
Task 13: applyFix call-site relocation — stamp only on run-declaring actions
Files:
-
Modify:
frontend/src/pages/AssistantChatPage.tsx -
Step 1: Remove the
applyFixcall fromhandleApplyFix
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
applyFixtohandleScriptDecisionforone_offanddraft_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:
const handleScriptDecision = useCallback(
async (decision: UserDecision, options?: { editedScript?: string; parametersUsed?: Record<string, unknown> }) => {
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
onMarkRunonTemplateMatchPanel
Find the TemplateMatchPanel render site. Add the onMarkRun prop:
<TemplateMatchPanel
fix={activeFix}
sessionId={activeChatId}
onClose={() => 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
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b && npm run build"
Manual browser QA:
- Fix with
script_template_id→ Apply →TemplateMatchPanelopens, banner stays on Proposed state. Click "I ran this" → banner flips to Verifying. - Fix with
ai_drafted_script→ Apply →InlineNoTemplateDialogopens, banner stays on Proposed. Clickone_offcard → banner flips to Verifying. (Repeat withdraft_template— same result.) Clickbuild_template→ navigates; banner stays on Proposed. - Fix with neither → Apply → Script Builder tab opens, banner stays on Proposed. Draft + submit → tab disappears. Engineer clicks Apply again →
InlineNoTemplateDialogopens. Pickone_off→ banner flips to Verifying.
- Step 5: Commit
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) <noreply@anthropic.com>
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
bottomSlotis still needed
Grep:
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_commitbump 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:
/ultrareviewstill 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
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
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) <noreply@anthropic.com>
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: <hash-alembic-generated> 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?