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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user