feat(pilot): Phase 4 — Resolve + Escalate PSA writebacks with status verification
All checks were successful
Mirror to GitHub / mirror (push) Successful in 11s
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:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user