All checks were successful
Mirror to GitHub / mirror (push) Successful in 10s
Wires the SuggestedFix card to an inline panel that handles both cases:
template-matched fixes open the Script Library generator with parameters
pre-filled from session context; un-matched fixes open the three-option
dialog (one_off / draft_template / build_template). The decision endpoint
records the path choice with side effects: draft_template persists a
draft_templates row via a Sonnet-driven TemplateExtractionService;
build_template returns a redirect to the Script Builder; one_off just
records the choice.
Backend:
- TemplateExtractionService: drafts a parameter schema from a concrete
rendered script. Conservative by default ("prefer fewer parameters").
Round-trip-validates that templated_body only references declared
parameters; missing-key mismatch falls back to the original script
with no params. LLM/parse failures fall back identically — the
engineer can still create a draft and refine in the post-resolve
prompt (Phase 6).
- /suggested-fixes/{fix_id}/decision side effects:
* one_off → returns rendered_script (engineer's edited version or the
fix's ai_drafted_script verbatim)
* draft_template → same + creates draft_templates row with extracted
params, returns draft_template_id
* build_template → returns redirect_path=/scripts/builder?from_session=
&fix= so the frontend can navigate to the builder pre-loaded
- 400 when a non-template fix has no ai_drafted_script (template-matched
fixes take the dedicated /scripts/generate path, not this endpoint).
- 12 tests: TemplateExtractionService parse + fallback paths, all four
decision branches, edited_script override, missing-script 400.
Frontend:
- src/components/pilot/script/{TemplateMatchPanel, NoTemplateDialog,
ParameterizationPreview}.tsx — inline panels rendered in the task
lane's bottom slot when the engineer clicks a SuggestedFix card.
- TemplateMatchPanel: loads template via /scripts/templates/{id},
pre-fills params from fix.ai_drafted_parameters with cyan "from
session" tags, generates via existing /scripts/generate (already
bumps state_version on ai_session_id from Phase 3). 404 falls back
with a clear message instead of erroring.
- NoTemplateDialog: shows the AI-drafted script with proposed parameter
values highlighted in amber via ParameterizationPreview; three option
cards with the middle (draft_template) flagged Recommended; inline
edit on the script body before deciding.
- SuggestedFix card now clickable: onActivate toggles the inline panel.
- AssistantChatPage: scriptPanelOpen state + handleScriptDecision that
navigates on build_template and toasts on the other paths. Active fix
changes auto-close the panel so engineers don't act on stale state.
- Cmd+K → "Open inline Script Generator" palette entry surfaces only on
/pilot/:id routes; fires a window event the chat page subscribes to.
No Resolve shortcut added per Section 14 decision (browser ⌘R conflict).
Verified 2026-04-22 against the dev stack:
- one_off / draft_template / build_template all return the right shape
with real Sonnet TemplateExtractionService for the draft path.
- Conservative extraction confirmed: cmdkey + Restart-Process script
yielded zero proposed parameters as intended.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
278 lines
10 KiB
Python
278 lines
10 KiB
Python
"""API + service tests for Phase 5 inline Script Generator integration.
|
|
|
|
Covers:
|
|
- TemplateExtractionService: well-formed, fallback on bad output, missing-key fallback.
|
|
- /suggested-fixes/{fix_id}/decision side effects:
|
|
* one_off returns rendered_script, no draft_templates row.
|
|
* draft_template returns rendered_script + draft_template_id, draft persisted.
|
|
* build_template returns redirect_path.
|
|
* dismissed (Phase 3) still works.
|
|
- 400 when ai_drafted_script is missing for a non-template fix.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
from sqlalchemy import select
|
|
|
|
from app.api.endpoints.session_suggested_fixes import _clear_preview_cache_for_tests
|
|
from app.models.ai_session import AISession
|
|
from app.models.draft_template import DraftTemplate
|
|
from app.models.session_suggested_fix import SessionSuggestedFix
|
|
from app.services.template_extraction_service import (
|
|
_fallback,
|
|
_parse_response,
|
|
extract_parameters,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_preview_cache():
|
|
_clear_preview_cache_for_tests()
|
|
yield
|
|
_clear_preview_cache_for_tests()
|
|
|
|
|
|
async def _make_session_with_fix(
|
|
test_db, user, *, with_template_id: bool = False, with_drafted_script: bool = True,
|
|
) -> tuple[AISession, SessionSuggestedFix]:
|
|
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": "phase 5 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="Reset cached creds",
|
|
description="Clearing the cached credential...",
|
|
confidence_pct=85,
|
|
ai_drafted_script=(
|
|
'cmdkey /delete:"outlook.office365.com"\n'
|
|
'Restart-Process -Name OUTLOOK'
|
|
) if with_drafted_script else None,
|
|
ai_drafted_parameters={"target_user": "jsmith"} if with_drafted_script else None,
|
|
)
|
|
test_db.add(fix)
|
|
await test_db.commit()
|
|
await test_db.refresh(session)
|
|
await test_db.refresh(fix)
|
|
return session, fix
|
|
|
|
|
|
# ── TemplateExtractionService: parse + fallback ───────────────────────────
|
|
|
|
class TestParseResponse:
|
|
def test_well_formed(self):
|
|
raw = (
|
|
'{"parameters": [{"key":"host","label":"Host","type":"text",'
|
|
'"inferred_from":"session fact"}],'
|
|
'"templated_body":"Get-Service -ComputerName {{ host }}"}'
|
|
)
|
|
result = _parse_response(raw)
|
|
assert result is not None
|
|
assert len(result["parameters"]) == 1
|
|
assert result["parameters"][0]["key"] == "host"
|
|
assert result["templated_body"].endswith("{{ host }}")
|
|
|
|
def test_strips_fences(self):
|
|
raw = '```json\n{"parameters": [], "templated_body": "x"}\n```'
|
|
result = _parse_response(raw)
|
|
assert result is not None and result["parameters"] == []
|
|
|
|
def test_invalid_key_dropped(self):
|
|
# Capital letters and dashes in key names violate snake_case — drop.
|
|
raw = (
|
|
'{"parameters":[{"key":"BadKey-Name","type":"text"}],'
|
|
'"templated_body":"x"}'
|
|
)
|
|
result = _parse_response(raw)
|
|
assert result is not None and result["parameters"] == []
|
|
|
|
def test_unknown_type_falls_back_to_text(self):
|
|
raw = (
|
|
'{"parameters":[{"key":"x","type":"weird"}],"templated_body":"x"}'
|
|
)
|
|
result = _parse_response(raw)
|
|
assert result is not None and result["parameters"][0]["type"] == "text"
|
|
|
|
def test_malformed_json_returns_none(self):
|
|
assert _parse_response("not json") is None
|
|
|
|
def test_non_dict_returns_none(self):
|
|
assert _parse_response('["a","b"]') is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_parameters_round_trip_failure_uses_fallback():
|
|
"""Templated_body referencing an undeclared placeholder triggers fallback."""
|
|
fake_provider = AsyncMock()
|
|
fake_provider.generate_json = AsyncMock(return_value=(
|
|
# Declares parameter `host` but the body references `port` too.
|
|
'{"parameters":[{"key":"host","label":"Host","type":"text"}],'
|
|
'"templated_body":"Get-Service -ComputerName {{ host }} -Port {{ port }}"}',
|
|
100, 50,
|
|
))
|
|
with patch(
|
|
"app.services.template_extraction_service.get_ai_provider",
|
|
return_value=fake_provider,
|
|
):
|
|
result = await extract_parameters(
|
|
script_body="Get-Service -ComputerName srv01 -Port 8080",
|
|
)
|
|
fb = _fallback("Get-Service -ComputerName srv01 -Port 8080")
|
|
assert result == fb
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_parameters_llm_exception_uses_fallback():
|
|
fake_provider = AsyncMock()
|
|
fake_provider.generate_json = AsyncMock(side_effect=RuntimeError("boom"))
|
|
with patch(
|
|
"app.services.template_extraction_service.get_ai_provider",
|
|
return_value=fake_provider,
|
|
):
|
|
result = await extract_parameters(script_body="echo hello")
|
|
assert result == _fallback("echo hello")
|
|
|
|
|
|
# ── Decision endpoint: one_off ─────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_one_off_returns_rendered_script_no_draft(
|
|
client: AsyncClient, test_user, auth_headers, test_db
|
|
):
|
|
session, fix = await _make_session_with_fix(test_db, test_user)
|
|
r = await client.post(
|
|
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
|
|
headers=auth_headers,
|
|
json={"decision": "one_off"},
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["user_decision"] == "one_off"
|
|
assert body["rendered_script"] is not None
|
|
assert "cmdkey" in body["rendered_script"]
|
|
assert body["draft_template_id"] is None
|
|
assert body["redirect_path"] is None
|
|
|
|
# No draft_templates row should have been created.
|
|
rows = (
|
|
await test_db.execute(select(DraftTemplate).where(DraftTemplate.source_session_id == session.id))
|
|
).scalars().all()
|
|
assert list(rows) == []
|
|
|
|
|
|
# ── Decision endpoint: draft_template ─────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_draft_template_creates_draft_with_extracted_params(
|
|
client: AsyncClient, test_user, auth_headers, test_db
|
|
):
|
|
session, fix = await _make_session_with_fix(test_db, test_user)
|
|
|
|
fake_provider = AsyncMock()
|
|
fake_provider.generate_json = AsyncMock(return_value=(
|
|
'{"parameters":[{"key":"target_user","label":"Target User","type":"text",'
|
|
'"inferred_from":"session fact"}],'
|
|
'"templated_body":"cmdkey /delete:\\"outlook.office365.com\\"\\n'
|
|
'Restart-Process -Name OUTLOOK"}',
|
|
80, 60,
|
|
))
|
|
|
|
with patch(
|
|
"app.services.template_extraction_service.get_ai_provider",
|
|
return_value=fake_provider,
|
|
):
|
|
r = await client.post(
|
|
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
|
|
headers=auth_headers,
|
|
json={"decision": "draft_template"},
|
|
)
|
|
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["user_decision"] == "draft_template"
|
|
assert body["rendered_script"] is not None
|
|
assert body["draft_template_id"] is not None
|
|
assert body["redirect_path"] is None
|
|
|
|
drafts = (
|
|
await test_db.execute(select(DraftTemplate).where(DraftTemplate.source_session_id == session.id))
|
|
).scalars().all()
|
|
drafts = list(drafts)
|
|
assert len(drafts) == 1
|
|
draft = drafts[0]
|
|
assert draft.status == "pending"
|
|
assert draft.proposed_name == fix.title
|
|
proposed = draft.proposed_parameters.get("parameters") if isinstance(draft.proposed_parameters, dict) else None
|
|
assert isinstance(proposed, list) and len(proposed) == 1
|
|
assert proposed[0]["key"] == "target_user"
|
|
|
|
|
|
# ── Decision endpoint: build_template ─────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_template_returns_redirect_path(
|
|
client: AsyncClient, test_user, auth_headers, test_db
|
|
):
|
|
session, fix = await _make_session_with_fix(test_db, test_user)
|
|
r = await client.post(
|
|
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
|
|
headers=auth_headers,
|
|
json={"decision": "build_template"},
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["redirect_path"] is not None
|
|
assert str(session.id) in body["redirect_path"]
|
|
assert str(fix.id) in body["redirect_path"]
|
|
|
|
|
|
# ── Decision endpoint: 400 when no drafted script ─────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_one_off_without_drafted_script_returns_400(
|
|
client: AsyncClient, test_user, auth_headers, test_db
|
|
):
|
|
"""A template-matched fix takes the dedicated /scripts/generate path; trying
|
|
to one_off it via this endpoint without an ai_drafted_script must surface
|
|
a clear client-error, not silently render nothing."""
|
|
session, fix = await _make_session_with_fix(test_db, test_user, with_drafted_script=False)
|
|
r = await client.post(
|
|
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
|
|
headers=auth_headers,
|
|
json={"decision": "one_off"},
|
|
)
|
|
assert r.status_code == 400
|
|
assert "ai_drafted_script" in r.json()["detail"]
|
|
|
|
|
|
# ── Decision endpoint: edited script overrides ai_drafted_script ──────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edited_script_overrides_ai_drafted(
|
|
client: AsyncClient, test_user, auth_headers, test_db
|
|
):
|
|
session, fix = await _make_session_with_fix(test_db, test_user)
|
|
r = await client.post(
|
|
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
|
|
headers=auth_headers,
|
|
json={
|
|
"decision": "one_off",
|
|
"edited_script": "Get-Service -Name Dnscache",
|
|
},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["rendered_script"] == "Get-Service -Name Dnscache"
|