From 70c5da0c75fe300581ef60d2257cff055a38c913 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 23 Apr 2026 22:15:48 -0400 Subject: [PATCH] fix(pilot): persist AI-proposal rejection + clear on outcome write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #3 from phase-8-review-issues.md. 'Not yet' on the AI-confirming banner was a local-state hide; the proposal re-surfaced on the next refreshSessionDerived call. Two-part fix: - PATCH /outcome now clears ai_outcome_proposal on any terminal action (engineer has taken a decision; stale AI proposal is moot). - New DELETE /ai-sessions/:sid/suggested-fixes/:fid/ai-outcome-proposal endpoint for explicit 'Not yet' rejection. Does not touch status or state_version — pure UI state. Frontend handleRejectAIProposal now calls the DELETE and setActiveFix with the server response. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/endpoints/session_suggested_fixes.py | 45 +++++++++ backend/tests/test_fix_outcome_endpoint.py | 97 +++++++++++++++++++ frontend/src/api/sessionSuggestedFixes.ts | 12 +++ frontend/src/pages/AssistantChatPage.tsx | 20 ++-- 4 files changed, 168 insertions(+), 6 deletions(-) diff --git a/backend/app/api/endpoints/session_suggested_fixes.py b/backend/app/api/endpoints/session_suggested_fixes.py index 769cd663..882263e2 100644 --- a/backend/app/api/endpoints/session_suggested_fixes.py +++ b/backend/app/api/endpoints/session_suggested_fixes.py @@ -338,6 +338,9 @@ async def patch_suggested_fix_outcome( if fix.applied_at is None and body.outcome != "dismissed": fix.applied_at = now + # Clear any pending AI outcome proposal — engineer has taken a terminal action. + fix.ai_outcome_proposal = None + # Outcome changes the bundle that resolution-note/escalation-package # previews see, so bump state_version inside the same transaction — # mirrors the pattern in record_decision above. @@ -352,6 +355,48 @@ async def patch_suggested_fix_outcome( return SessionSuggestedFixResponse.model_validate(fix) +# ── Suggested fix: clear AI outcome proposal ("Not yet") ───────────────────── + +@router.delete( + "/suggested-fixes/{fix_id}/ai-outcome-proposal", + response_model=SessionSuggestedFixResponse, +) +async def clear_ai_outcome_proposal( + session_id: UUID, + fix_id: UUID, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], + _: None = Depends(require_engineer_or_admin), +) -> SessionSuggestedFixResponse: + """Explicitly dismiss the AI-proposed outcome banner ("Not yet"). + + Clears `ai_outcome_proposal` without touching status or state_version + (this is pure UI state, not outcome data). Idempotent: returns 200 even + when the field is already null. After this call the banner will not + re-surface on the next refreshSessionDerived unless the AI emits a new + proposal. + """ + await _load_session_or_404(db, session_id) + + result = await db.execute( + select(SessionSuggestedFix).where( + SessionSuggestedFix.id == fix_id, + SessionSuggestedFix.session_id == session_id, + ) + ) + fix = result.scalar_one_or_none() + if fix is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found" + ) + + fix.ai_outcome_proposal = None + + await db.commit() + await db.refresh(fix) + return SessionSuggestedFixResponse.model_validate(fix) + + async def _summarize_session_for_extraction( db: AsyncSession, session_id: UUID, ) -> str: diff --git a/backend/tests/test_fix_outcome_endpoint.py b/backend/tests/test_fix_outcome_endpoint.py index 89a4fb15..432a5c22 100644 --- a/backend/tests/test_fix_outcome_endpoint.py +++ b/backend/tests/test_fix_outcome_endpoint.py @@ -437,3 +437,100 @@ async def test_apply_rejects_dismissed( headers=auth_headers, ) assert r.status_code == 409, r.text + + +# ── AI outcome proposal: clear / reject ─────────────────────────────────────── + +async def _make_session_with_fix_and_proposal(test_db, user) -> tuple[str, str]: + """Create an AISession + fix with a populated ai_outcome_proposal.""" + from uuid import UUID as _UUID + session = AISession( + user_id=user["user_data"]["id"], + account_id=user["user_data"]["account_id"], + session_type="chat", + intake_type="free_text", + intake_content={"text": "proposal clear test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[], + ) + test_db.add(session) + await test_db.flush() + + fix = SessionSuggestedFix( + session_id=session.id, + account_id=session.account_id, + title="Flush DNS cache", + description="Run ipconfig /flushdns on the affected host.", + confidence_pct=74, + ai_outcome_proposal={"fix_id": str(session.id), "outcome": "success", "reason": "User confirmed resolved"}, + ) + test_db.add(fix) + await test_db.commit() + await test_db.refresh(fix) + + return str(session.id), str(fix.id) + + +@pytest.mark.asyncio +async def test_outcome_patch_clears_ai_proposal( + client: AsyncClient, test_user, auth_headers, test_db +): + """PATCH /outcome clears ai_outcome_proposal regardless of which outcome is written.""" + session_id, fix_id = await _make_session_with_fix_and_proposal(test_db, test_user) + + # Verify the proposal is set before the patch. + from uuid import UUID + result = await test_db.execute( + select(SessionSuggestedFix).where(SessionSuggestedFix.id == UUID(fix_id)) + ) + fix_before = result.scalar_one() + assert fix_before.ai_outcome_proposal is not None + + r = await client.patch( + f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome", + headers=auth_headers, + json={"outcome": "applied_success"}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["ai_outcome_proposal"] is None, ( + "PATCH /outcome must clear ai_outcome_proposal on any terminal action" + ) + + +@pytest.mark.asyncio +async def test_delete_ai_proposal_clears_field( + client: AsyncClient, test_user, auth_headers, test_db +): + """DELETE /ai-outcome-proposal clears the field without changing status.""" + session_id, fix_id = await _make_session_with_fix_and_proposal(test_db, test_user) + + r = await client.delete( + f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/ai-outcome-proposal", + headers=auth_headers, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["ai_outcome_proposal"] is None, ( + "DELETE /ai-outcome-proposal must clear the field" + ) + assert body["status"] == "proposed", ( + "DELETE /ai-outcome-proposal must not change fix status" + ) + + +@pytest.mark.asyncio +async def test_delete_ai_proposal_when_none_is_idempotent( + client: AsyncClient, test_user, auth_headers, test_db +): + """DELETE /ai-outcome-proposal returns 200 even when the field is already null.""" + session_id, fix_id = await _make_session_with_fix(test_db, test_user) + + # Fix created by _make_session_with_fix has ai_outcome_proposal=None. + r = await client.delete( + f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/ai-outcome-proposal", + headers=auth_headers, + ) + assert r.status_code == 200, r.text + assert r.json()["ai_outcome_proposal"] is None diff --git a/frontend/src/api/sessionSuggestedFixes.ts b/frontend/src/api/sessionSuggestedFixes.ts index fbbf654c..288c53eb 100644 --- a/frontend/src/api/sessionSuggestedFixes.ts +++ b/frontend/src/api/sessionSuggestedFixes.ts @@ -196,6 +196,18 @@ export const sessionSuggestedFixesApi = { ) return r.data }, + + /** + * Explicitly dismiss the AI-proposed outcome banner ("Not yet"). + * Clears ai_outcome_proposal on the server without touching status or + * state_version. Idempotent: returns 200 even when the field is already null. + */ + async clearAIProposal(sessionId: string, fixId: string): Promise { + const r = await apiClient.delete( + `/ai-sessions/${sessionId}/suggested-fixes/${fixId}/ai-outcome-proposal`, + ) + return r.data + }, } export default sessionSuggestedFixesApi diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index ff7bb2ea..b837aa8d 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -572,12 +572,20 @@ export default function AssistantChatPage() { await handleSetOutcome(fixOutcome, notes) }, [activeFix, handleSetOutcome]) - // Phase 8: reject the AI proposal — clear it locally (client-side only for v1; - // the proposal will re-surface on next server fetch but that's acceptable). - const handleRejectAIProposal = useCallback(() => { - if (!activeFix) return - setActiveFix({ ...activeFix, ai_outcome_proposal: null }) - }, [activeFix]) + // Phase 8: reject the AI proposal — persist the rejection to the server so + // the banner does not re-surface on the next refreshSessionDerived call. + // Falls back to a local-state clear on error (non-fatal: banner may re-arm + // on the next refetch, matching the previous behaviour). + const handleRejectAIProposal = useCallback(async () => { + if (!activeFix || !activeChatId) return + try { + const updated = await sessionSuggestedFixesApi.clearAIProposal(activeChatId, activeFix.id) + setActiveFix(updated) + } catch { + // Non-fatal fallback: clear locally so the banner disappears immediately. + setActiveFix({ ...activeFix, ai_outcome_proposal: null }) + } + }, [activeFix, activeChatId]) // Phase 8: silence the nudge banner without recording an outcome. const handleSilenceNudge = useCallback(() => {