CI surfaced react-hooks/set-state-in-effect on the synchronous setState(computeState(token)) inside the useEffect body. The earlier shape mirrored token -> state via an effect, which is exactly the "you might not need an effect" pattern React 19's eslint rule now flags. Switch to derived state: compute during render, use a useReducer tick to force re-render on the 30s cadence (so relative timestamps stay current even when token props don't change). Same observable behavior, no cascading renders. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
46 KiB
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— hasformat_ticket_context_for_prompt()already - Existing schemas:
schemas/psa_context.py— hasTicketContext,TicketDetails,CompanyInfo,ConfigItem,TicketNote, etc. - Existing frontend:
TicketPickerModal.tsx,TicketContextPanel.tsx,IntegrationsPage.tsx - Existing service:
services/redaction_service.py— hasapply_redaction_to_text()for password redaction
Existing patterns to follow:
- PSA:
app/services/psa/— abstractPSAProviderinterface + 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 formatsTicketContextinto 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.tsusesaiSessionsApiobject pattern (not standalone exports) - Frontend ticket UI:
src/components/session/TicketPickerModal.tsx(note: currently takessessionIdprop for old sessions — needs adapter)
Key Design Decisions (from product review)
These decisions were confirmed during product review before implementation:
- PSA connection scope: Per-account (one CW connection per MSP). Individual engineers mapped to CW members via
PsaMemberMapping. - 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.
- 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.
- 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.
- Session ownership on escalation: Engineer A keeps ownership (
session.user_idunchanged). Session goes torequesting_escalationstatus. Engineer B works within the same session but A remains the originator. Both see it in their history. - 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.
- Pause/Resume — same engineer, bookmark for later or recover from browser crash. Status:
- Escalation queue location: Both — sidebar nav item "Escalations" with badge count AND a tab in session history page.
- 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).
- 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]...").
- 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.
- 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:
- Receives
psa_connection_idandpsa_ticket_idfrom the intake request - Loads the
PsaConnectionfrom the database - 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) - On success: stores the full ticket context (serialized
TicketContext) insession.ticket_dataJSONB - Uses the existing
format_ticket_context_for_prompt()fromservices/psa/ticket_context.pyto build the system prompt context block — do NOT rewrite this formatting, it already handles all fields correctly - Builds enriched intake content that includes both the formatted ticket context and any additional free-text the engineer provided
- 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_idis set (so we know which ticket to push docs to later)session.psa_connection_idis setsession.ticket_datais 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:
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
FlowPilotTicketPickercomponent that reuses the search/display logic but returns the selected ticket data to the parent instead of calling the link API, or - (b) Refactor
TicketPickerModalto accept anonSelectcallback prop as an alternative tosessionId, 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:
- The ticket summary populates the intake area as a styled ticket card (showing ticket #, summary, client name, priority badge)
- An additional textarea appears below for "Add context" — optional free text the engineer can add
- The intake type switches to
psa_ticket(orcombinedif they also add text) - On submit,
createAISession()is called withintake_type: "psa_ticket",psa_ticket_id,psa_connection_id, andintake_contentcontaining 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
useTicketContexthook 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
TicketContextPanelstyling — 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:
- Internal Note: Full diagnostic trail posted as an internal note on the ticket
- Time Entry: Auto-create a time entry with the session duration (if CW member mapping exists)
- 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
PsaPostLogmodel hasForeignKey("sessions.id")pointing to old sessions, NOTai_sessions. You must add anai_session_idnullable UUID FK column toPsaPostLog(via migration) so it can reference AI sessions. Keep the originalsession_idcolumn 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)toservices/psa/base.pyas an abstract method, (2) implement it inservices/psa/connectwise/provider.pyusing the CWPOST /time/entriesendpoint, (3) add aPSATimeEntrytype toservices/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 amember_mapping_warningin 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()fromservices/redaction_service.pyto scrub passwords and sensitive data before pushing to ConnectWise - Time entry calculation:
session.resolved_at - session.created_at, rounded to nearest 15 minutes (configurable viaflowpilot_settings) - Automatic retry on failure: If the PSA push fails, create a
PsaPostLogentry withstatus='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 becomesfailedand 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:
- Engineer clicks Resolve →
POST /ai-sessions/{id}/resolve flowpilot_engine.resolve_session()generates documentation (existing)- New: If session has PSA link, call
psa_documentation_service.push_documentation(session, documentation) - Push runs async — don't block the response
- Return
SessionCloseResponsewith 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:
- Query
PsaPostLogfor entries withstatus='pending_retry'andretry_count < 3 - For each, attempt the push again via
psa_documentation_service - On success: update
status='sent' - On failure: increment
retry_count, set next retry with exponential backoff - 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:
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:
- Verify session is
activeand belongs to current user - Set
session.status = "paused",session.paused_at = utcnow() - Return updated session
New endpoint — Resume own paused session:
POST /api/v1/ai-sessions/{id}/resume
Flow:
- Verify session is
pausedand belongs to current user - Set
session.status = "active", clearpaused_at - Return the session with all existing steps (engineer picks up exactly where they left off)
- 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:
- Change
session.status = "escalated"→session.status = "requesting_escalation" - Do NOT set
session.resolved_atyet (session isn't done — it's waiting for pickup) - Store
session.escalation_package["original_user_id"] = str(user_id) - 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 viaAI_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 failedenvironment_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:
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:
- Verify session status is
requesting_escalation - Verify the current user has permission (same team) and is NOT the original engineer
- Track the new handler (add to
escalation_package["picked_up_by"] = str(user_id),escalation_package["picked_up_at"] = utcnow()) - Set
session.status = "active" - 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: ..."
- Based on
resume_mode:"continue": Generate the next diagnostic step as usual (picks up where A left off)"fresh": Useadditional_contextas new input, but FlowPilot's system prompt includes all of A's work so it won't repeat steps
- 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"andadditional_context
- "Continue Where They Left Off" → calls pickup with
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 isactive, 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):
// 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:
class LinkTicketRequest(BaseModel):
psa_ticket_id: str
psa_connection_id: UUID
Flow:
- Fetch ticket data from ConnectWise (graceful failure — if CW is down, still store the ticket ID for later doc push)
- Update
session.psa_ticket_id,session.psa_connection_id,session.ticket_data - Inject ticket context into FlowPilot's system prompt for subsequent steps — append the formatted ticket context to
session.conversation_messagessystem prompt. FlowPilot will naturally acknowledge the new context in its next response. - 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:
# 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: AddPSATimeEntrymodelservices/psa/base.py: Add abstractcreate_time_entry()methodservices/psa/connectwise/provider.py: Implementcreate_time_entry()using CWPOST /time/entries
Run migration:
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
- Start a session from a ticket — verify FlowPilot references ticket context
- Start a session with CW unavailable — verify manual fallback works
- Resolve a ticket session — verify ConnectWise shows the note with timing
- Resolve without CW member mapping — verify warning shown, timing in notes
- Start a free-text session → link ticket mid-session → verify FlowPilot acknowledges context → resolve → verify push
- Pause a session → navigate away → come back → resume → verify full context preserved
- Close browser tab during active session → reopen → navigate to session → verify recovery
- Full escalation: Engineer A escalates → Engineer B sees badge in sidebar → picks up via "Continue" → sees briefing → resolves
- Full escalation: Engineer B picks up via "Start Fresh" with own context → FlowPilot doesn't repeat A's steps
- Verify Engineer A still sees the session in their history after B picks it up
- Test PSA settings — toggle options, verify behavior changes
- 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)