feat(pilot): Phase 4 — Resolve + Escalate PSA writebacks with status verification
All checks were successful
Mirror to GitHub / mirror (push) Successful in 11s

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 23:54:54 -04:00
parent 7ccf4c602b
commit 8fd2c1bac6
10 changed files with 1337 additions and 46 deletions

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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}
/>
</>
}