fix(pilot): persist AI-proposal rejection + clear on outcome write

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 22:15:48 -04:00
parent de2bef3175
commit 70c5da0c75
4 changed files with 168 additions and 6 deletions

View File

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

View File

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

View File

@@ -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<SessionSuggestedFix> {
const r = await apiClient.delete<SessionSuggestedFix>(
`/ai-sessions/${sessionId}/suggested-fixes/${fixId}/ai-outcome-proposal`,
)
return r.data
},
}
export default sessionSuggestedFixesApi

View File

@@ -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(() => {