feat(ai-session): add Phase 2 PSA integration, escalation handoff, and session management

Phase 2 of the FlowPilot-First Pivot connecting AI sessions to ConnectWise PSA:

Slice 1 — PSA Ticket Intake:
- FlowPilotEngine accepts psa_ticket intake with graceful CW API fallback
- Ticket picker on intake screen (refactored TicketPickerModal for dual-mode)
- Ticket context card in session sidebar

Slice 2 — Auto Documentation Push:
- PSA documentation service with resolution/escalation note formatting
- Time entry creation via new ConnectWise provider method
- Automatic retry scheduler (APScheduler, 5min interval, 3 retries)
- PSA push status indicators in frontend with manual retry button
- Member mapping warning when CW member not mapped

Slice 3 — Session Pause/Resume & Escalation Handoff:
- Pause/resume endpoints for same-engineer session bookmarking
- Escalation flow: requesting_escalation status, self-escalation blocked
- Enhanced escalation package with LLM-generated hypotheses/suggestions
- Pickup endpoint with continue/fresh resume modes and briefing step
- Escalation queue (sidebar nav + dedicated page)
- SessionBriefing component with continue/fresh choice UI
- EscalateModal with PSA-aware button text

Slice 4 — Mid-Session Ticket Linking:
- Link ticket retroactively with context injection into system prompt
- Link Ticket button in session sidebar

Slice 5 — FlowPilot PSA Settings:
- Settings tab on IntegrationsPage with 7 configurable options
- Stored as flowpilot_settings JSONB on PsaConnection

Database: 2 migrations (flowpilot_settings, psa_post_log changes, status constraint)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 01:30:05 +00:00
parent 2063a799b0
commit bbe590bfec
37 changed files with 3698 additions and 121 deletions

View File

@@ -0,0 +1,885 @@
# FlowPilot-First Pivot — Phase 2: PSA Integration & Escalation Handoff
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Connect FlowPilot to ConnectWise PSA so engineers can start sessions from tickets, and documentation flows back automatically on resolution or escalation. Also implement escalation handoffs with full context briefing, session pause/resume for individual engineers, and in-app escalation notifications.
**Architecture:** Builds on existing PSA infrastructure (`services/psa/`, `PsaConnection` model, ConnectWise client) and Phase 1 AI session models (`AISession`, `AISessionStep`, `FlowPilotEngine`). Adds PSA ticket intake to sessions, auto-documentation push on close, session pause/resume, and escalation handoff mechanics.
**Tech Stack:** FastAPI, SQLAlchemy 2.0 (async), httpx (ConnectWise API), React, TypeScript, Tailwind CSS v4, shadcn/ui
**Prerequisites:**
- Phase 1 complete (AI session core — models, engine, API, frontend)
- Existing PSA integration (`docs/plans/2026-03-14-connectwise-psa-integration-plan.md`)
- Existing models: `PsaConnection`, `PsaMemberMapping`, `PsaPostLog`
- Existing services: `services/psa/base.py`, `services/psa/connectwise/client.py`, `services/psa/connectwise/provider.py`
- Existing service: `services/psa/ticket_context.py` — has `format_ticket_context_for_prompt()` already
- Existing schemas: `schemas/psa_context.py` — has `TicketContext`, `TicketDetails`, `CompanyInfo`, `ConfigItem`, `TicketNote`, etc.
- Existing frontend: `TicketPickerModal.tsx`, `TicketContextPanel.tsx`, `IntegrationsPage.tsx`
- Existing service: `services/redaction_service.py` — has `apply_redaction_to_text()` for password redaction
**Existing patterns to follow:**
- PSA: `app/services/psa/` — abstract `PSAProvider` interface + ConnectWise implementation
- PSA context: `app/services/psa/connectwise/provider.py``get_ticket_context()` already fetches ticket + company + contact + configs + notes + related tickets in parallel with caching
- PSA prompt formatting: `app/services/psa/ticket_context.py``format_ticket_context_for_prompt()` already formats `TicketContext` into structured text for AI prompts
- Sessions: `app/api/endpoints/sessions.py` — existing ticket linking patterns
- Phase 1: `app/services/flowpilot_engine.py`, `app/api/endpoints/ai_sessions.py`
- Frontend API pattern: `src/api/aiSessions.ts` uses `aiSessionsApi` object pattern (not standalone exports)
- Frontend ticket UI: `src/components/session/TicketPickerModal.tsx` (note: currently takes `sessionId` prop for old sessions — needs adapter)
---
## Key Design Decisions (from product review)
These decisions were confirmed during product review before implementation:
1. **PSA connection scope:** Per-account (one CW connection per MSP). Individual engineers mapped to CW members via `PsaMemberMapping`.
2. **CW API failure at intake:** Graceful degradation — engineer can manually type ticket number and paste ticket notes. Session starts without rich context. Ticket can be linked later to pull in contact/company details.
3. **Missing CW member mapping for time entries:** Show warning "Map your CW account in Settings to enable auto-logged time entries." Always include start time, end time, and total duration in the note text regardless.
4. **PSA push failure retry:** Automatic background retries via APScheduler (up to 3 attempts, exponential backoff). Plus a manual "Retry" button that only appears when auto-retries are exhausted or push is in failed state.
5. **Session ownership on escalation:** Engineer A **keeps ownership** (`session.user_id` unchanged). Session goes to `requesting_escalation` status. Engineer B works within the same session but A remains the originator. Both see it in their history.
6. **Escalation vs Pause:** Two separate features:
- **Pause/Resume** — same engineer, bookmark for later or recover from browser crash. Status: `paused`.
- **Escalation** — handoff to another engineer with context briefing. Status: `requesting_escalation``escalated` (when picked up). Self-escalation blocked.
7. **Escalation queue location:** Both — sidebar nav item "Escalations" with badge count AND a tab in session history page.
8. **Escalation pickup UX:** Engineer B sees a briefing card summarizing A's work, then chooses: (a) "Continue where they left off" (picks up same conversation), or (b) "Start fresh with context" (types their own input, but FlowPilot knows everything A tried so it won't repeat steps).
9. **Mid-session ticket linking:** Inject ticket context into system prompt immediately. FlowPilot naturally acknowledges the new context in its next response ("Thanks for linking that ticket. I can see this is for [client]...").
10. **Ticket status on resolve:** Contextual dropdown pulled dynamically from the linked ticket's board statuses (not a global setting). Admin setting just controls whether engineers are prompted to pick a status.
11. **Tests:** Mocked CW responses based on OpenAPI spec. No sandbox available yet.
---
## Context: What Phase 2 Adds
Phase 1 delivered FlowPilot with free-text intake only. Phase 2 makes it a ticket workflow tool:
**PSA Ticket Intake:** Engineer selects a ConnectWise ticket → FlowPilot pulls ticket data (summary, client, priority, history, configuration items) and uses it as rich context for diagnosis. If CW is unavailable, engineer can manually enter ticket number and paste notes — graceful degradation, not a hard block.
**Auto Documentation Push:** On resolution or escalation, FlowPilot auto-generates documentation and pushes it back to the ConnectWise ticket as internal notes + time entry. Automatic background retries on failure, with manual retry fallback. Notes always include session timing (start, end, duration) even if time entry creation fails due to missing member mapping.
**Session Pause/Resume:** Engineer can pause a session and come back later, or recover seamlessly from browser crashes and page reloads. Same engineer, same session.
**Escalation Handoff:** Engineer A hits a wall → clicks Escalate → FlowPilot packages everything tried so far → session goes to "requesting escalation" status → Engineer B sees it in the escalation queue (sidebar + session history tab) → picks it up with a briefing card → chooses to continue where A left off or start fresh with full context. Engineer A retains session ownership throughout. Self-escalation is blocked.
---
## Slice 1: PSA Ticket Intake for AI Sessions
### Task 1: Extend FlowPilotEngine to accept PSA ticket intake
**Files:**
- Edit: `backend/app/services/flowpilot_engine.py`
**What to add:**
Add a new method `_process_ticket_intake()` that:
1. Receives `psa_connection_id` and `psa_ticket_id` from the intake request
2. Loads the `PsaConnection` from the database
3. **Attempts** to use `ConnectWiseProvider.get_ticket_context()` — if this fails (API down, bad credentials), catch the error and fall back gracefully (session starts with just the ticket ID stored, no rich context)
4. On success: stores the full ticket context (serialized `TicketContext`) in `session.ticket_data` JSONB
5. Uses the existing `format_ticket_context_for_prompt()` from `services/psa/ticket_context.py` to build the system prompt context block — do NOT rewrite this formatting, it already handles all fields correctly
6. Builds enriched intake content that includes both the formatted ticket context and any additional free-text the engineer provided
7. Passes the enriched context to `_classify_intake()` and the system prompt
**Graceful degradation on CW failure:**
If `get_ticket_context()` fails, the session still starts:
- `session.psa_ticket_id` is set (so we know which ticket to push docs to later)
- `session.psa_connection_id` is set
- `session.ticket_data` is null or minimal (just the ticket ID)
- The engineer's free-text intake (which may include pasted ticket notes) is used as the sole context
- A warning is returned in the response: `"psa_context_status": "unavailable"` so the frontend can show "Couldn't pull ticket details — ConnectWise may be unavailable"
**IMPORTANT — Reuse existing infrastructure:**
The ConnectWise provider at `services/psa/connectwise/provider.py` already has `get_ticket_context()` which returns a `TicketContext` schema (defined in `schemas/psa_context.py`). And `services/psa/ticket_context.py` already has `format_ticket_context_for_prompt()` that converts a `TicketContext` into a structured text block for AI prompts. Both of these are battle-tested from the existing session/copilot system. Phase 2 should call them directly:
```python
from app.services.psa.ticket_context import format_ticket_context_for_prompt
from app.services.psa.registry import get_provider_for_connection
# In _process_ticket_intake():
try:
provider = await get_provider_for_connection(psa_connection_id, db)
ticket_context = await provider.get_ticket_context(int(psa_ticket_id), str(psa_connection_id))
ticket_prompt_block = format_ticket_context_for_prompt(ticket_context)
session.ticket_data = ticket_context.model_dump(mode="json")
psa_context_status = "loaded"
except Exception as e:
logger.warning(f"Failed to fetch ticket context: {e}")
ticket_prompt_block = None
psa_context_status = "unavailable"
```
**Modify `start_session()`** to detect `intake_type == 'psa_ticket'` and call `_process_ticket_intake()` before the normal flow. The ticket context gets injected into the system prompt alongside any matched flow context.
**Key detail:** The engineer may also type additional context alongside the ticket pull (e.g., "Ticket #12345 — user called back and said it's also affecting their second monitor"). The intake content should merge both sources.
**Verification:** Start a session with `intake_type: "psa_ticket"` and a valid ticket ID. Verify FlowPilot's first question references the ticket content. Check `session.ticket_data` is populated. Also test with a bad connection — verify session still starts with a warning.
```
git commit -m "feat(ai-session): add PSA ticket intake to FlowPilot Engine"
```
### Task 2: Add ticket picker to FlowPilot intake screen
**Files:**
- Edit: `frontend/src/components/flowpilot/FlowPilotIntake.tsx`
**What to add:**
The "Pull from Ticket" button (currently disabled from Phase 1) becomes active when the user's account has a PSA connection configured.
**IMPORTANT — TicketPickerModal adaptation:** The existing `TicketPickerModal` at `src/components/session/TicketPickerModal.tsx` was built for legacy sessions — it requires a `sessionId` prop and calls `sessionPsaApi.linkTicket()` internally. For the FlowPilot intake screen, you need to either:
- (a) Create a new `FlowPilotTicketPicker` component that reuses the search/display logic but returns the selected ticket data to the parent instead of calling the link API, or
- (b) Refactor `TicketPickerModal` to accept an `onSelect` callback prop as an alternative to `sessionId`, making it usable in both contexts
Option (b) is preferred since it avoids code duplication. Add an `onSelect?: (ticketId: string, ticket: PSATicketInfo) => void` prop. When provided, the modal calls `onSelect` instead of the internal link API. The existing legacy usage passes `sessionId` + `onLinked` as before (no breaking change).
On click, open the adapted `TicketPickerModal`. When a ticket is selected:
1. The ticket summary populates the intake area as a styled ticket card (showing ticket #, summary, client name, priority badge)
2. An additional textarea appears below for "Add context" — optional free text the engineer can add
3. The intake type switches to `psa_ticket` (or `combined` if they also add text)
4. On submit, `createAISession()` is called with `intake_type: "psa_ticket"`, `psa_ticket_id`, `psa_connection_id`, and `intake_content` containing both the ticket reference and any additional text
**Manual ticket entry fallback:** If the ticket picker fails to connect to CW, or the engineer prefers, they can also manually type a ticket number and paste relevant notes into the free-text area. This still sets `intake_type: "psa_ticket"` with the ticket number, but `psa_connection_id` triggers a context fetch attempt on the backend (which may gracefully fail per Task 1).
**UX details:**
- Check for active PSA connections via existing `useTicketContext` hook or the integrations API
- If no PSA connection exists, the "Pull from Ticket" button shows a tooltip: "Connect your PSA in Settings → Integrations"
- The ticket card should match the existing `TicketContextPanel` styling — dark glass card with cyan accent border, ticket number prominent
- After ticket selection, "Start Session" button text changes to "Start Session with Ticket #12345"
- If CW fetch fails, show toast: "Couldn't reach ConnectWise — you can still type the ticket details manually"
**Verification:** Open the FlowPilot intake. Click "Pull from Ticket". Search for a ticket in ConnectWise. Select it. See the ticket card appear. Add optional context. Submit. Verify the session starts with ticket data.
```
git commit -m "feat(ai-session): add PSA ticket picker to FlowPilot intake"
```
### Task 3: Display ticket context in active session sidebar
**Files:**
- Edit: `frontend/src/components/flowpilot/FlowPilotSession.tsx`
- Create: `frontend/src/components/flowpilot/SessionTicketCard.tsx`
**What to add:**
When the active session has `psa_ticket_id` set, show a `SessionTicketCard` in the right sidebar above the confidence indicator. This card shows:
- Ticket # (clickable — opens ConnectWise ticket in new tab if URL is available)
- Ticket summary
- Client name
- Priority badge (color-coded)
- Status badge
- Config items list (if any)
If `ticket_data` is minimal (CW was unavailable at intake), show a simplified card with just the ticket number and a "Refresh from CW" button that attempts to pull context again.
Reuse styling patterns from the existing `TicketContextPanel` and `TicketLinkIndicator` components.
**Verification:** Start a ticket-based session. See the ticket card in the sidebar with all relevant info.
```
git commit -m "feat(ai-session): display ticket context in FlowPilot session sidebar"
```
---
## Slice 2: Auto Documentation Push to PSA
### Task 4: Build PSA documentation push service
**Files:**
- Create: `backend/app/services/psa_documentation_service.py`
**Architecture:**
This service takes a completed `AISession` and pushes structured documentation back to ConnectWise. It handles three operations:
1. **Internal Note:** Full diagnostic trail posted as an internal note on the ticket
2. **Time Entry:** Auto-create a time entry with the session duration (if CW member mapping exists)
3. **Status Update:** Optionally update ticket status based on contextual selection at resolution time
**Internal note format:**
```
═══ FlowPilot Session Documentation ═══
Session: {session_id}
Engineer: {user.display_name}
Date: {resolved_at}
Started: {created_at}
Ended: {resolved_at}
Duration: {duration_display}
── Problem ──
{problem_summary}
Domain: {problem_domain}
── Diagnosis Path ──
1. [Question] {context_message}
→ Response: {selected_option or free_text_input}
2. [Action] {content description}
→ Result: {action_result summary}
3. [Question] {context_message}
→ Response: {selected_option}
... (all steps)
── Resolution ──
{resolution_summary}
{resolution_action}
── AI Confidence ──
Final confidence: {confidence_tier} ({confidence_score})
Matched flow: {matched_flow_name or "None - new discovery"}
── Session Timing ──
Start: {created_at formatted}
End: {resolved_at formatted}
Total: {duration_display}
Generated by ResolutionFlow FlowPilot
```
**IMPORTANT — Always include timing:** The "Session Timing" section is always present in the note, even when a time entry can't be created (missing member mapping). This ensures the time data is always on the ticket for manual entry.
**For escalations, the format changes:**
```
═══ FlowPilot Escalation Documentation ═══
Session: {session_id}
Escalated by: {user.display_name}
Escalated to: {escalated_to.display_name or "Unassigned"}
Date: {resolved_at}
Started: {created_at}
Duration: {duration_display}
── Problem ──
{problem_summary}
── Work Completed ──
{numbered list of all steps taken}
── Escalation Reason ──
{escalation_reason}
── Remaining Hypotheses ──
{from escalation_package.hypotheses}
── Suggested Next Steps ──
{from escalation_package.suggestions}
── Session Timing ──
Start: {created_at formatted}
Escalated: {escalated_at formatted}
Total: {duration_display}
Generated by ResolutionFlow FlowPilot
```
**Key implementation details:**
- Use the existing PSA provider abstraction (`services/psa/base.py``post_note()`)
- **PsaPostLog FK issue:** The existing `PsaPostLog` model has `ForeignKey("sessions.id")` pointing to old sessions, NOT `ai_sessions`. You must add an `ai_session_id` nullable UUID FK column to `PsaPostLog` (via migration) so it can reference AI sessions. Keep the original `session_id` column for backward compatibility — make it nullable if it isn't already.
- **Time entry method missing:** The PSA base class and ConnectWise provider do NOT currently have a `create_time_entry()` method. You must add: (1) `async def create_time_entry(ticket_id, member_id, hours, notes, work_type)` to `services/psa/base.py` as an abstract method, (2) implement it in `services/psa/connectwise/provider.py` using the CW `POST /time/entries` endpoint, (3) add a `PSATimeEntry` type to `services/psa/types.py`
- **Missing member mapping handling:** Before creating a time entry, look up the engineer's CW member ID via `PsaMemberMapping`. If no mapping exists: skip the time entry, include a `member_mapping_warning` in the response ("Map your CW account in Settings → Integrations to enable auto-logged time entries"). The note text always includes timing regardless.
- Use `apply_redaction_to_text()` from `services/redaction_service.py` to scrub passwords and sensitive data before pushing to ConnectWise
- Time entry calculation: `session.resolved_at - session.created_at`, rounded to nearest 15 minutes (configurable via `flowpilot_settings`)
- **Automatic retry on failure:** If the PSA push fails, create a `PsaPostLog` entry with `status='pending_retry'`. APScheduler job runs every 5 minutes, retries failed pushes up to 3 times with exponential backoff (5min, 15min, 45min). After 3 failures, status becomes `failed` and the frontend shows a manual "Retry" button.
- The documentation text should be plain text (ConnectWise notes don't support markdown well)
**Verification:** Resolve an AI session that has a linked ticket. Check ConnectWise — verify the internal note appeared on the ticket with the full diagnostic trail including timing. Verify a time entry was created (if member mapped). Check `psa_post_logs` table for the audit record.
```
git commit -m "feat(ai-session): add PSA documentation push service"
```
### Task 5: Wire documentation push into session resolution/escalation + background retry
**Files:**
- Edit: `backend/app/services/flowpilot_engine.py`
- Edit: `backend/app/api/endpoints/ai_sessions.py`
- Create: `backend/app/services/psa_retry_scheduler.py`
**What to add:**
In the `resolve_session()` and `escalate_session()` methods, after generating documentation, check if the session has a `psa_ticket_id` and `psa_connection_id`. If so, call `psa_documentation_service.push_documentation()`.
**Flow:**
1. Engineer clicks Resolve → `POST /ai-sessions/{id}/resolve`
2. `flowpilot_engine.resolve_session()` generates documentation (existing)
3. **New:** If session has PSA link, call `psa_documentation_service.push_documentation(session, documentation)`
4. Push runs async — don't block the response
5. Return `SessionCloseResponse` with new fields: `psa_push_status`, `member_mapping_warning`
Same flow for escalation.
**Background retry scheduler (`psa_retry_scheduler.py`):**
APScheduler job that runs every 5 minutes:
1. Query `PsaPostLog` for entries with `status='pending_retry'` and `retry_count < 3`
2. For each, attempt the push again via `psa_documentation_service`
3. On success: update `status='sent'`
4. On failure: increment `retry_count`, set next retry with exponential backoff
5. After 3 failures: set `status='failed'`
Register the scheduler in the FastAPI lifespan (follows existing APScheduler pattern for maintenance flows).
**Add to response schemas:**
Edit `backend/app/schemas/ai_session.py` — add PSA fields to `SessionCloseResponse`:
```python
class SessionCloseResponse(BaseModel):
session_id: UUID
status: str
documentation: SessionDocumentation
psa_push_status: str = "no_psa" # sent | pending_retry | no_psa | failed
psa_push_error: str | None = None
member_mapping_warning: str | None = None # Set when time entry skipped due to missing mapping
```
**Add manual retry endpoint:**
```
POST /api/v1/ai-sessions/{id}/retry-psa-push
```
Only callable when the session's latest `PsaPostLog` entry has `status='failed'`. Resets to `pending_retry` and triggers an immediate push attempt.
**Verification:** Resolve a ticket-linked session. Verify the response includes `psa_push_status: "sent"`. Check ConnectWise for the note. Resolve a session without a ticket — verify `psa_push_status: "no_psa"`. Test with a user who has no CW member mapping — verify `member_mapping_warning` is present and note still includes timing.
```
git commit -m "feat(ai-session): wire PSA documentation push into resolve/escalate with auto-retry"
```
### Task 6: Show PSA push status in frontend
**Files:**
- Edit: `frontend/src/components/flowpilot/SessionDocView.tsx`
- Edit: `frontend/src/types/ai-session.ts`
**What to add:**
After resolution/escalation, the documentation view now shows a PSA sync indicator:
- **"sent":** Green checkmark + "Documentation pushed to ticket #{ticket_id}"
- **"pending_retry":** Amber clock icon + "Documentation queued for push — will sync shortly"
- **"failed":** Red warning + "Failed to push to ticket — {error}" with a "Retry" button that calls `POST /ai-sessions/{id}/retry-psa-push`
- **"no_psa":** No indicator shown (session wasn't linked to a ticket)
If `member_mapping_warning` is present, show an info banner: "Time entry was not created — [Map your CW account](link to settings) to enable auto-logged time. Session timing is included in the ticket note."
Update the TypeScript types to include `psa_push_status`, `psa_push_error`, and `member_mapping_warning` on `SessionCloseResponse`.
**Verification:** Resolve a ticket-linked session. See "Documentation pushed to ticket #12345" in the documentation view. Test retry button with a simulated failure.
```
git commit -m "feat(ai-session): show PSA push status in documentation view"
```
---
## Slice 3: Session Pause/Resume & Escalation Handoff
### Task 7: Session pause/resume for same engineer
**Files:**
- Edit: `backend/app/services/flowpilot_engine.py`
- Edit: `backend/app/api/endpoints/ai_sessions.py`
- Edit: `backend/app/schemas/ai_session.py`
**What to add:**
Engineers need to pause a session and come back later (lunch break, waiting for info, browser crash recovery).
**New endpoint — Pause session:**
```
POST /api/v1/ai-sessions/{id}/pause
```
Flow:
1. Verify session is `active` and belongs to current user
2. Set `session.status = "paused"`, `session.paused_at = utcnow()`
3. Return updated session
**New endpoint — Resume own paused session:**
```
POST /api/v1/ai-sessions/{id}/resume
```
Flow:
1. Verify session is `paused` and belongs to current user
2. Set `session.status = "active"`, clear `paused_at`
3. Return the session with all existing steps (engineer picks up exactly where they left off)
4. No briefing step needed — it's the same engineer
**Browser crash recovery:**
Sessions in `active` status should be resumable by navigating back to `/pilot/{sessionId}`. The frontend should detect an existing active session and restore it (conversation history is already in `conversation_messages` JSONB). This is mostly a frontend concern — the backend already stores all state.
**Verification:** Start a session, progress 3 steps. Pause it. Navigate away. Come back. Resume. Verify you're back at step 3 with full context. Also test: close the browser tab while in an active session, reopen, navigate to session — verify it loads correctly.
```
git commit -m "feat(ai-session): add session pause/resume for same engineer"
```
### Task 8: Build escalation handoff backend
**Files:**
- Edit: `backend/app/services/flowpilot_engine.py`
- Edit: `backend/app/api/endpoints/ai_sessions.py`
- Edit: `backend/app/schemas/ai_session.py`
**Session status lifecycle (updated from Phase 1):**
```
active → paused (same engineer pause)
paused → active (same engineer resume)
active → requesting_escalation (engineer requests escalation)
requesting_escalation → active (another engineer picks it up)
active → resolved (session completed)
active → escalated (escalation completed — terminal, session was handed off and resolved by another engineer)
requesting_escalation → escalated (escalation expired or cancelled — terminal)
```
**Key ownership rule:** `session.user_id` ALWAYS stays as Engineer A (the originator). When Engineer B picks up the session, we track them via a new `current_handler_id` field (or in the `escalation_package` JSONB). Both engineers see the session in their history — A sees "I escalated this" and B sees "I picked this up."
**Modify the existing `escalate_session()` in `flowpilot_engine.py`:**
1. Change `session.status = "escalated"``session.status = "requesting_escalation"`
2. Do NOT set `session.resolved_at` yet (session isn't done — it's waiting for pickup)
3. Store `session.escalation_package["original_user_id"] = str(user_id)`
4. **Block self-escalation:** If `escalated_to_id == current_user.id`, return 400 error
**Enhance `_build_escalation_package()`:**
The existing Phase 1 implementation builds a basic package with `problem_summary`, `steps_tried`, and `escalation_reason`. Enhance it to also include:
- `remaining_hypotheses`: Make a quick LLM call (haiku-tier via `AI_MODEL_TIERS["fast"]`) asking: "Based on this diagnostic conversation, what are the most likely remaining causes that haven't been ruled out?" Pass the conversation_messages as context.
- `suggested_next_steps`: From the same LLM call: "What should the next engineer try first?"
- `steps_ruled_out`: Walk the steps and identify options that were tested and failed
- `environment_context`: Extract any environment-specific info mentioned during the session (server names, IP addresses, software versions, etc.)
- `original_user_id`: The engineer who escalated (for attribution in the briefing)
This LLM call should use the fast model since it's a summarization task, not a diagnostic one. If the call fails, fall back to the basic package without hypotheses/suggestions — don't block the escalation.
**New endpoint — Pick up escalated session:**
```
POST /api/v1/ai-sessions/{id}/pickup
```
Request body:
```python
class PickupSessionRequest(BaseModel):
"""Pick up an escalated session as a new engineer."""
resume_mode: str = "continue" # "continue" or "fresh"
additional_context: str | None = None # New info or question from the receiving engineer
```
**Pickup flow:**
1. Verify session status is `requesting_escalation`
2. Verify the current user has permission (same team) and is NOT the original engineer
3. Track the new handler (add to `escalation_package["picked_up_by"] = str(user_id)`, `escalation_package["picked_up_at"] = utcnow()`)
4. Set `session.status = "active"`
5. Generate a "briefing step" — a special step that summarizes everything for the new engineer:
- "Here's what {original_engineer} found so far: ..."
- "They ruled out X, Y, Z"
- "Remaining hypotheses: A, B"
- "Suggested next steps: ..."
6. Based on `resume_mode`:
- `"continue"`: Generate the next diagnostic step as usual (picks up where A left off)
- `"fresh"`: Use `additional_context` as new input, but FlowPilot's system prompt includes all of A's work so it won't repeat steps
7. Return the briefing step + next step
**New endpoint — List sessions requesting escalation for team:**
```
GET /api/v1/ai-sessions/escalation-queue
```
Returns sessions with `status = "requesting_escalation"` for the current user's team, sorted by most recent. This is the "pickup queue" for escalated tickets. Includes: problem summary, escalation reason, who escalated, when, ticket # (if linked), step count, assigned-to (if specified).
**Verification:** Start a session, progress 3-4 steps, escalate with a reason. Verify session is `requesting_escalation`. Log in as another user on the same team. Hit `/ai-sessions/escalation-queue`. See the session. Pick it up with `resume_mode: "continue"`. Verify the briefing step accurately summarizes prior work. Continue diagnosis. Also test `resume_mode: "fresh"` with additional context.
```
git commit -m "feat(ai-session): add escalation handoff backend with pickup flow"
```
### Task 9: Escalation handoff frontend + in-app notifications
**Files:**
- Create: `frontend/src/components/flowpilot/EscalateModal.tsx`
- Create: `frontend/src/components/flowpilot/EscalationQueue.tsx`
- Create: `frontend/src/components/flowpilot/SessionBriefing.tsx`
- Edit: `frontend/src/components/flowpilot/FlowPilotActionBar.tsx`
- Edit: `frontend/src/components/flowpilot/FlowPilotSession.tsx`
- Edit: `frontend/src/hooks/useFlowPilotSession.ts`
- Edit: `frontend/src/api/aiSessions.ts`
- Edit: `frontend/src/types/ai-session.ts`
- Edit: `frontend/src/components/layout/Sidebar.tsx` (or equivalent nav component)
- Edit: `frontend/src/router.tsx`
**EscalateModal:**
When the engineer clicks "Escalate" in the action bar, this modal opens:
- Textarea: "Why are you escalating?" (required)
- Dropdown: "Assign to" — list of team members (optional, defaults to unassigned)
- Summary card: auto-generated preview of the escalation package (steps taken, hypotheses remaining)
- "Escalate & Update Ticket" button (if PSA linked) / "Escalate" button (if not)
- **Self-escalation blocked:** Current user excluded from the "Assign to" dropdown
**EscalationQueue:**
New component accessible from **both** the sidebar nav and as a tab in session history.
**Sidebar nav item:** "Escalations" with a badge showing count of sessions in `requesting_escalation` status for the user's team. Badge uses amber-400 color. Positioned below "Sessions" in the nav.
**Session history tab:** New tab "Escalated" alongside existing tabs. Shows the same queue content.
Queue content:
- Card for each session in `requesting_escalation`: problem summary, escalation reason, who escalated, when, ticket # (if linked), step count
- "Pick Up" button on each card
- Sort by most recent
- Filter by: assigned to me, unassigned, all
**SessionBriefing:**
When an engineer picks up an escalated session, the first thing they see is a styled briefing card (distinct from normal step cards — use an amber/purple accent border to distinguish from regular cyan steps):
- "Escalation from {original_engineer}"
- Problem summary
- Steps already taken (collapsed list, expandable)
- What was ruled out
- Remaining hypotheses
- Suggested next steps
- Two action buttons:
- **"Continue Where They Left Off"** → calls pickup with `resume_mode: "continue"`, proceeds to FlowPilot's next question
- **"Start Fresh With Context"** → shows a textarea for the engineer to type their own input/question, then calls pickup with `resume_mode: "fresh"` and `additional_context`
**Pause/Resume UI (from Task 7):**
- Add "Pause" button to `FlowPilotActionBar` (alongside Resolve and Escalate)
- Paused sessions show in session history with a "Paused" badge
- Clicking a paused session resumes it automatically (or shows a "Resume" button)
- On page load, if navigating to `/pilot/{sessionId}` and session is `active`, restore the full conversation (browser crash recovery)
**API client additions:**
Add to the existing `aiSessionsApi` object in `src/api/aiSessions.ts` (follow the same pattern as existing methods):
```typescript
// Add to aiSessionsApi object:
async pauseSession(sessionId: string): Promise<AISessionDetail> {
const response = await apiClient.post<AISessionDetail>(
`/ai-sessions/${sessionId}/pause`
)
return response.data
},
async resumeSession(sessionId: string): Promise<AISessionDetail> {
const response = await apiClient.post<AISessionDetail>(
`/ai-sessions/${sessionId}/resume`
)
return response.data
},
async pickupSession(sessionId: string, data: { resume_mode: string; additional_context?: string }): Promise<StepResponseResponse> {
const response = await apiClient.post<StepResponseResponse>(
`/ai-sessions/${sessionId}/pickup`,
data
)
return response.data
},
async getEscalationQueue(): Promise<AISessionSummary[]> {
const response = await apiClient.get<AISessionSummary[]>('/ai-sessions/escalation-queue')
return response.data
},
async linkTicket(sessionId: string, data: { psa_ticket_id: string; psa_connection_id: string }): Promise<AISessionDetail> {
const response = await apiClient.post<AISessionDetail>(
`/ai-sessions/${sessionId}/link-ticket`,
data
)
return response.data
},
async retryPsaPush(sessionId: string): Promise<{ psa_push_status: string }> {
const response = await apiClient.post<{ psa_push_status: string }>(
`/ai-sessions/${sessionId}/retry-psa-push`
)
return response.data
},
```
**Hook updates:**
Add `pauseSession`, `resumeSession`, `pickupSession`, `escalationQueue`, `linkTicket`, `retryPsaPush` to `useFlowPilotSession`.
**Router updates:**
- Add route for the escalation queue page (e.g., `/escalations`)
- Ensure `/pilot/{sessionId}` handles all session states (active, paused, requesting_escalation)
**Verification:** Full escalation flow — Engineer A starts session, progresses, escalates with reason. Engineer B sees it in the sidebar queue (badge count), picks it up via "Continue Where They Left Off", sees the briefing, continues diagnosis, resolves. Also test: Engineer B picks up via "Start Fresh With Context" with their own input. Also test pause/resume for same engineer.
```
git commit -m "feat(ai-session): add escalation handoff and pause/resume frontend"
```
---
## Slice 4: Session-to-Ticket Linking for Existing Sessions
### Task 10: Link an in-progress session to a ticket retroactively
**Files:**
- Edit: `backend/app/api/endpoints/ai_sessions.py`
- Edit: `backend/app/services/flowpilot_engine.py`
- Edit: `frontend/src/components/flowpilot/FlowPilotSession.tsx`
**What to add:**
Sometimes an engineer starts a session with free-text intake and then realizes "oh, this is ticket #12345." They should be able to link a ticket mid-session.
**New endpoint:**
```
POST /api/v1/ai-sessions/{id}/link-ticket
```
Request:
```python
class LinkTicketRequest(BaseModel):
psa_ticket_id: str
psa_connection_id: UUID
```
**Flow:**
1. Fetch ticket data from ConnectWise (graceful failure — if CW is down, still store the ticket ID for later doc push)
2. Update `session.psa_ticket_id`, `session.psa_connection_id`, `session.ticket_data`
3. **Inject ticket context into FlowPilot's system prompt** for subsequent steps — append the formatted ticket context to `session.conversation_messages` system prompt. FlowPilot will naturally acknowledge the new context in its next response.
4. Return updated session data
**Frontend:**
Add a "Link Ticket" button in the session sidebar (where the ticket card would be, if there isn't one). Opens the adapted `TicketPickerModal` (with `onSelect` prop from Task 2). On selection, calls `linkTicket()` and the `SessionTicketCard` appears in the sidebar.
**Verification:** Start a free-text session. Progress a few steps. Click "Link Ticket". Select a ticket. Verify ticket card appears in sidebar. Continue diagnosis — verify FlowPilot's next response acknowledges the ticket context. Resolve. Verify documentation pushes to the linked ticket.
```
git commit -m "feat(ai-session): add mid-session ticket linking with context injection"
```
---
## Slice 5: Configuration & Settings
### Task 11: FlowPilot PSA settings
**Files:**
- Edit: `frontend/src/pages/account/IntegrationsPage.tsx` (or create a new section)
- Edit: `backend/app/models/psa_connection.py` (add fields if needed)
- Edit: `backend/app/api/endpoints/integrations.py` (settings CRUD)
**What to add:**
Under the existing PSA integrations settings, add a "FlowPilot Settings" section:
- **Auto-push documentation:** Toggle (default: on) — automatically push session documentation to linked tickets on resolution
- **Auto-create time entry:** Toggle (default: on) — automatically create a time entry when resolving (requires CW member mapping)
- **Time rounding:** Dropdown — "Nearest 15 minutes" (default), "Nearest 30 minutes", "Exact", "Don't create time entries"
- **Default note visibility:** Dropdown — "Internal only" (default), "Internal and external"
- **Include diagnostic steps in notes:** Toggle (default: on) — if off, only push the summary, not the full step trail
- **Prompt for ticket status on resolution:** Toggle (default: off) — when on, engineer sees a status dropdown at resolution time, populated dynamically from the linked ticket's board statuses via `get_ticket_statuses(board_id)`. When off, ticket status is not changed.
- **Prompt for ticket status on escalation:** Toggle (default: off) — same as above but for escalation
**Note on status dropdowns:** These are NOT global dropdowns in settings. The setting is just a toggle for whether the engineer is prompted. The actual status options are pulled dynamically at resolution/escalation time based on the specific ticket's board (using the existing `get_ticket_statuses(board_id)` method). This is board-agnostic — works correctly regardless of which CW board the ticket is on.
These settings should be stored on the `PsaConnection` model as a `flowpilot_settings` JSONB column (add via migration if needed).
**Verification:** Navigate to integrations settings. See FlowPilot settings section. Toggle settings. Resolve a session with "prompt for status" enabled — verify the status dropdown shows the correct statuses for that ticket's board. Verify the documentation push respects all configured settings.
```
git commit -m "feat(ai-session): add FlowPilot PSA configuration settings"
```
---
## Summary of All New/Modified Files
### Backend — New
```
app/services/psa_documentation_service.py # Documentation push to PSA
app/services/psa_retry_scheduler.py # APScheduler job for retrying failed PSA pushes
```
### Backend — Modified
```
app/services/flowpilot_engine.py # PSA ticket intake, pause/resume, enhanced escalation, pickup
app/api/endpoints/ai_sessions.py # Pause, resume, pickup, escalation queue, link-ticket, retry-push endpoints
app/schemas/ai_session.py # New schemas: PickupSessionRequest, LinkTicketRequest, psa_push_status, member_mapping_warning
app/models/psa_connection.py # Add flowpilot_settings JSONB column
app/models/psa_post_log.py # Add ai_session_id FK, make session_id nullable, add retry_count
app/services/psa/base.py # Add abstract create_time_entry() method
app/services/psa/types.py # Add PSATimeEntry type
app/services/psa/connectwise/provider.py # Implement create_time_entry() for CW API
app/components/session/TicketPickerModal.tsx # Add onSelect callback prop for dual-mode usage
alembic/versions/xxx_phase2_psa_flowpilot.py # Migration: flowpilot_settings, psa_post_log changes
```
### Frontend — New
```
src/components/flowpilot/EscalateModal.tsx # Enhanced escalation dialog with team member dropdown
src/components/flowpilot/EscalationQueue.tsx # Pickup queue for escalated sessions
src/components/flowpilot/SessionBriefing.tsx # Handoff briefing card with continue/fresh options
src/components/flowpilot/SessionTicketCard.tsx # Ticket info in session sidebar
```
### Frontend — Modified
```
src/components/flowpilot/FlowPilotIntake.tsx # Ticket picker integration, manual fallback, PSA connection check
src/components/flowpilot/FlowPilotSession.tsx # Ticket card in sidebar, link ticket button, pause/resume
src/components/flowpilot/FlowPilotActionBar.tsx # Pause button, escalate opens enhanced modal
src/components/flowpilot/SessionDocView.tsx # PSA push status indicator, retry button, member mapping warning
src/components/session/TicketPickerModal.tsx # Add onSelect prop for FlowPilot intake usage
src/components/layout/Sidebar.tsx # Escalation queue nav item with badge count
src/hooks/useFlowPilotSession.ts # Pause, resume, pickup, linkTicket, escalationQueue, retryPsaPush
src/api/aiSessions.ts # New API functions (follow aiSessionsApi object pattern)
src/types/ai-session.ts # New types: psa_push_status, PickupSessionRequest, etc.
src/pages/account/IntegrationsPage.tsx # FlowPilot PSA settings section
src/router.tsx # Escalation queue route
```
---
## Database Changes
**Migration:** This phase requires a single migration with multiple changes:
```python
# 1. Add flowpilot_settings to psa_connections
op.add_column('psa_connections', sa.Column(
'flowpilot_settings',
sa.dialects.postgresql.JSONB(),
nullable=True,
server_default='{}',
comment='FlowPilot-specific settings: auto_push, time_rounding, note_visibility, etc.'
))
# 2. Add ai_session_id FK to psa_post_log (existing table points to old sessions only)
op.add_column('psa_post_log', sa.Column(
'ai_session_id',
sa.dialects.postgresql.UUID(as_uuid=True),
sa.ForeignKey('ai_sessions.id', ondelete='CASCADE'),
nullable=True,
comment='FK to AI sessions (Phase 2). Original session_id FK remains for legacy sessions.'
))
op.create_index('ix_psa_post_log_ai_session_id', 'psa_post_log', ['ai_session_id'])
# 3. Make original session_id nullable (was NOT NULL — legacy sessions only)
op.alter_column('psa_post_log', 'session_id', nullable=True)
# 4. Add retry_count to psa_post_log for automatic retries
op.add_column('psa_post_log', sa.Column(
'retry_count',
sa.Integer(),
nullable=False,
server_default='0',
comment='Number of retry attempts for failed PSA pushes'
))
op.add_column('psa_post_log', sa.Column(
'next_retry_at',
sa.DateTime(timezone=True),
nullable=True,
comment='When to attempt the next retry'
))
```
**Also update `PsaPostLog` model** (`app/models/psa_post_log.py`): Add the `ai_session_id` mapped column and relationship. Make `session_id` `Optional`. Add `retry_count` and `next_retry_at`.
**Also update `PsaConnection` model** (`app/models/psa_connection.py`): Add the `flowpilot_settings` JSONB mapped column.
**Also update PSA abstraction layer:**
- `services/psa/types.py`: Add `PSATimeEntry` model
- `services/psa/base.py`: Add abstract `create_time_entry()` method
- `services/psa/connectwise/provider.py`: Implement `create_time_entry()` using CW `POST /time/entries`
**Run migration:**
```bash
cd /projects/patherly/backend
DATABASE_URL=postgresql://postgres:postgres@resolutionflow_postgres:5432/resolutionflow \
venv/bin/alembic upgrade head
```
---
## Testing Strategy
All tests use mocked ConnectWise responses based on the OpenAPI spec (no CW sandbox available yet). Mock shapes should match `docs/connectwise/connectwise-psa-resolutionflow-reference.json`.
### Backend Unit Tests
**Files:** `backend/tests/test_psa_documentation_service.py`
- Test documentation formatting for resolved sessions (verify timing section always present)
- Test documentation formatting for escalated sessions
- Test password redaction in documentation
- Test time entry calculation (rounding logic for 15min, 30min, exact)
- Test PSA push with mock ConnectWise client
- Test missing member mapping — verify warning returned and note still includes timing
- Test retry logic — verify exponential backoff scheduling
**Files:** `backend/tests/test_escalation_handoff.py`
- Test escalation package generation (including LLM-generated hypotheses)
- Test self-escalation blocked (400 error)
- Test session pickup flow — "continue" mode (new engineer, briefing step)
- Test session pickup flow — "fresh" mode (new engineer provides own context)
- Test ownership preserved (session.user_id stays as Engineer A)
- Test permission enforcement (can't pick up session from another team)
- Test pause/resume for same engineer
**Files:** `backend/tests/test_ai_sessions_psa.py`
- Full flow: create ticket-based session → diagnose → resolve → verify PSA push with timing
- Full flow: create session → escalate → pickup by another user → resolve
- Test mid-session ticket linking with context injection
- Test PSA push failure → automatic retry → eventual success
- Test PSA push failure → exhaust retries → manual retry button
- Test graceful degradation when CW API is unavailable at intake
### Frontend Manual Testing
1. Start a session from a ticket — verify FlowPilot references ticket context
2. Start a session with CW unavailable — verify manual fallback works
3. Resolve a ticket session — verify ConnectWise shows the note with timing
4. Resolve without CW member mapping — verify warning shown, timing in notes
5. Start a free-text session → link ticket mid-session → verify FlowPilot acknowledges context → resolve → verify push
6. Pause a session → navigate away → come back → resume → verify full context preserved
7. Close browser tab during active session → reopen → navigate to session → verify recovery
8. Full escalation: Engineer A escalates → Engineer B sees badge in sidebar → picks up via "Continue" → sees briefing → resolves
9. Full escalation: Engineer B picks up via "Start Fresh" with own context → FlowPilot doesn't repeat A's steps
10. Verify Engineer A still sees the session in their history after B picks it up
11. Test PSA settings — toggle options, verify behavior changes
12. Test ticket status prompt at resolution — verify correct statuses shown for that ticket's board
---
## What Comes Next (Phase 3 — NOT in scope here)
For context only — do NOT implement these in Phase 2:
- **Knowledge Flywheel:** Post-session flow proposal generation
- **Review Queue:** UI for approving AI-generated flow proposals
- **Flow Editor as curation tool:** Repurpose for reviewing AI-generated flows
- **In-session Script Generator:** FlowPilot invokes script generation contextually
- **Knowledge gap detection:** Track free-text escapes, high escalation categories
- **Team analytics:** MTTR, resolution rates, knowledge coverage
- **Escalation notifications:** Push notifications or email alerts for escalation queue (Phase 2 has in-app badge only)