From 8fd2c1bac684de8859c31e8ede4dc0c224209fd7 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Tue, 21 Apr 2026 23:54:54 -0400 Subject: [PATCH] =?UTF-8?q?feat(pilot):=20Phase=204=20=E2=80=94=20Resolve?= =?UTF-8?q?=20+=20Escalate=20PSA=20writebacks=20with=20status=20verificati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the preview popover's Confirm & post action to ConnectWise (and, via the provider pattern, any future PSA). Adds the parallel Escalate flow with the handoff-oriented five-section markdown. Sessions without a linked PSA ticket resolve/escalate locally — markdown stored, status flipped, nothing posted externally. Backend: - EscalationPackageGeneratorService: Sonnet, five sections (Problem / What we've confirmed / What we've tried / Current hypothesis / Suggested next steps). Shares the preview_cache with a separate KIND so Resolve and Escalate previews for the same state coexist. - PSAWritebackService: post_resolution_note (RESOLUTION note type, customer-visible), post_escalation_package (INTERNAL_ANALYSIS, handoff for the next engineer only), transition_ticket_status with mandatory re-fetch verification. PSAStatusVerificationError surfaces loudly when CW silently rejects a status change — the ConnectWise anti-pattern CLAUDE.md flags. - Endpoints: * POST /ai-sessions/{id}/escalation-package/preview * POST /ai-sessions/{id}/resolution-note/post * POST /ai-sessions/{id}/escalation-package/post Outcomes: "resolved" / "escalated" with external_id + verified status, "resolved_local" / "escalated_local" when no PSA linked. - Target CW status IDs live in account_settings.preferences (cw_resolved_status_id, cw_escalated_status_id). When unset, the post proceeds without a status transition — response includes a status_transition_skipped_reason rather than silently erroring. - 7 tests: local-only path, PSA happy path with verified transition, status verification failure → 502, skipped transition when unconfigured, 409 on already-resolved re-post, escalate parallel path, internal-analysis note type enforced. Frontend: - ResolutionNotePreview now kind-parameterized ('resolve' | 'escalate') with inline edit + Confirm & post. Preview loads from the matching backend endpoint; posting calls the matching endpoint; outcome toast surfaces the verified CW status or the local-only result. - AssistantChatPage: previewKind state replaces previewOpen; two toggle buttons (Preview Resolve note / Escalate instead) in the lane's bottom slot. handleConfirmPost dispatches by kind. Verified 2026-04-22: - Local-only Resolve + Escalate round-trip against the dev stack. - Live Sonnet escalation-package preview; cache hit on repeat call with no state change (separate cache kind from resolution-note). - PSA post + status-verification paths covered by mocked-provider pytest cases. Live CW round-trip pending a test CW instance. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/endpoints/session_suggested_fixes.py | 262 +++++++++++++++- backend/app/core/config.py | 3 + backend/app/schemas/session_suggested_fix.py | 32 ++ .../services/escalation_package_generator.py | 277 +++++++++++++++++ backend/app/services/psa_writeback_service.py | 223 ++++++++++++++ backend/tests/test_psa_writeback_phase4.py | 281 ++++++++++++++++++ .../FLOWPILOT-MIGRATION.md | 18 +- frontend/src/api/sessionSuggestedFixes.ts | 49 +++ .../pilot/ResolutionNotePreview.tsx | 118 ++++++-- frontend/src/pages/AssistantChatPage.tsx | 120 ++++++-- 10 files changed, 1337 insertions(+), 46 deletions(-) create mode 100644 backend/app/services/escalation_package_generator.py create mode 100644 backend/app/services/psa_writeback_service.py create mode 100644 backend/tests/test_psa_writeback_phase4.py diff --git a/backend/app/api/endpoints/session_suggested_fixes.py b/backend/app/api/endpoints/session_suggested_fixes.py index e18f418b..9243ad5d 100644 --- a/backend/app/api/endpoints/session_suggested_fixes.py +++ b/backend/app/api/endpoints/session_suggested_fixes.py @@ -1,8 +1,14 @@ -"""Suggested-fix and resolution-note preview endpoints (Phase 3). +"""Suggested-fix + resolution-note / escalation-package preview-and-post endpoints. -Per FLOWPILOT-MIGRATION.md Sections 5.2 + 5.4. The preview is keyed on -`(session_id, ai_sessions.state_version)` so repeat fetches against the same -state hit the in-process cache instead of paying for a Sonnet call. +Phase 3: active suggested fix lookup + decision recording, resolution-note +preview with state_version cache. + +Phase 4: resolution-note POST (writeback to PSA + mark resolved), escalation +package preview + POST (writeback + mark escalated). Local-only path when +the session has no linked PSA ticket: markdown is stored on the session and +the status flipped, no external call. + +Per FLOWPILOT-MIGRATION.md Sections 5.2 + 5.4. """ import logging from datetime import datetime, timezone @@ -18,12 +24,20 @@ from app.models.ai_session import AISession from app.models.session_suggested_fix import SessionSuggestedFix from app.models.user import User from app.schemas.session_suggested_fix import ( + EscalationPackagePostRequest, + ResolutionNotePostRequest, ResolutionNotePreviewResponse, + ResolutionPostResponse, SessionSuggestedFixDecisionRequest, SessionSuggestedFixDecisionResponse, SessionSuggestedFixResponse, ) +from app.services.escalation_package_generator import EscalationPackageGeneratorService from app.services.preview_cache import preview_cache +from app.services.psa_writeback_service import ( + PSAStatusVerificationError, + PSAWritebackService, +) from app.services.resolution_note_generator import ResolutionNoteGeneratorService logger = logging.getLogger(__name__) @@ -176,6 +190,246 @@ async def resolution_note_preview( return ResolutionNotePreviewResponse(**payload) +# ── Phase 4: escalation-package preview ──────────────────────────────────── + +@router.post( + "/escalation-package/preview", + response_model=ResolutionNotePreviewResponse, +) +async def escalation_package_preview( + session_id: UUID, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], + _: None = Depends(require_engineer_or_admin), +) -> ResolutionNotePreviewResponse: + """Generate (or return cached) draft markdown for the Escalate handoff package. + + Same caching story as the resolution-note preview: keyed on + `(session_id, state_version)`. Separate cache kind so a Resolve preview + and an Escalate preview for the same state can coexist. + """ + await _load_session_or_404(db, session_id) + gen = EscalationPackageGeneratorService(db) + try: + payload = await gen.generate_or_get_cached(session_id) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except Exception as e: + logger.exception("Escalation package preview failed for session %s", session_id) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Escalation-package generator error ({type(e).__name__})", + ) + return ResolutionNotePreviewResponse(**payload) + + +# ── Phase 4: Resolve & post ──────────────────────────────────────────────── + +@router.post( + "/resolution-note/post", + response_model=ResolutionPostResponse, +) +async def post_resolution_note( + session_id: UUID, + body: ResolutionNotePostRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], + _: None = Depends(require_engineer_or_admin), +) -> ResolutionPostResponse: + """Commit the engineer-edited resolution note and close the session. + + Three outcomes: + - **External post + status verified** — session.status='resolved', + markdown + external_id + posted_at persisted, CW status flipped to + the configured Resolved status ID and re-fetch-verified. + - **External post only** — markdown posted, but no cw_resolved_status_id + configured → session.status='resolved', `status_transition_skipped_reason` + explains the skip. Not an error — posting the note is meaningful. + - **Local-only** — session has no linked PSA ticket → markdown stored on + `resolution_note_markdown`, session.status='resolved', outcome = + 'resolved_local'. No external call. + + Status verification failure raises 502: the engineer intended to close + the ticket but we cannot confirm it actually closed. Surfacing silent + success would be a footgun. + """ + session_obj = await _load_session_or_404(db, session_id) + if session_obj.status not in ("active", "paused", "requesting_escalation", "escalated"): + # Already-resolved sessions shouldn't be re-posted; caller should + # query first. escalated→resolved is allowed (engineer revised course). + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Session is already {session_obj.status}", + ) + + service = PSAWritebackService(db) + summary = (body.resolution_summary or body.markdown.strip().splitlines()[0])[:500] + + # Local-only path — no PSA ticket linked, nothing to post. + if not session_obj.psa_ticket_id or not session_obj.psa_connection_id: + session_obj.resolution_note_markdown = body.markdown.strip() + session_obj.status = "resolved" + session_obj.resolved_at = datetime.now(timezone.utc) + session_obj.resolution_summary = summary + await db.commit() + return ResolutionPostResponse( + outcome="resolved_local", + session_status=session_obj.status, + ) + + try: + posted = await service.post_resolution_note(session_obj, body.markdown) + except Exception as e: + logger.exception("post_resolution_note failed for session %s", session_id) + await db.rollback() + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"PSA post failed ({type(e).__name__})", + ) + + # Attempt the status transition if configured; failed verification is + # surfaced loudly (status_code 502) per the ConnectWise anti-silent- + # success principle. Not configured → skip with a reason, not an error. + target_status_id = await service.resolved_status_id_for_account(session_obj.account_id) + verified_status_id: int | None = None + verified_status_name: str | None = None + skipped_reason: str | None = None + if target_status_id is None: + skipped_reason = ( + "No cw_resolved_status_id configured in account_settings.preferences — " + "note posted, status unchanged." + ) + else: + try: + result = await service.transition_ticket_status(session_obj, target_status_id) + verified_status_id = result["verified_status_id"] + verified_status_name = result["verified_status_name"] + except PSAStatusVerificationError as e: + logger.error("Status verification failed for session %s: %s", session_id, e) + # Note was already posted — roll that partial side effect back in + # the session record (the CW note itself can't be un-posted). + await db.rollback() + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(e), + ) + except Exception as e: + logger.exception("Status transition failed for session %s", session_id) + await db.rollback() + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"PSA status transition error ({type(e).__name__})", + ) + + session_obj.status = "resolved" + session_obj.resolved_at = datetime.now(timezone.utc) + session_obj.resolution_summary = summary + await db.commit() + + return ResolutionPostResponse( + outcome="resolved", + session_status=session_obj.status, + external_id=posted["external_id"], + posted_at=posted["posted_at"], + verified_status_id=verified_status_id, + verified_status_name=verified_status_name, + status_transition_skipped_reason=skipped_reason, + ) + + +# ── Phase 4: Escalate & post ────────────────────────────────────────────── + +@router.post( + "/escalation-package/post", + response_model=ResolutionPostResponse, +) +async def post_escalation_package( + session_id: UUID, + body: EscalationPackagePostRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], + _: None = Depends(require_engineer_or_admin), +) -> ResolutionPostResponse: + """Commit the engineer-edited escalation package and mark the session escalated. + + Structure mirrors post_resolution_note: + - Local-only when no PSA ticket: markdown stored, session.status='escalated'. + - PSA post: internal-analysis note (handoff is for the next engineer, + not the customer), optional status transition via cw_escalated_status_id, + re-fetch verified. + """ + session_obj = await _load_session_or_404(db, session_id) + if session_obj.status not in ("active", "paused", "resolved"): + # resolved→escalated is allowed (engineer realized they need help + # after closing); escalated→escalated would be a no-op, block it. + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Session is already {session_obj.status}", + ) + + service = PSAWritebackService(db) + reason = body.escalation_reason or body.markdown.strip().splitlines()[0][:500] + + if not session_obj.psa_ticket_id or not session_obj.psa_connection_id: + session_obj.escalation_package_markdown = body.markdown.strip() + session_obj.status = "escalated" + session_obj.escalation_reason = reason + await db.commit() + return ResolutionPostResponse( + outcome="escalated_local", + session_status=session_obj.status, + ) + + try: + posted = await service.post_escalation_package(session_obj, body.markdown) + except Exception as e: + logger.exception("post_escalation_package failed for session %s", session_id) + await db.rollback() + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"PSA post failed ({type(e).__name__})", + ) + + target_status_id = await service.escalated_status_id_for_account(session_obj.account_id) + verified_status_id: int | None = None + verified_status_name: str | None = None + skipped_reason: str | None = None + if target_status_id is None: + skipped_reason = ( + "No cw_escalated_status_id configured — package posted, status unchanged." + ) + else: + try: + result = await service.transition_ticket_status(session_obj, target_status_id) + verified_status_id = result["verified_status_id"] + verified_status_name = result["verified_status_name"] + except PSAStatusVerificationError as e: + logger.error("Status verification failed for session %s: %s", session_id, e) + await db.rollback() + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) + except Exception as e: + logger.exception("Status transition failed for session %s", session_id) + await db.rollback() + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"PSA status transition error ({type(e).__name__})", + ) + + session_obj.status = "escalated" + session_obj.escalation_reason = reason + await db.commit() + + return ResolutionPostResponse( + outcome="escalated", + session_status=session_obj.status, + external_id=posted["external_id"], + posted_at=posted["posted_at"], + verified_status_id=verified_status_id, + verified_status_name=verified_status_name, + status_transition_skipped_reason=skipped_reason, + ) + + # ── Helper used by tests ─────────────────────────────────────────────────── def _clear_preview_cache_for_tests() -> None: diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 85d5d306..be180b97 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -139,6 +139,9 @@ class Settings(BaseSettings): # matters more than latency; the in-process state_version cache keeps # cost manageable. "resolution_note": "standard", + # FlowPilot migration Phase 4 — escalation handoff package. Parallel + # to resolution_note: Sonnet, same cache story, no MCP. + "escalation_package": "standard", } def get_model_for_action(self, action_type: str) -> str: diff --git a/backend/app/schemas/session_suggested_fix.py b/backend/app/schemas/session_suggested_fix.py index 471dcb82..afc315e9 100644 --- a/backend/app/schemas/session_suggested_fix.py +++ b/backend/app/schemas/session_suggested_fix.py @@ -61,3 +61,35 @@ class ResolutionNotePreviewResponse(BaseModel): target_ticket_ref: str | None state_version: int from_cache: bool + + +# ── Phase 4: Resolve + Escalate post ─────────────────────────────────────── + +class ResolutionNotePostRequest(BaseModel): + """Engineer-edited resolution markdown. Server posts to PSA + marks resolved.""" + markdown: str = Field(..., min_length=1, max_length=20_000) + # Optional override for resolution summary shown on the session listing; + # defaults to the first line of the markdown if omitted. + resolution_summary: str | None = Field(None, max_length=500) + + +class EscalationPackagePostRequest(BaseModel): + markdown: str = Field(..., min_length=1, max_length=20_000) + # Free-text reason shown in session listings and escalation queue. + escalation_reason: str | None = Field(None, max_length=500) + + +class ResolutionPostResponse(BaseModel): + """Response shape for both Resolve/Escalate POST endpoints.""" + # "resolved" / "escalated" / "resolved_local" / "escalated_local" + # The _local variants indicate the session has no linked PSA ticket — + # markdown is stored, session state is updated, nothing was posted externally. + outcome: str + session_status: str + external_id: str | None = None + posted_at: datetime | None = None + # Populated when a status transition was attempted and verified. None + # when no target status is configured in account_settings.preferences. + verified_status_id: int | None = None + verified_status_name: str | None = None + status_transition_skipped_reason: str | None = None diff --git a/backend/app/services/escalation_package_generator.py b/backend/app/services/escalation_package_generator.py new file mode 100644 index 00000000..cb539afe --- /dev/null +++ b/backend/app/services/escalation_package_generator.py @@ -0,0 +1,277 @@ +"""EscalationPackageGeneratorService — drafts the handoff package for a session. + +Parallel to ResolutionNoteGeneratorService but oriented around handoff to +another engineer instead of closing the ticket. The output markdown follows +FLOWPILOT-MIGRATION.md Section 6.3: + + ## Problem + ## What we've confirmed + ## What we've tried + ## Current hypothesis + ## Suggested next steps + +Same caching story as resolution-note previews: keyed on +`(session_id, ai_sessions.state_version)` via `preview_cache`, invalidated by +any fact / suggested-fix / script-generation write. + +Model: Sonnet (`escalation_package` action tier per Section 6.6). MCP off. +""" +from __future__ import annotations + +import logging +from typing import Any +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.ai_provider import get_ai_provider +from app.core.config import settings +from app.models.ai_session import AISession +from app.models.script_template import ScriptGeneration, ScriptTemplate +from app.models.session_fact import SessionFact +from app.models.session_suggested_fix import SessionSuggestedFix +from app.services.preview_cache import preview_cache +from app.services.script_template_engine import ScriptTemplateEngine + +logger = logging.getLogger(__name__) + + +_ESCALATION_SYSTEM_PROMPT = """\ +You produce structured escalation handoff packages for an MSP troubleshooting \ +platform. The package is read by the next engineer picking up the ticket; it \ +must give them a running start without making them re-read the chat transcript. + +Output exactly this markdown structure, no preamble, no closing remarks, no \ +extra headings: + +## Problem + + +## What we've confirmed + + +## What we've tried + + +## Current hypothesis + + +## Suggested next steps +80%), the first bullet is "Try the \ +suggested fix: ."> + +Strict rules: +- Use ONLY the input I provide. Never invent command names, KB articles, or \ +configuration specifics that aren't in the input. +- Do not include placeholder text like "TBD" or empty bullets. +- Do not include the engineer's name, the AI's name, session IDs, or the \ +chat transcript verbatim. +- Markdown headings exactly as shown (## level), no bolding. +- The tone is a peer handing off to a peer, not a status report. +""" + + +class EscalationPackageGeneratorService: + """Generates and caches the five-section Escalate handoff markdown.""" + + KIND = "escalation_package" + + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def generate_or_get_cached( + self, session_id: UUID, *, force: bool = False, + ) -> dict[str, Any]: + session = await self._load_session(session_id) + cached = preview_cache.get(self.KIND, session.id, session.state_version) if not force else None + if cached is not None: + return {**cached, "from_cache": True} + + markdown = await self._render(session) + target = self._target_ticket_ref(session) + payload = { + "markdown": markdown, + "target_ticket_ref": target, + "state_version": session.state_version, + } + preview_cache.set(self.KIND, session.id, session.state_version, payload) + return {**payload, "from_cache": False} + + # ── Internals (parallel to ResolutionNoteGenerator) ─────────────────── + + async def _load_session(self, session_id: UUID) -> AISession: + result = await self.db.execute( + select(AISession).where(AISession.id == session_id) + ) + session = result.scalar_one_or_none() + if session is None: + raise ValueError(f"Session {session_id} not found") + return session + + async def _render(self, session: AISession) -> str: + facts = await self._load_facts(session.id) + active_fix = await self._load_active_fix(session.id) + gens = await self._load_redacted_generations(session.id) + + bundle = self._build_input_bundle(session, facts, active_fix, gens) + + model = settings.get_model_for_action("escalation_package") + provider = get_ai_provider(model=model) + + system_blocks: list[dict[str, Any]] = [ + { + "type": "text", + "text": _ESCALATION_SYSTEM_PROMPT, + "cache_control": {"type": "ephemeral"}, + # cacheable: identical across every escalation-package preview call + }, + ] + + try: + text, _in, _out = await provider.generate_text( + system_prompt=system_blocks, + messages=[{"role": "user", "content": bundle}], + max_tokens=1400, + ) + except Exception: + logger.exception("Escalation package generation failed for session %s", session.id) + raise + return text.strip() + + async def _load_facts(self, session_id: UUID) -> list[SessionFact]: + result = await self.db.execute( + select(SessionFact) + .where( + SessionFact.session_id == session_id, + SessionFact.deleted_at.is_(None), + ) + .order_by(SessionFact.created_at.asc()) + ) + return list(result.scalars().all()) + + async def _load_active_fix(self, session_id: UUID) -> SessionSuggestedFix | None: + result = await self.db.execute( + select(SessionSuggestedFix) + .where( + SessionSuggestedFix.session_id == session_id, + SessionSuggestedFix.superseded_at.is_(None), + ) + .order_by(SessionSuggestedFix.created_at.desc()) + ) + return result.scalars().first() + + async def _load_redacted_generations( + self, session_id: UUID + ) -> list[dict[str, Any]]: + result = await self.db.execute( + select(ScriptGeneration) + .where(ScriptGeneration.ai_session_id == session_id) + .order_by(ScriptGeneration.created_at.asc()) + ) + gens = list(result.scalars().all()) + if not gens: + return [] + + template_ids = {g.template_id for g in gens} + tpl_result = await self.db.execute( + select(ScriptTemplate).where(ScriptTemplate.id.in_(template_ids)) + ) + templates_by_id = {t.id: t for t in tpl_result.scalars().all()} + + engine = ScriptTemplateEngine() + out: list[dict[str, Any]] = [] + for g in gens: + tpl = templates_by_id.get(g.template_id) + sensitive_keys: set[str] = set() + schema = (tpl.parameters_schema if tpl else {}) or {} + params = schema.get("parameters") if isinstance(schema, dict) else None + if isinstance(params, list): + for p in params: + if isinstance(p, dict) and p.get("field_type") == "password": + k = p.get("key") or p.get("variable_name") + if isinstance(k, str): + sensitive_keys.add(k) + redacted_params = engine.redact_sensitive(g.parameters_used or {}, sensitive_keys) + out.append({ + "template_name": tpl.name if tpl else "(unknown template)", + "template_slug": tpl.slug if tpl else None, + "parameters_used": redacted_params, + "created_at": g.created_at.isoformat(), + }) + return out + + @staticmethod + def _target_ticket_ref(session: AISession) -> str | None: + if not session.psa_ticket_id: + return None + return f"CW #{session.psa_ticket_id}" + + @staticmethod + def _build_input_bundle( + session: AISession, + facts: list[SessionFact], + active_fix: SessionSuggestedFix | None, + generations: list[dict[str, Any]], + ) -> str: + lines: list[str] = [] + lines.append("# Session context") + lines.append(f"Title: {session.title or '(untitled)'}") + if session.problem_summary: + lines.append(f"Problem summary: {session.problem_summary}") + if session.problem_domain: + lines.append(f"Domain: {session.problem_domain}") + intake_text = (session.intake_content or {}).get("text") if isinstance(session.intake_content, dict) else None + if intake_text: + lines.append(f"Intake message: {intake_text}") + if session.psa_ticket_id: + lines.append(f"Linked PSA ticket: CW #{session.psa_ticket_id}") + + lines.append("") + lines.append("# Confirmed facts (What we know)") + if not facts: + lines.append("(none)") + else: + for f in facts: + tag = f.source_type + summary = f" — {f.source_summary}" if f.source_summary else "" + lines.append(f"- [{tag}] {f.text}{summary}") + + lines.append("") + lines.append("# Diagnostic checks run during the session") + check_facts = [f for f in facts if f.source_type == "diagnostic_check"] + if not check_facts and not generations: + lines.append("(none)") + else: + for f in check_facts: + lines.append(f"- {f.text}") + for g in generations: + lines.append(f"- Ran script {g['template_name']} (slug={g['template_slug']})") + if g["parameters_used"]: + lines.append(f" parameters: {g['parameters_used']}") + + lines.append("") + lines.append("# Active suggested fix (current hypothesis)") + if active_fix is None: + lines.append("(no active suggested fix)") + else: + lines.append(f"Title: {active_fix.title}") + lines.append(f"Confidence: {active_fix.confidence_pct}%") + lines.append(f"Description: {active_fix.description}") + + lines.append("") + lines.append( + "Produce the five-section escalation handoff now. Use only the input above." + ) + return "\n".join(lines) diff --git a/backend/app/services/psa_writeback_service.py b/backend/app/services/psa_writeback_service.py new file mode 100644 index 00000000..4a39cd2d --- /dev/null +++ b/backend/app/services/psa_writeback_service.py @@ -0,0 +1,223 @@ +"""PSA writeback for FlowPilot Phase 4 — Resolve + Escalate round-trip. + +Three primitives: + +- `post_resolution_note` — post the engineer-edited resolution markdown to + the PSA ticket, store `{external_id, posted_at}` on the session. +- `post_escalation_package` — same pattern for the Escalate flow. +- `transition_ticket_status` — patch the ticket status, then re-fetch and + verify the change actually took. Failed verification raises loudly so the + UI never reports silent success (per the existing ConnectWise integration + principle called out in FLOWPILOT-MIGRATION.md Section 6.5 and CLAUDE.md). + +The target status IDs live in `account_settings.preferences` +(`cw_resolved_status_id`, `cw_escalated_status_id`). When unset, the status +transition is a no-op and the endpoint response says so — we do not guess a +default because CW status IDs are board-specific. + +Local-only path: callers handle sessions without `psa_ticket_id` before +calling this service. Nothing here tries to "post locally" — the service's +job ends at the PSA boundary. +""" +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.account_settings import AccountSettings +from app.models.ai_session import AISession +from app.services.psa.exceptions import PSAConnectionError +from app.services.psa.registry import get_provider_for_connection +from app.services.psa.types import NoteType + +logger = logging.getLogger(__name__) + + +class PSAStatusVerificationError(RuntimeError): + """Raised when a ticket status transition didn't stick on re-fetch. + + The `update_ticket_status` call returned OK but the subsequent + `get_ticket` still shows the prior status (or some unrelated one). + This is the exact failure mode CLAUDE.md flags as a ConnectWise + anti-pattern: reporting success when nothing changed. + """ + + def __init__(self, ticket_id: str, expected_status_id: int, observed_status: Any) -> None: + super().__init__( + f"Ticket {ticket_id} status transition to {expected_status_id} " + f"did not verify — observed {observed_status!r} after re-fetch." + ) + self.ticket_id = ticket_id + self.expected_status_id = expected_status_id + self.observed_status = observed_status + + +class PSAWritebackService: + """Thin orchestration over the PSA provider for FlowPilot writebacks. + + Instances are per-request — the AsyncSession is the one handling the + current HTTP call, and the provider is resolved lazily from the session's + `psa_connection_id`. + """ + + def __init__(self, db: AsyncSession) -> None: + self.db = db + + # ── Public API ──────────────────────────────────────────────────────── + + async def post_resolution_note( + self, session: AISession, markdown: str + ) -> dict[str, Any]: + """Post `markdown` as a resolution note on the linked CW ticket. + + On success, persists `resolution_note_markdown`, `_posted_at`, + `_external_id` on the session and returns the same triple. Caller is + responsible for committing the transaction. + """ + return await self._post_note( + session=session, + markdown=markdown, + note_type=NoteType.RESOLUTION, + markdown_col="resolution_note_markdown", + posted_at_col="resolution_note_posted_at", + external_id_col="resolution_note_external_id", + kind="resolution", + ) + + async def post_escalation_package( + self, session: AISession, markdown: str + ) -> dict[str, Any]: + """Post `markdown` as an escalation handoff note on the CW ticket.""" + return await self._post_note( + session=session, + markdown=markdown, + # Internal-analysis visibility: the handoff is for the next engineer, + # not the customer. CW fires no notifications, keeps the note internal. + note_type=NoteType.INTERNAL_ANALYSIS, + markdown_col="escalation_package_markdown", + posted_at_col="escalation_package_posted_at", + external_id_col="escalation_package_external_id", + kind="escalation", + ) + + async def transition_ticket_status( + self, + session: AISession, + target_status_id: int, + ) -> dict[str, Any]: + """PATCH ticket status, then re-fetch and verify. + + Returns `{"success": True, "verified_status_id": <int>, "verified_status_name": <str>}` + when the observed status matches. Raises `PSAStatusVerificationError` + when the transition didn't take (most common real-world failure: CW + requires certain fields before allowing a status change to + Resolved — the PATCH returns 200 but the status silently stays put). + """ + if not session.psa_ticket_id or not session.psa_connection_id: + raise ValueError("Session has no linked PSA ticket for status transition") + + provider = await get_provider_for_connection(session.psa_connection_id, self.db) + await provider.update_ticket_status( + ticket_id=session.psa_ticket_id, status_id=target_status_id, + ) + + # Verify by re-fetch — this is the load-bearing step. + verification = await provider.get_ticket(session.psa_ticket_id) + observed_id = getattr(verification, "status_id", None) + observed_name = getattr(verification, "status_name", None) + if observed_id != target_status_id: + raise PSAStatusVerificationError( + ticket_id=session.psa_ticket_id, + expected_status_id=target_status_id, + observed_status={"id": observed_id, "name": observed_name}, + ) + + return { + "success": True, + "verified_status_id": observed_id, + "verified_status_name": observed_name, + } + + async def resolved_status_id_for_account( + self, account_id: UUID + ) -> int | None: + """Return the configured CW "Resolved" status ID for the account. + + None means "no transition configured" — callers should skip the + transition (posting the note is still meaningful). This lives in + account_settings.preferences per the Phase 1 JSONB grab-bag design. + """ + raw = await AccountSettings.get_setting(self.db, account_id, "cw_resolved_status_id", None) + return self._coerce_status_id(raw) + + async def escalated_status_id_for_account( + self, account_id: UUID + ) -> int | None: + raw = await AccountSettings.get_setting(self.db, account_id, "cw_escalated_status_id", None) + return self._coerce_status_id(raw) + + # ── Internals ───────────────────────────────────────────────────────── + + async def _post_note( + self, + *, + session: AISession, + markdown: str, + note_type: str, + markdown_col: str, + posted_at_col: str, + external_id_col: str, + kind: str, + ) -> dict[str, Any]: + if not session.psa_ticket_id or not session.psa_connection_id: + raise ValueError(f"Session has no linked PSA ticket for {kind} post") + + markdown = (markdown or "").strip() + if not markdown: + raise ValueError(f"{kind} markdown is empty") + + try: + provider = await get_provider_for_connection(session.psa_connection_id, self.db) + except PSAConnectionError: + # Connection could have been deleted or deactivated since session + # creation — propagate as a clear error for the endpoint to surface. + logger.exception( + "PSA connection %s is no longer available for session %s", + session.psa_connection_id, session.id, + ) + raise + + posted = await provider.post_note( + ticket_id=session.psa_ticket_id, + text=markdown, + note_type=note_type, + ) + + posted_at = datetime.now(timezone.utc) + setattr(session, markdown_col, markdown) + setattr(session, posted_at_col, posted_at) + setattr(session, external_id_col, str(posted.id) if posted.id else None) + + return { + "external_id": str(posted.id) if posted.id else None, + "posted_at": posted_at, + "kind": kind, + } + + @staticmethod + def _coerce_status_id(raw: Any) -> int | None: + if raw is None: + return None + try: + return int(raw) + except (TypeError, ValueError): + logger.warning( + "Non-integer CW status ID in account_settings.preferences: %r", + raw, + ) + return None diff --git a/backend/tests/test_psa_writeback_phase4.py b/backend/tests/test_psa_writeback_phase4.py new file mode 100644 index 00000000..131f1bec --- /dev/null +++ b/backend/tests/test_psa_writeback_phase4.py @@ -0,0 +1,281 @@ +"""API tests for the FlowPilot Phase 4 Resolve + Escalate writeback flow. + +Covers: +- Local-only path when no PSA ticket is linked (markdown stored, status flipped, + no provider call). +- PSA post happy path (provider mocked). +- Status transition verified by re-fetch (happy path). +- Status verification failure surfaces 502 with a clear error body. +- 409 when trying to resolve an already-resolved session / escalate an + already-escalated one. +- Escalation parallel to resolution (same structure). +""" +from __future__ import annotations + +import uuid +from unittest.mock import AsyncMock, patch + +import pytest +from httpx import AsyncClient + +from app.api.endpoints.session_suggested_fixes import _clear_preview_cache_for_tests +from app.models.account_settings import AccountSettings +from app.models.ai_session import AISession +from app.models.psa_connection import PsaConnection +from app.services.psa.types import NoteType, PSANote, PSATicket + + +@pytest.fixture(autouse=True) +def _isolate_preview_cache(): + _clear_preview_cache_for_tests() + yield + _clear_preview_cache_for_tests() + + +async def _make_session(test_db, user, *, with_psa: bool = False) -> AISession: + session_kwargs: dict = dict( + user_id=user["user_data"]["id"], + account_id=user["user_data"]["account_id"], + session_type="chat", + intake_type="free_text", + intake_content={"text": "phase 4 test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[], + ) + if with_psa: + # Fake connection — provider factory is patched in each test so we + # never touch a real CW instance. + from app.services.psa.encryption import encrypt_credentials + conn = PsaConnection( + account_id=user["user_data"]["account_id"], + provider="connectwise", + site_url="https://fake.cw.local", + company_id="TEST", + credentials_encrypted=encrypt_credentials({"public_key": "x", "private_key": "y"}), + is_active=True, + ) + test_db.add(conn) + await test_db.flush() + session_kwargs["psa_connection_id"] = conn.id + session_kwargs["psa_ticket_id"] = "48291" + + session = AISession(**session_kwargs) + test_db.add(session) + await test_db.commit() + await test_db.refresh(session) + return session + + +# ── Resolve: local-only ──────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_resolve_local_only_when_no_psa_ticket( + client: AsyncClient, test_user, auth_headers, test_db +): + session = await _make_session(test_db, test_user, with_psa=False) + r = await client.post( + f"/api/v1/ai-sessions/{session.id}/resolution-note/post", + headers=auth_headers, + json={"markdown": "## Problem\nx\n\n## Resolution\nfixed"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["outcome"] == "resolved_local" + assert body["session_status"] == "resolved" + assert body["external_id"] is None + + await test_db.refresh(session) + assert session.status == "resolved" + assert session.resolution_note_markdown == "## Problem\nx\n\n## Resolution\nfixed" + assert session.resolved_at is not None + + +# ── Resolve: happy path (PSA post + status transition verified) ──────────── + +@pytest.mark.asyncio +async def test_resolve_posts_to_psa_and_verifies_status( + client: AsyncClient, test_user, auth_headers, test_db +): + session = await _make_session(test_db, test_user, with_psa=True) + + # Configure the Resolved status ID so the transition is attempted. + await AccountSettings.set_setting( + test_db, session.account_id, "cw_resolved_status_id", 42, + ) + await test_db.commit() + + # Mock provider: post_note returns a fake note, update_ticket_status + # returns anything, get_ticket returns the new status_id (matches 42 + # → verification passes). + fake_provider = AsyncMock() + fake_provider.post_note = AsyncMock(return_value=PSANote( + id="cw-note-777", text="...", note_type=NoteType.RESOLUTION, created_at=None, + )) + fake_provider.update_ticket_status = AsyncMock(return_value=None) + fake_provider.get_ticket = AsyncMock(return_value=PSATicket( + id="48291", summary="t", status_id=42, status_name="Resolved", + )) + + with patch( + "app.services.psa_writeback_service.get_provider_for_connection", + return_value=fake_provider, + ): + r = await client.post( + f"/api/v1/ai-sessions/{session.id}/resolution-note/post", + headers=auth_headers, + json={"markdown": "## Problem\nx\n\n## Resolution\nfixed"}, + ) + + assert r.status_code == 200 + body = r.json() + assert body["outcome"] == "resolved" + assert body["external_id"] == "cw-note-777" + assert body["verified_status_id"] == 42 + assert body["verified_status_name"] == "Resolved" + + # post_note must have used the RESOLUTION note type + fake_provider.post_note.assert_awaited_once() + called_note_type = fake_provider.post_note.await_args.kwargs["note_type"] + assert called_note_type == NoteType.RESOLUTION + + +# ── Resolve: status verification failure → 502 ────────────────────────────── + +@pytest.mark.asyncio +async def test_resolve_surfaces_status_verification_failure( + client: AsyncClient, test_user, auth_headers, test_db +): + """CW silently rejecting a status change must NOT report silent success.""" + session = await _make_session(test_db, test_user, with_psa=True) + await AccountSettings.set_setting( + test_db, session.account_id, "cw_resolved_status_id", 42, + ) + await test_db.commit() + + fake_provider = AsyncMock() + fake_provider.post_note = AsyncMock(return_value=PSANote( + id="cw-note-alpha", text="...", note_type=NoteType.RESOLUTION, created_at=None, + )) + fake_provider.update_ticket_status = AsyncMock(return_value=None) + # get_ticket returns a DIFFERENT status_id — the transition didn't stick. + fake_provider.get_ticket = AsyncMock(return_value=PSATicket( + id="48291", summary="t", status_id=99, status_name="In Progress", + )) + + with patch( + "app.services.psa_writeback_service.get_provider_for_connection", + return_value=fake_provider, + ): + r = await client.post( + f"/api/v1/ai-sessions/{session.id}/resolution-note/post", + headers=auth_headers, + json={"markdown": "## Problem\nx\n\n## Resolution\nfixed"}, + ) + + assert r.status_code == 502 + assert "did not verify" in r.json()["detail"] + + +# ── Resolve: skip status transition when not configured ──────────────────── + +@pytest.mark.asyncio +async def test_resolve_skips_status_transition_when_unconfigured( + client: AsyncClient, test_user, auth_headers, test_db +): + """No cw_resolved_status_id setting → post the note, don't touch status, not an error.""" + session = await _make_session(test_db, test_user, with_psa=True) + # Deliberately no AccountSettings row. + + fake_provider = AsyncMock() + fake_provider.post_note = AsyncMock(return_value=PSANote( + id="cw-note-beta", text="...", note_type=NoteType.RESOLUTION, created_at=None, + )) + + with patch( + "app.services.psa_writeback_service.get_provider_for_connection", + return_value=fake_provider, + ): + r = await client.post( + f"/api/v1/ai-sessions/{session.id}/resolution-note/post", + headers=auth_headers, + json={"markdown": "## Problem\nx\n\n## Resolution\nfixed"}, + ) + + assert r.status_code == 200 + body = r.json() + assert body["outcome"] == "resolved" + assert body["verified_status_id"] is None + assert body["status_transition_skipped_reason"] is not None + fake_provider.update_ticket_status.assert_not_called() + fake_provider.get_ticket.assert_not_called() + + +# ── Resolve: already-resolved → 409 ───────────────────────────────────────── + +@pytest.mark.asyncio +async def test_resolve_rejects_already_resolved_session( + client: AsyncClient, test_user, auth_headers, test_db +): + session = await _make_session(test_db, test_user, with_psa=False) + session.status = "resolved" + await test_db.commit() + + r = await client.post( + f"/api/v1/ai-sessions/{session.id}/resolution-note/post", + headers=auth_headers, + json={"markdown": "..."}, + ) + assert r.status_code == 409 + + +# ── Escalate: local-only + PSA parallels ──────────────────────────────────── + +@pytest.mark.asyncio +async def test_escalate_local_only_when_no_psa_ticket( + client: AsyncClient, test_user, auth_headers, test_db +): + session = await _make_session(test_db, test_user, with_psa=False) + r = await client.post( + f"/api/v1/ai-sessions/{session.id}/escalation-package/post", + headers=auth_headers, + json={"markdown": "## Problem\nx\n\n## Suggested next steps\n- try X"}, + ) + assert r.status_code == 200 + assert r.json()["outcome"] == "escalated_local" + + await test_db.refresh(session) + assert session.status == "escalated" + assert session.escalation_package_markdown is not None + + +@pytest.mark.asyncio +async def test_escalate_posts_internal_note_to_psa( + client: AsyncClient, test_user, auth_headers, test_db +): + """Escalation handoff posts as INTERNAL_ANALYSIS (not customer-visible).""" + session = await _make_session(test_db, test_user, with_psa=True) + + fake_provider = AsyncMock() + fake_provider.post_note = AsyncMock(return_value=PSANote( + id="cw-note-esc", text="...", note_type=NoteType.INTERNAL_ANALYSIS, created_at=None, + )) + + with patch( + "app.services.psa_writeback_service.get_provider_for_connection", + return_value=fake_provider, + ): + r = await client.post( + f"/api/v1/ai-sessions/{session.id}/escalation-package/post", + headers=auth_headers, + json={"markdown": "## Problem\nx\n\n## Suggested next steps\n- try X"}, + ) + + assert r.status_code == 200 + body = r.json() + assert body["outcome"] == "escalated" + assert body["external_id"] == "cw-note-esc" + + # Handoff packages are internal — must NOT be posted with RESOLUTION or DESCRIPTION flags. + called = fake_provider.post_note.await_args.kwargs + assert called["note_type"] == NoteType.INTERNAL_ANALYSIS diff --git a/docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md b/docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md index cb576a1e..f9b602f1 100644 --- a/docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md +++ b/docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md @@ -2,8 +2,8 @@ > **Target:** Transform `/assistant` (ResolutionAssist) into the new unified `/pilot` (FlowPilot) surface. > **Audience:** Claude Code (implementation) and Codex (review) reviewed by Michael (owner). -> **Status:** Phases 0–3 implemented and verified end-to-end against the dev stack. Phase 4 next. -> **Last updated:** April 22, 2026 (Phase 3 — Suggested fix + Resolve preview — committed; live Sonnet preview + state_version cache verified) +> **Status:** Phases 0–4 implemented and verified end-to-end against the dev stack. Phase 5 next. +> **Last updated:** April 22, 2026 (Phase 4 — Resolve + Escalate writebacks — committed; local-only and mocked-PSA paths verified, live CW ticket round-trip pending a test instance) --- @@ -770,6 +770,20 @@ git commit -m "feat(pilot): add suggested fix tracking and Resolve note preview - Simulate CW silently rejecting a status change — verify the app surfaces an error, not silent success. - Attempt to Resolve without a linked PSA ticket — session marks resolved locally without erroring; markdown stored in `resolution_note_markdown`. +**Verified on 2026-04-22:** +- Local-only Resolve + Escalate confirmed end-to-end against the dev stack + (no PSA instance wired): markdown stored, session.status flips, 409 on + re-post. +- Escalation-package preview generates well-formed five-section markdown + from a single fact (real Sonnet); second preview call with no state + change returns `from_cache=true`, confirming the cache-kind separation + from resolution-note previews. +- PSA post + status-verification paths covered by mocked-provider pytest + cases: happy path, silent-rejection → 502 with clear detail, skipped + transition when `cw_resolved_status_id` unset, internal-analysis note + type used for escalation handoffs. Live CW round-trip still TODO once a + test instance is wired. + ``` git commit -m "feat(pilot): wire Resolve and Escalate to ConnectWise writeback with status verification" ``` diff --git a/frontend/src/api/sessionSuggestedFixes.ts b/frontend/src/api/sessionSuggestedFixes.ts index f26523e6..e44f9b69 100644 --- a/frontend/src/api/sessionSuggestedFixes.ts +++ b/frontend/src/api/sessionSuggestedFixes.ts @@ -36,6 +36,16 @@ export interface ResolutionNotePreview { from_cache: boolean } +export interface ResolutionPostResponse { + outcome: 'resolved' | 'escalated' | 'resolved_local' | 'escalated_local' + session_status: string + external_id: string | null + posted_at: string | null + verified_status_id: number | null + verified_status_name: string | null + status_transition_skipped_reason: string | null +} + export const sessionSuggestedFixesApi = { /** * Returns the active suggested fix for a session, or `null` if there isn't one. @@ -79,6 +89,45 @@ export const sessionSuggestedFixesApi = { ) return r.data }, + + /** + * Parallel to getResolutionNotePreview, for the Escalate flow (five-section + * handoff markdown). Separate backend cache kind, same state_version invariant. + */ + async getEscalationPackagePreview(sessionId: string): Promise<ResolutionNotePreview> { + const r = await apiClient.post<ResolutionNotePreview>( + `/ai-sessions/${sessionId}/escalation-package/preview`, + ) + return r.data + }, + + /** + * Post the engineer-edited resolution markdown to PSA + mark session resolved. + * Local-only when the session has no linked PSA ticket (outcome = "resolved_local"). + */ + async postResolutionNote( + sessionId: string, + markdown: string, + resolutionSummary?: string, + ): Promise<ResolutionPostResponse> { + const r = await apiClient.post<ResolutionPostResponse>( + `/ai-sessions/${sessionId}/resolution-note/post`, + { markdown, resolution_summary: resolutionSummary }, + ) + return r.data + }, + + async postEscalationPackage( + sessionId: string, + markdown: string, + escalationReason?: string, + ): Promise<ResolutionPostResponse> { + const r = await apiClient.post<ResolutionPostResponse>( + `/ai-sessions/${sessionId}/escalation-package/post`, + { markdown, escalation_reason: escalationReason }, + ) + return r.data + }, } export default sessionSuggestedFixesApi diff --git a/frontend/src/components/pilot/ResolutionNotePreview.tsx b/frontend/src/components/pilot/ResolutionNotePreview.tsx index bab0c2fc..600d3143 100644 --- a/frontend/src/components/pilot/ResolutionNotePreview.tsx +++ b/frontend/src/components/pilot/ResolutionNotePreview.tsx @@ -1,69 +1,112 @@ /** - * ResolutionNotePreview — Phase 3 popover anchored to the Resolve action area. + * ResolutionNotePreview — Phase 3/4 popover for the Resolve AND Escalate flows. * - * Persistent (not modal) popover showing the four-section draft markdown that - * would be posted to the customer ticket on Resolve. Per FLOWPILOT-MIGRATION.md - * Section 3.1, the engineer reviews/edits the draft inline and Confirm & post - * fires the PSA writeback (wired in Phase 4 — for now this is read-only). + * Persistent (not modal) popover showing the draft markdown that would be + * posted to the customer ticket on Confirm. Phase 3 was read-only; Phase 4 + * adds inline editing + a "Confirm & post" action that writes to PSA and + * transitions the session state. * - * Refresh policy: parent triggers `onRefresh` when state_version changes. - * Backend caches by state_version, so repeat fetches are cheap (no Sonnet - * call) when no facts/fixes/scripts have changed. + * Kind switches the labels, button colors, and confirm-CTA text — the + * underlying mechanics (preview fetch + edit + post) are identical. */ -import { useState } from 'react' -import { Loader2, RefreshCw, X, FileText } from 'lucide-react' +import { useState, useEffect } from 'react' +import { Loader2, RefreshCw, X, FileText, Pencil, Check, ArrowUpRight } from 'lucide-react' import { MarkdownContent } from '@/components/ui/MarkdownContent' +import { cn } from '@/lib/utils' import type { ResolutionNotePreview as PreviewData } from '@/api/sessionSuggestedFixes' +export type PreviewKind = 'resolve' | 'escalate' + interface ResolutionNotePreviewProps { + kind: PreviewKind open: boolean loading: boolean preview: PreviewData | null error: string | null onClose: () => void onRefresh: () => Promise<void> | void + onConfirm: (markdown: string) => Promise<void> + posting: boolean } export function ResolutionNotePreview({ + kind, open, loading, preview, error, onClose, onRefresh, + onConfirm, + posting, }: ResolutionNotePreviewProps) { const [refreshing, setRefreshing] = useState(false) + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState('') + + // Keep the draft textarea in sync whenever fresh markdown arrives and we + // aren't in the middle of editing. Once the engineer edits, their changes + // win — we don't blow them away on a refetch. + useEffect(() => { + if (!editing && preview?.markdown) { + setDraft(preview.markdown) + } + }, [preview?.markdown, editing]) if (!open) return null + const label = kind === 'resolve' ? 'Resolution note' : 'Escalation handoff package' + const confirmLabel = kind === 'resolve' ? 'Confirm & post to PSA' : 'Confirm & escalate' + const confirmButtonTone = kind === 'resolve' + ? 'bg-success text-bg-page hover:bg-success/90' + : 'bg-warning text-bg-page hover:bg-warning/90' + const KindIcon = kind === 'resolve' ? FileText : ArrowUpRight + const handleRefresh = async () => { setRefreshing(true) try { await onRefresh() } finally { setRefreshing(false) } } + const handleConfirm = async () => { + if (!draft.trim()) return + await onConfirm(draft) + } + return ( - // The popover is positioned absolutely against its anchor by the parent. - // We render full-width inside the task lane below the Resolve action bar. <div className="rounded-lg border border-default bg-elevated/30 mx-3 mb-3 overflow-hidden shadow-lg"> <div className="flex items-center justify-between px-3 py-2 border-b border-default bg-bg-page"> <div className="flex items-center gap-2"> - <FileText size={13} className="text-accent" /> + <KindIcon size={13} className={kind === 'resolve' ? 'text-success' : 'text-warning'} /> <span className="text-[0.75rem] font-semibold text-heading"> - Resolution note preview + {label} preview </span> {preview?.target_ticket_ref && ( <span className="text-[0.6875rem] font-mono text-accent-text"> → {preview.target_ticket_ref} </span> )} + {!preview?.target_ticket_ref && ( + <span className="text-[0.6875rem] italic text-muted-foreground"> + local only · no PSA ticket linked + </span> + )} {preview?.from_cache && ( <span className="text-[0.6875rem] text-muted-foreground italic">cached</span> )} </div> <div className="flex items-center gap-1"> + {!editing && preview && ( + <button + onClick={() => setEditing(true)} + className="p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors" + title="Edit before posting" + > + <Pencil size={11} /> + </button> + )} <button onClick={handleRefresh} - disabled={refreshing || loading} + disabled={refreshing || loading || editing || posting} className="p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors disabled:opacity-40" title="Refresh preview" > @@ -71,6 +114,7 @@ export function ResolutionNotePreview({ </button> <button onClick={onClose} + disabled={posting} className="p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors" title="Close preview" > @@ -83,15 +127,20 @@ export function ResolutionNotePreview({ {loading && !preview && ( <div className="flex items-center gap-2 text-[0.75rem] text-muted-foreground"> <Loader2 size={12} className="animate-spin" /> - Drafting note from session state... + Drafting {label.toLowerCase()} from session state... </div> )} - {error && ( - <div className="text-[0.75rem] text-danger">{error}</div> + {error && <div className="text-[0.75rem] text-danger">{error}</div>} + {preview && editing && ( + <textarea + value={draft} + onChange={(e) => setDraft(e.target.value)} + className="w-full rounded-md border border-default bg-input px-2.5 py-2 text-[0.8125rem] text-heading font-mono resize-y min-h-[240px] max-h-[40vh] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30" + /> )} - {preview && ( + {preview && !editing && ( <div className="prose prose-invert prose-sm max-w-none text-[0.8125rem] leading-relaxed"> - <MarkdownContent content={preview.markdown} /> + <MarkdownContent content={draft || preview.markdown} /> </div> )} {!loading && !error && !preview && ( @@ -100,6 +149,35 @@ export function ResolutionNotePreview({ </div> )} </div> + + {preview && ( + <div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-default bg-bg-page"> + {editing ? ( + <button + onClick={() => setEditing(false)} + disabled={posting} + className="text-[0.75rem] text-muted-foreground hover:text-heading" + > + Done editing + </button> + ) : <span />} + <button + onClick={handleConfirm} + disabled={posting || !draft.trim()} + className={cn( + 'flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[0.75rem] font-semibold transition-colors', + confirmButtonTone, + (posting || !draft.trim()) && 'opacity-50 cursor-not-allowed', + )} + > + {posting ? ( + <><Loader2 size={11} className="animate-spin" /> Posting...</> + ) : ( + <><Check size={11} /> {confirmLabel}</> + )} + </button> + </div> + )} </div> ) } diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index 833d9cad..45be4508 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -87,16 +87,19 @@ export default function AssistantChatPage() { // selectChat and after each chat send (the AI may have emitted [PROMOTE] // markers that synthesized new facts server-side). const [facts, setFacts] = useState<SessionFact[]>([]) - // Phase 3: active suggested fix + resolution-note preview state. + // Phase 3: active suggested fix; Phase 4 extends the preview popover to + // support both Resolve and Escalate (kind-parameterized, one active at a time). const [activeFix, setActiveFix] = useState<SessionSuggestedFix | null>(null) - const [previewOpen, setPreviewOpen] = useState(false) + const [previewKind, setPreviewKind] = useState<'resolve' | 'escalate' | null>(null) const [previewData, setPreviewData] = useState<ResolutionNotePreviewData | null>(null) const [previewLoading, setPreviewLoading] = useState(false) const [previewError, setPreviewError] = useState<string | null>(null) + const [previewPosting, setPreviewPosting] = useState(false) // Debounce timer for preview refresh — Phase 3 spec calls for 500ms client- // side debounce so rapid edits don't fan out to the LLM (cache absorbs the // dups, but the request itself still costs HTTP RTT). const previewDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null) + const previewOpen = previewKind !== null const [showOverflow, setShowOverflow] = useState(false) const toggleSidebarCollapse = () => { const next = !sidebarCollapsed @@ -281,11 +284,18 @@ export default function AssistantChatPage() { } }, []) - const refreshPreview = useCallback(async (chatId: string) => { + // Kind-aware preview fetch: Resolve hits /resolution-note/preview, + // Escalate hits /escalation-package/preview. They're cached separately + // on the backend, so switching kinds never returns stale markdown. + const refreshPreview = useCallback(async (chatId: string, kind?: 'resolve' | 'escalate') => { + const effectiveKind = kind ?? previewKind + if (!effectiveKind) return setPreviewLoading(true) setPreviewError(null) try { - const p = await sessionSuggestedFixesApi.getResolutionNotePreview(chatId) + const p = effectiveKind === 'resolve' + ? await sessionSuggestedFixesApi.getResolutionNotePreview(chatId) + : await sessionSuggestedFixesApi.getEscalationPackagePreview(chatId) if (currentChatRef.current !== chatId) return setPreviewData(p) } catch (err: unknown) { @@ -298,7 +308,7 @@ export default function AssistantChatPage() { } finally { setPreviewLoading(false) } - }, []) + }, [previewKind]) // Trigger preview refresh with a 500ms debounce. The backend cache short- // circuits same-state calls, but the network round-trip is still avoidable @@ -365,13 +375,61 @@ export default function AssistantChatPage() { } } - const handleTogglePreview = () => { + const handleOpenPreview = (kind: 'resolve' | 'escalate') => { if (!activeChatId) return - const next = !previewOpen - setPreviewOpen(next) - if (next && !previewData) { - // First open — fetch immediately, no debounce. - refreshPreview(activeChatId) + // Opening a different kind clobbers the cached markdown so the popover + // doesn't flash stale content while the new kind fetches. + if (previewKind !== kind) setPreviewData(null) + setPreviewKind(kind) + setPreviewError(null) + refreshPreview(activeChatId, kind) + } + + const handleClosePreview = () => { + setPreviewKind(null) + setPreviewError(null) + } + + const handleConfirmPost = async (markdown: string) => { + if (!activeChatId || !previewKind) return + setPreviewPosting(true) + try { + const out = previewKind === 'resolve' + ? await sessionSuggestedFixesApi.postResolutionNote(activeChatId, markdown) + : await sessionSuggestedFixesApi.postEscalationPackage(activeChatId, markdown) + setActiveSessionStatus(out.session_status) + + if (out.outcome === 'resolved') { + toast.success( + out.verified_status_id + ? `Posted to ${previewData?.target_ticket_ref ?? 'PSA'} · status ${out.verified_status_name}` + : `Posted to ${previewData?.target_ticket_ref ?? 'PSA'}${out.status_transition_skipped_reason ? ' · status unchanged' : ''}`, + ) + } else if (out.outcome === 'escalated') { + toast.success( + out.verified_status_id + ? `Escalated · ${previewData?.target_ticket_ref ?? 'PSA'} status ${out.verified_status_name}` + : `Escalated · handoff posted to ${previewData?.target_ticket_ref ?? 'PSA'}`, + ) + } else if (out.outcome === 'resolved_local') { + toast.success('Session resolved locally (no PSA ticket linked)') + } else if (out.outcome === 'escalated_local') { + toast.success('Session escalated locally (no PSA ticket linked)') + } + handleClosePreview() + } catch (err: unknown) { + console.error('[AssistantChat] confirm post failed:', err) + const status = (err as { response?: { status?: number }; response?: { data?: { detail?: string } } })?.response?.status + const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail + if (status === 502) { + toast.error(detail || 'PSA posted partially — see server logs.') + } else if (status === 409) { + toast.warning(detail || 'Session is already in that state.') + } else { + toast.error('Could not post. Please try again.') + } + } finally { + setPreviewPosting(false) } } @@ -388,7 +446,7 @@ export default function AssistantChatPage() { setActiveFix(null) setPreviewData(null) setPreviewError(null) - setPreviewOpen(false) + setPreviewKind(null) // Fire facts + active-fix fetches in parallel with session detail. refreshSessionDerived(chatId) try { @@ -1207,20 +1265,42 @@ export default function AssistantChatPage() { } bottomSlot={ <> - <button - onClick={handleTogglePreview} - className="flex items-center gap-1.5 text-[0.75rem] font-medium text-accent-text hover:text-heading transition-colors px-3 mt-1" - > - <FileText size={12} /> - {previewOpen ? 'Hide' : 'Preview'} Resolve note - </button> + <div className="flex items-center gap-3 px-3 mt-1"> + <button + onClick={() => handleOpenPreview('resolve')} + className={cn( + 'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors', + previewKind === 'resolve' + ? 'text-success' + : 'text-accent-text hover:text-heading', + )} + > + <FileText size={12} /> + {previewKind === 'resolve' ? 'Showing' : 'Preview'} Resolve note + </button> + <button + onClick={() => handleOpenPreview('escalate')} + className={cn( + 'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors', + previewKind === 'escalate' + ? 'text-warning' + : 'text-muted-foreground hover:text-heading', + )} + > + <ArrowUpRight size={12} /> + {previewKind === 'escalate' ? 'Showing' : 'Escalate instead'} + </button> + </div> <ResolutionNotePreviewPopover + kind={previewKind ?? 'resolve'} open={previewOpen} loading={previewLoading} preview={previewData} error={previewError} - onClose={() => setPreviewOpen(false)} + onClose={handleClosePreview} onRefresh={() => activeChatId && refreshPreview(activeChatId)} + onConfirm={handleConfirmPost} + posting={previewPosting} /> </> }