mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
2640 lines
115 KiB
Python
2640 lines
115 KiB
Python
"""Tests for enforcement pruning and null-data handling.
|
|
|
|
These cover three regressions observed in trace 019d7b5c884dff0ff648680b9f31f715:
|
|
1. Extraction returning all-null fields was treated as success.
|
|
2. Context grew linearly because old tool outputs kept full content.
|
|
3. No escalation when the agent looped on the same null-data failure.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from agents import RunConfig
|
|
|
|
from skyvern.config import settings
|
|
from skyvern.forge.sdk.copilot.blocker_signal import (
|
|
UNCOVERED_OUTPUT_RESCOUT_STEER_REASON_CODE,
|
|
CopilotToolBlockerSignal,
|
|
stash_blocker_signal,
|
|
)
|
|
from skyvern.forge.sdk.copilot.build_test_outcome import (
|
|
RecordedBuildTestOutcome,
|
|
author_time_reject_missing_output_paths,
|
|
recorded_outcome_from_author_time_reject,
|
|
)
|
|
from skyvern.forge.sdk.copilot.code_block_synthesis import SynthesizedCodeBlock
|
|
from skyvern.forge.sdk.copilot.completion_verification import CompletionVerificationResult, CriterionVerdict
|
|
from skyvern.forge.sdk.copilot.config import SYNTHESIZED_OFFER_REFRESH_STEP_THRESHOLD, BlockAuthoringPolicy
|
|
from skyvern.forge.sdk.copilot.context import CodeAuthoringRepairContext
|
|
from skyvern.forge.sdk.copilot.enforcement import (
|
|
KEEP_RECENT_TOOL_OUTPUTS,
|
|
SYNTHESIZED_BLOCK_PERSISTENCE_REASON_CODE,
|
|
CopilotGoalSatisfied,
|
|
_canonical_output_path,
|
|
_check_enforcement,
|
|
_maybe_synthesized_block_offer_msg,
|
|
_needs_suspicious_success_nudge,
|
|
_prune_input_list,
|
|
_should_block_mutating_tool_after_synthesized_offer,
|
|
_should_force_advisory_run_dispatch,
|
|
_should_force_synthesized_block_persistence,
|
|
_summarize_tool_output,
|
|
_uncovered_output_reject_admits_evaluate,
|
|
consume_uncovered_output_reopen_event,
|
|
record_scouted_output_coverage,
|
|
run_with_enforcement,
|
|
synthesized_block_persistence_signal,
|
|
synthesized_persistence_reopened,
|
|
synthesized_persistence_reopened_after_failed_run,
|
|
synthesized_trajectory_is_goal_complete,
|
|
uncovered_output_reject_scout_steer_signal,
|
|
uncovered_requested_output_paths,
|
|
)
|
|
from skyvern.forge.sdk.copilot.mcp_adapter import (
|
|
_POST_HOOK_CONTEXT_ROLLBACK_FIELDS,
|
|
_restore_post_hook_context,
|
|
_snapshot_post_hook_context,
|
|
)
|
|
from skyvern.forge.sdk.copilot.output_contracts import (
|
|
OUTPUT_SOURCE_UNOBSERVABLE_REASON_CODE,
|
|
OutputContractAdvisoryState,
|
|
)
|
|
from skyvern.forge.sdk.copilot.reached_download_target import ReachedDownloadTarget
|
|
from skyvern.forge.sdk.copilot.request_policy import CompletionCriterion, RequestPolicy
|
|
from skyvern.forge.sdk.copilot.streaming_adapter import _update_enforcement_from_tool
|
|
from skyvern.forge.sdk.copilot.tools import (
|
|
_INTERNAL_RUN_CANCELLED_BY_WATCHDOG_KEY,
|
|
_analyze_run_blocks,
|
|
_is_meaningful_extracted_data,
|
|
_record_run_blocks_result,
|
|
_record_workflow_update_result,
|
|
)
|
|
from skyvern.forge.sdk.copilot.turn_halt import stash_turn_halt_from_blocker_signal
|
|
from skyvern.forge.sdk.copilot.turn_intent import RequiredContextKey, TurnIntent, TurnIntentAuthority, TurnIntentMode
|
|
from skyvern.forge.sdk.copilot.verification_evidence import WorkflowVerificationEvidence
|
|
from tests.unit.conftest import make_copilot_context
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _disable_author_time_gate_log_only(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(settings, "WORKFLOW_COPILOT_AUTHOR_TIME_GATE_LOG_ONLY", False)
|
|
|
|
|
|
class _Ctx:
|
|
"""Minimal stand-in for CopilotContext used in enforcement checks.
|
|
|
|
Keep this in sync with ``AgentContext`` enforcement-state fields — missing
|
|
attributes would show up as AttributeError in the branches that use bare
|
|
access rather than ``getattr``.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.navigate_called = False
|
|
self.observation_after_navigate = False
|
|
self.navigate_enforcement_done = False
|
|
self.update_workflow_called = False
|
|
self.test_after_update_done = False
|
|
self.post_update_nudge_count = 0
|
|
self.coverage_nudge_count = 0
|
|
self.format_nudge_count = 0
|
|
self.user_message = ""
|
|
self.last_update_block_count = None
|
|
self.last_test_ok = None
|
|
self.last_test_failure_reason = None
|
|
self.last_test_suspicious_success = False
|
|
self.last_test_anti_bot = None
|
|
self.last_failure_category_top = None
|
|
self.failed_test_nudge_count = 0
|
|
self.explore_without_workflow_nudge_count = 0
|
|
self.repeated_failure_streak_count = 0
|
|
self.repeated_failure_nudge_emitted_at_streak = 0
|
|
self.verified_terminal_proposal_ready = False
|
|
self.completion_verification_result = None
|
|
self.last_artifact_health_blocker_reason = None
|
|
self.latest_diagnosis_repair_contract = None
|
|
self.last_code_authoring_repair_context = None
|
|
self.synthesized_block_reopened_after_failed_run = False
|
|
self.synthesized_block_reopened_for_output_coverage = False
|
|
self.scouted_output_covered_paths: set[str] = set()
|
|
self.uncovered_output_rescout_context_key = None
|
|
self.uncovered_output_rescout_steer_key = None
|
|
self.latest_recorded_build_test_outcome = None
|
|
self.last_run_blocks_workflow_run_id = None
|
|
self.completion_criteria_turn_state = None
|
|
self.reached_download_target: ReachedDownloadTarget | None = None
|
|
self.author_time_gate_ablation_events = []
|
|
self.request_policy = None
|
|
|
|
|
|
class TestSynthesizedOfferPersistenceGate:
|
|
@staticmethod
|
|
def _unsatisfied_verification() -> CompletionVerificationResult:
|
|
return CompletionVerificationResult(
|
|
status="evaluated",
|
|
criterion_ids=["fallback"],
|
|
verdicts=[
|
|
CriterionVerdict(
|
|
criterion_id="fallback",
|
|
state="unsatisfied",
|
|
reason_code="evidence_contradicts",
|
|
)
|
|
],
|
|
)
|
|
|
|
def _authoring_ctx(
|
|
self,
|
|
*,
|
|
trajectory: list[dict[str, object]],
|
|
download_target: ReachedDownloadTarget | None,
|
|
) -> _Ctx:
|
|
ctx = _Ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=True),
|
|
)
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = len(trajectory)
|
|
ctx.scout_trajectory = trajectory
|
|
ctx.reached_download_target = download_target
|
|
ctx.synthesized_block_offered_goal_complete = synthesized_trajectory_is_goal_complete(ctx)
|
|
return ctx
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_offer_retry_forces_update_and_run_blocks_tool_choice(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
ctx = _Ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=True),
|
|
)
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
ctx.scout_trajectory = [
|
|
{
|
|
"tool_name": "type_text",
|
|
"selector": "input[name='q']",
|
|
"source_url": "https://example.test/start",
|
|
"accessible_name": "Search",
|
|
},
|
|
{
|
|
"tool_name": "click",
|
|
"selector": "button[data-action='search']",
|
|
"accessible_name": "Search",
|
|
},
|
|
]
|
|
ctx.synthesized_block_offered = False
|
|
ctx.synthesized_block_offered_trajectory_len = 0
|
|
ctx.reached_download_target = None
|
|
stream = MagicMock()
|
|
stream.is_disconnected = AsyncMock(return_value=False)
|
|
|
|
fake_result = MagicMock()
|
|
fake_result.final_output = None
|
|
fake_result.new_items = []
|
|
fake_result.to_input_list.return_value = []
|
|
run_configs: list[RunConfig | None] = []
|
|
|
|
def fake_run_streamed(*args: Any, **kwargs: Any) -> Any:
|
|
run_configs.append(kwargs.get("run_config"))
|
|
return fake_result
|
|
|
|
async def fake_stream_to_sse(result: Any, s: Any, c: Any) -> None:
|
|
if len(run_configs) >= 2:
|
|
c.update_workflow_called = True
|
|
c.test_after_update_done = True
|
|
|
|
monkeypatch.setattr(
|
|
"skyvern.forge.sdk.copilot.enforcement.synthesize_code_block",
|
|
lambda *args, **kwargs: SynthesizedCodeBlock(code="await page.click('button')"),
|
|
)
|
|
monkeypatch.setattr("skyvern.forge.sdk.copilot.enforcement.Runner.run_streamed", fake_run_streamed)
|
|
monkeypatch.setattr(
|
|
"skyvern.forge.sdk.copilot.streaming_adapter.stream_to_sse",
|
|
fake_stream_to_sse,
|
|
)
|
|
|
|
returned = await run_with_enforcement(
|
|
agent=MagicMock(),
|
|
initial_input="hello",
|
|
ctx=ctx,
|
|
stream=stream,
|
|
run_config=RunConfig(),
|
|
)
|
|
|
|
assert returned is fake_result
|
|
assert len(run_configs) == 2
|
|
assert run_configs[0].model_settings is None
|
|
assert run_configs[1].model_settings is not None
|
|
assert run_configs[1].model_settings.tool_choice == "update_and_run_blocks"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_diagnose_offer_retry_does_not_force_update_and_run_blocks_tool_choice(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
ctx = _Ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.DIAGNOSE,
|
|
authority=TurnIntentAuthority(may_read_run_context=True),
|
|
)
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
ctx.scout_trajectory = [
|
|
{
|
|
"tool_name": "click",
|
|
"selector": "button[data-action='continue']",
|
|
"source_url": "https://example.test/start",
|
|
}
|
|
]
|
|
ctx.synthesized_block_offered = False
|
|
ctx.synthesized_block_offered_trajectory_len = 0
|
|
ctx.reached_download_target = None
|
|
stream = MagicMock()
|
|
stream.is_disconnected = AsyncMock(return_value=False)
|
|
|
|
fake_result = MagicMock()
|
|
fake_result.final_output = None
|
|
fake_result.new_items = []
|
|
fake_result.to_input_list.return_value = []
|
|
run_configs: list[RunConfig | None] = []
|
|
|
|
def fake_run_streamed(*args: Any, **kwargs: Any) -> Any:
|
|
run_configs.append(kwargs.get("run_config"))
|
|
return fake_result
|
|
|
|
async def fake_stream_to_sse(result: Any, s: Any, c: Any) -> None:
|
|
if len(run_configs) >= 2:
|
|
c.update_workflow_called = True
|
|
c.test_after_update_done = True
|
|
|
|
monkeypatch.setattr(
|
|
"skyvern.forge.sdk.copilot.enforcement.synthesize_code_block",
|
|
lambda *args, **kwargs: SynthesizedCodeBlock(code="await page.click('button')"),
|
|
)
|
|
monkeypatch.setattr("skyvern.forge.sdk.copilot.enforcement.Runner.run_streamed", fake_run_streamed)
|
|
monkeypatch.setattr(
|
|
"skyvern.forge.sdk.copilot.streaming_adapter.stream_to_sse",
|
|
fake_stream_to_sse,
|
|
)
|
|
|
|
returned = await run_with_enforcement(
|
|
agent=MagicMock(),
|
|
initial_input="hello",
|
|
ctx=ctx,
|
|
stream=stream,
|
|
run_config=RunConfig(),
|
|
)
|
|
|
|
assert returned is fake_result
|
|
assert len(run_configs) == 2
|
|
assert run_configs[0].model_settings is None
|
|
assert run_configs[1].model_settings is None
|
|
|
|
def test_authoring_offer_blocks_non_persistence_tool_until_update_and_run_blocks(self) -> None:
|
|
ctx = _Ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=True),
|
|
)
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = 2
|
|
ctx.synthesized_block_offered_goal_complete = True
|
|
ctx.scout_trajectory = [
|
|
{"tool_name": "type_text", "selector": "input[name='q']", "accessible_name": "Search"},
|
|
{"tool_name": "click", "selector": "button[data-action='search']", "accessible_name": "Search"},
|
|
]
|
|
|
|
signal = synthesized_block_persistence_signal(ctx, "evaluate")
|
|
|
|
assert isinstance(signal, CopilotToolBlockerSignal)
|
|
assert signal.internal_reason_code == SYNTHESIZED_BLOCK_PERSISTENCE_REASON_CODE
|
|
assert signal.blocked_tool == "evaluate"
|
|
assert signal.cleared_by_tools == frozenset({"update_and_run_blocks"})
|
|
assert signal.recovery_hint == "retry_with_different_tool"
|
|
assert signal.renders_final_reply is False
|
|
assert synthesized_block_persistence_signal(ctx, "update_and_run_blocks") is None
|
|
|
|
def test_authoring_offer_blocks_page_mutating_tool_before_loop_detection(self) -> None:
|
|
ctx = _Ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=True),
|
|
)
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = 1
|
|
ctx.synthesized_block_offered_goal_complete = False
|
|
ctx.scout_trajectory = [{"tool_name": "type_text", "selector": "input[name='q']", "accessible_name": "Search"}]
|
|
|
|
signal = synthesized_block_persistence_signal(ctx, "click")
|
|
|
|
assert isinstance(signal, CopilotToolBlockerSignal)
|
|
assert signal.internal_reason_code == SYNTHESIZED_BLOCK_PERSISTENCE_REASON_CODE
|
|
assert signal.blocked_tool == "click"
|
|
assert signal.cleared_by_tools == frozenset({"update_and_run_blocks"})
|
|
assert signal.renders_final_reply is False
|
|
|
|
def test_actuation_obligation_admits_required_fill_tool_during_persistence_offer(self) -> None:
|
|
ctx = _Ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=True),
|
|
required_context={RequiredContextKey.BROWSER_STATE},
|
|
)
|
|
ctx.request_policy = RequestPolicy(
|
|
completion_criteria=[
|
|
CompletionCriterion(
|
|
id="form-submit",
|
|
outcome="form fields are filled",
|
|
kind="terminal_action",
|
|
terminal_action_family="form",
|
|
)
|
|
],
|
|
)
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = 1
|
|
ctx.scout_trajectory = [{"tool_name": "click", "selector": "button.start", "accessible_name": "Start"}]
|
|
|
|
assert synthesized_block_persistence_signal(ctx, "type_text") is None
|
|
click_signal = synthesized_block_persistence_signal(ctx, "click")
|
|
assert isinstance(click_signal, CopilotToolBlockerSignal)
|
|
assert click_signal.internal_reason_code == SYNTHESIZED_BLOCK_PERSISTENCE_REASON_CODE
|
|
|
|
def test_actuation_obligation_admits_required_fill_tool_for_method_mandated_run_contract(self) -> None:
|
|
ctx = _Ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=True),
|
|
required_context={RequiredContextKey.BROWSER_STATE},
|
|
)
|
|
ctx.request_policy = RequestPolicy(
|
|
completion_criteria=[
|
|
CompletionCriterion(
|
|
id="visible-fill",
|
|
outcome="fields are visibly filled on the live page",
|
|
method_mandated=True,
|
|
level="run",
|
|
)
|
|
],
|
|
)
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = 1
|
|
ctx.scout_trajectory = [{"tool_name": "click", "selector": "button.start", "accessible_name": "Start"}]
|
|
|
|
assert synthesized_block_persistence_signal(ctx, "type_text") is None
|
|
click_signal = synthesized_block_persistence_signal(ctx, "click")
|
|
assert isinstance(click_signal, CopilotToolBlockerSignal)
|
|
assert click_signal.internal_reason_code == SYNTHESIZED_BLOCK_PERSISTENCE_REASON_CODE
|
|
|
|
def test_actuation_obligation_blocks_type_text_for_definition_method_contract(self) -> None:
|
|
ctx = _Ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=True),
|
|
required_context={RequiredContextKey.BROWSER_STATE},
|
|
)
|
|
ctx.request_policy = RequestPolicy(
|
|
completion_criteria=[
|
|
CompletionCriterion(
|
|
id="definition-contract",
|
|
outcome="inputs are reusable",
|
|
method_mandated=True,
|
|
level="definition",
|
|
)
|
|
],
|
|
)
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = 1
|
|
ctx.scout_trajectory = [{"tool_name": "click", "selector": "button.start", "accessible_name": "Start"}]
|
|
|
|
signal = synthesized_block_persistence_signal(ctx, "type_text")
|
|
|
|
assert isinstance(signal, CopilotToolBlockerSignal)
|
|
assert signal.internal_reason_code == SYNTHESIZED_BLOCK_PERSISTENCE_REASON_CODE
|
|
|
|
def test_actuation_obligation_admits_required_fill_tool_after_turn_state_reconcile(self) -> None:
|
|
ctx = _Ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=True),
|
|
required_context={RequiredContextKey.BROWSER_STATE},
|
|
)
|
|
ctx.request_policy = RequestPolicy(
|
|
completion_criteria=[
|
|
CompletionCriterion(
|
|
id="form-submit",
|
|
outcome="form fields are filled",
|
|
kind="terminal_action",
|
|
terminal_action_family="form",
|
|
)
|
|
],
|
|
)
|
|
ctx.completion_criteria_turn_state = SimpleNamespace(
|
|
decision=SimpleNamespace(
|
|
criteria=(
|
|
CompletionCriterion(
|
|
id="workflow-run",
|
|
outcome="workflow has been tested",
|
|
kind="terminal_action",
|
|
terminal_action_family="workflow_run",
|
|
),
|
|
)
|
|
)
|
|
)
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = 2
|
|
ctx.scout_trajectory = [
|
|
{"tool_name": "click", "selector": "button.start", "accessible_name": "Start"},
|
|
{"tool_name": "type_text", "selector": "#company", "accessible_name": "Company"},
|
|
]
|
|
|
|
assert synthesized_block_persistence_signal(ctx, "type_text") is None
|
|
|
|
def test_persistence_offer_blocks_type_text_without_actuation_obligation(self) -> None:
|
|
ctx = _Ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=True),
|
|
required_context={RequiredContextKey.BROWSER_STATE},
|
|
)
|
|
ctx.request_policy = RequestPolicy()
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = 1
|
|
ctx.scout_trajectory = [{"tool_name": "click", "selector": "button.start", "accessible_name": "Start"}]
|
|
|
|
signal = synthesized_block_persistence_signal(ctx, "type_text")
|
|
|
|
assert isinstance(signal, CopilotToolBlockerSignal)
|
|
assert signal.internal_reason_code == SYNTHESIZED_BLOCK_PERSISTENCE_REASON_CODE
|
|
assert signal.blocked_tool == "type_text"
|
|
|
|
def test_actuation_obligation_admits_required_fill_tool_from_fill_trajectory(self) -> None:
|
|
ctx = _Ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=True),
|
|
required_context={RequiredContextKey.BROWSER_STATE},
|
|
)
|
|
ctx.request_policy = RequestPolicy()
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = 1
|
|
ctx.scout_trajectory = [
|
|
{
|
|
"tool_name": "type_text",
|
|
"selector": "#company",
|
|
"typed_value": "Example Realty Labs Inc",
|
|
"accessible_name": "Company",
|
|
}
|
|
]
|
|
|
|
assert synthesized_block_persistence_signal(ctx, "type_text") is None
|
|
|
|
def test_fill_less_trajectory_does_not_arm_actuation_obligation(self) -> None:
|
|
ctx = _Ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=True),
|
|
required_context={RequiredContextKey.BROWSER_STATE},
|
|
)
|
|
ctx.request_policy = RequestPolicy()
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = 1
|
|
ctx.scout_trajectory = [{"tool_name": "click", "selector": "button.start", "accessible_name": "Start"}]
|
|
|
|
signal = synthesized_block_persistence_signal(ctx, "type_text")
|
|
|
|
assert isinstance(signal, CopilotToolBlockerSignal)
|
|
assert signal.internal_reason_code == SYNTHESIZED_BLOCK_PERSISTENCE_REASON_CODE
|
|
|
|
def test_prerun_ambiguous_bare_selector_repair_allows_one_evaluate(self) -> None:
|
|
ctx = _Ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=True),
|
|
)
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = 1
|
|
ctx.synthesized_block_offered_goal_complete = True
|
|
ctx.scout_trajectory = [{"tool_name": "click", "selector": "button"}]
|
|
ctx.last_code_authoring_repair_context = CodeAuthoringRepairContext(
|
|
block_label="lookup",
|
|
reason_code="ambiguous_bare_selector",
|
|
selector="button",
|
|
source_url="https://example.com",
|
|
)
|
|
|
|
assert synthesized_block_persistence_signal(ctx, "evaluate") is None
|
|
|
|
def test_repeated_ambiguous_bare_selector_context_blocks_second_evaluate(self) -> None:
|
|
ctx = _Ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=True),
|
|
)
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = 1
|
|
ctx.synthesized_block_offered_goal_complete = True
|
|
ctx.scout_trajectory = [{"tool_name": "click", "selector": "button"}]
|
|
ctx.last_code_authoring_repair_context = CodeAuthoringRepairContext(
|
|
block_label="lookup",
|
|
reason_code="ambiguous_bare_selector",
|
|
selector="button",
|
|
source_url="https://example.com",
|
|
)
|
|
|
|
assert synthesized_block_persistence_signal(ctx, "evaluate") is None
|
|
signal = synthesized_block_persistence_signal(ctx, "evaluate")
|
|
|
|
assert isinstance(signal, CopilotToolBlockerSignal)
|
|
assert signal.internal_reason_code == SYNTHESIZED_BLOCK_PERSISTENCE_REASON_CODE
|
|
|
|
def test_ambiguous_bare_selector_with_stable_alternative_still_requires_persistence(self) -> None:
|
|
ctx = _Ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=True),
|
|
)
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = 1
|
|
ctx.synthesized_block_offered_goal_complete = True
|
|
ctx.scout_trajectory = [{"tool_name": "click", "selector": "button"}]
|
|
ctx.last_code_authoring_repair_context = CodeAuthoringRepairContext(
|
|
block_label="lookup",
|
|
reason_code="ambiguous_bare_selector",
|
|
selector="button",
|
|
source_url="https://example.com",
|
|
selector_alternatives=[{"tool_name": "click", "selector": 'role=button[name="Search"]'}],
|
|
)
|
|
|
|
signal = synthesized_block_persistence_signal(ctx, "evaluate")
|
|
|
|
assert isinstance(signal, CopilotToolBlockerSignal)
|
|
assert signal.internal_reason_code == SYNTHESIZED_BLOCK_PERSISTENCE_REASON_CODE
|
|
|
|
@pytest.mark.parametrize(
|
|
"tool_name",
|
|
["update_and_run_blocks", "update_workflow", "fill_credential_field"],
|
|
)
|
|
def test_authoring_offer_keeps_allowed_tools_unblocked(self, tool_name: str) -> None:
|
|
ctx = _Ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=True),
|
|
)
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = 1
|
|
ctx.synthesized_block_offered_goal_complete = False
|
|
ctx.scout_trajectory = [{"tool_name": "type_text", "selector": "input[name='q']", "accessible_name": "Search"}]
|
|
|
|
assert synthesized_block_persistence_signal(ctx, tool_name) is None
|
|
|
|
def test_authoring_offer_does_not_block_mutating_tool_when_trajectory_changed(self) -> None:
|
|
ctx = _Ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=True),
|
|
)
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = 1
|
|
ctx.synthesized_block_offered_goal_complete = False
|
|
ctx.scout_trajectory = [
|
|
{"tool_name": "type_text", "selector": "input[name='q']", "accessible_name": "Search"},
|
|
{"tool_name": "click", "selector": "button[data-action='search']", "accessible_name": "Search"},
|
|
]
|
|
|
|
assert synthesized_block_persistence_signal(ctx, "click") is None
|
|
|
|
def test_authoring_offer_does_not_block_mutating_tool_without_offer(self) -> None:
|
|
ctx = _Ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=True),
|
|
)
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
ctx.synthesized_block_offered = False
|
|
ctx.synthesized_block_offered_trajectory_len = 1
|
|
ctx.scout_trajectory = [{"tool_name": "type_text", "selector": "input[name='q']", "accessible_name": "Search"}]
|
|
|
|
assert synthesized_block_persistence_signal(ctx, "click") is None
|
|
|
|
def test_unresolved_recorded_outcome_blocks_page_mutating_tool_until_update_and_run_blocks(self) -> None:
|
|
ctx = _Ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=True),
|
|
)
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
ctx.completion_verification_result = self._unsatisfied_verification()
|
|
ctx.latest_recorded_build_test_outcome = RecordedBuildTestOutcome(
|
|
phase="persisted_block_run",
|
|
attempted_tool="update_and_run_blocks",
|
|
verdict="repairable_failure",
|
|
reason_code="outcome_not_demonstrated",
|
|
structural_failure_identity="completion:unsatisfied-output",
|
|
authored_structure_signature="authored:partial",
|
|
)
|
|
|
|
signal = synthesized_block_persistence_signal(ctx, "click")
|
|
|
|
assert isinstance(signal, CopilotToolBlockerSignal)
|
|
assert signal.internal_reason_code == SYNTHESIZED_BLOCK_PERSISTENCE_REASON_CODE
|
|
assert signal.blocked_tool == "click"
|
|
assert signal.cleared_by_tools == frozenset({"update_and_run_blocks"})
|
|
assert "last recorded test outcome" in signal.agent_steering_text
|
|
update_signal = synthesized_block_persistence_signal(ctx, "update_workflow")
|
|
assert isinstance(update_signal, CopilotToolBlockerSignal)
|
|
assert update_signal.internal_reason_code == SYNTHESIZED_BLOCK_PERSISTENCE_REASON_CODE
|
|
assert update_signal.blocked_tool == "update_workflow"
|
|
assert update_signal.cleared_by_tools == frozenset({"update_and_run_blocks"})
|
|
assert synthesized_block_persistence_signal(ctx, "update_and_run_blocks") is None
|
|
assert synthesized_block_persistence_signal(ctx, "evaluate") is None
|
|
|
|
@pytest.mark.parametrize(
|
|
"ctx_attrs",
|
|
[
|
|
{"synthesized_block_offered": False, "synthesized_block_offered_trajectory_len": 0},
|
|
{"synthesized_block_offered": True, "synthesized_block_offered_trajectory_len": 0},
|
|
{
|
|
"synthesized_block_offered": True,
|
|
"synthesized_block_offered_trajectory_len": 4,
|
|
"update_workflow_called": True,
|
|
},
|
|
],
|
|
ids=["no_offer", "zero_trajectory", "update_called"],
|
|
)
|
|
def test_non_persistence_tool_unaffected_without_active_offer_or_after_update(
|
|
self,
|
|
ctx_attrs: dict[str, object],
|
|
) -> None:
|
|
ctx = _Ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=True),
|
|
)
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
for key, value in ctx_attrs.items():
|
|
setattr(ctx, key, value)
|
|
|
|
assert synthesized_block_persistence_signal(ctx, "evaluate") is None
|
|
|
|
def test_recorded_workflow_update_clears_synthesized_persistence_gate(self) -> None:
|
|
ctx = make_copilot_context(workflow_yaml="title: Updated")
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=True),
|
|
)
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = 2
|
|
ctx.scout_trajectory = [
|
|
{"tool_name": "type_text", "selector": "input[name='q']", "accessible_name": "Search"},
|
|
{"tool_name": "click", "selector": "button[data-action='search']", "accessible_name": "Search"},
|
|
]
|
|
ctx.synthesized_block_offered_goal_complete = True
|
|
|
|
_record_workflow_update_result(
|
|
ctx,
|
|
{
|
|
"ok": True,
|
|
"data": {"block_count": 1},
|
|
"_workflow": SimpleNamespace(workflow_definition=SimpleNamespace(blocks=[SimpleNamespace()])),
|
|
},
|
|
)
|
|
|
|
assert ctx.update_workflow_called is True
|
|
assert synthesized_block_persistence_signal(ctx, "evaluate") is None
|
|
|
|
def test_recorded_workflow_update_clears_reopened_synthesized_persistence_latch(self) -> None:
|
|
ctx = make_copilot_context(workflow_yaml="title: Updated")
|
|
ctx.synthesized_block_reopened_after_failed_run = True
|
|
|
|
_record_workflow_update_result(
|
|
ctx,
|
|
{
|
|
"ok": True,
|
|
"data": {"block_count": 1},
|
|
"_workflow": SimpleNamespace(workflow_definition=SimpleNamespace(blocks=[SimpleNamespace()])),
|
|
},
|
|
)
|
|
|
|
assert ctx.synthesized_block_reopened_after_failed_run is False
|
|
|
|
def test_stale_shorter_offer_does_not_force_current_goal_complete_trajectory(self) -> None:
|
|
previous_trajectory = [
|
|
{"tool_name": "type_text", "selector": "input[name='q']", "accessible_name": "Search"},
|
|
]
|
|
trajectory = [
|
|
*previous_trajectory,
|
|
{"tool_name": "click", "selector": "button[data-action='search']", "accessible_name": "Search"},
|
|
]
|
|
ctx = self._authoring_ctx(trajectory=trajectory, download_target=None)
|
|
ctx.synthesized_block_offered_trajectory_len = len(previous_trajectory)
|
|
ctx.synthesized_block_offered_goal_complete = False
|
|
|
|
assert synthesized_trajectory_is_goal_complete(ctx) is True
|
|
assert _should_force_synthesized_block_persistence(ctx) is False
|
|
assert synthesized_block_persistence_signal(ctx, "evaluate") is None
|
|
|
|
def test_refreshed_goal_complete_offer_forces_current_trajectory(self) -> None:
|
|
trajectory = [
|
|
{"tool_name": "type_text", "selector": "input[name='q']", "accessible_name": "Search"},
|
|
{"tool_name": "click", "selector": "button[data-action='search']", "accessible_name": "Search"},
|
|
]
|
|
ctx = self._authoring_ctx(trajectory=trajectory, download_target=None)
|
|
|
|
assert ctx.synthesized_block_offered_trajectory_len == len(trajectory)
|
|
assert ctx.synthesized_block_offered_goal_complete is True
|
|
assert _should_force_synthesized_block_persistence(ctx) is True
|
|
assert synthesized_block_persistence_signal(ctx, "evaluate") is not None
|
|
|
|
def test_failed_verified_run_with_new_commit_reopens_synthesized_persistence_gate(self) -> None:
|
|
previous_trajectory = [
|
|
{
|
|
"tool_name": "fill_credential_field",
|
|
"selector": "#username",
|
|
"credential_id": "cred_1",
|
|
"credential_field": "username",
|
|
},
|
|
{"tool_name": "click", "selector": "button[data-action='login']", "accessible_name": "Log in"},
|
|
]
|
|
trajectory = [
|
|
*previous_trajectory,
|
|
{"tool_name": "click", "selector": "button[data-action='businessToggle']", "accessible_name": "Business"},
|
|
]
|
|
ctx = self._authoring_ctx(trajectory=trajectory, download_target=None)
|
|
ctx.update_workflow_called = True
|
|
ctx.test_after_update_done = True
|
|
ctx.last_test_ok = False
|
|
ctx.completion_verification_result = self._unsatisfied_verification()
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = len(previous_trajectory)
|
|
ctx.synthesized_block_offered_goal_complete = True
|
|
|
|
assert synthesized_persistence_reopened_after_failed_run(ctx) is True
|
|
assert _should_force_synthesized_block_persistence(ctx) is False
|
|
assert synthesized_block_persistence_signal(ctx, "evaluate") is None
|
|
|
|
assert _maybe_synthesized_block_offer_msg(ctx) is not None
|
|
assert ctx.synthesized_block_offered_trajectory_len == len(trajectory)
|
|
assert _should_force_synthesized_block_persistence(ctx) is True
|
|
assert synthesized_block_persistence_signal(ctx, "evaluate") is not None
|
|
|
|
def test_failed_verified_run_without_new_commit_keeps_synthesized_persistence_gate_clear(self) -> None:
|
|
previous_trajectory = [
|
|
{
|
|
"tool_name": "fill_credential_field",
|
|
"selector": "#username",
|
|
"credential_id": "cred_1",
|
|
"credential_field": "username",
|
|
},
|
|
{"tool_name": "click", "selector": "button[data-action='login']", "accessible_name": "Log in"},
|
|
]
|
|
trajectory = [
|
|
*previous_trajectory,
|
|
{
|
|
"tool_name": "fill_credential_field",
|
|
"selector": "#password",
|
|
"credential_id": "cred_1",
|
|
"credential_field": "password",
|
|
},
|
|
]
|
|
ctx = self._authoring_ctx(trajectory=trajectory, download_target=None)
|
|
ctx.update_workflow_called = True
|
|
ctx.test_after_update_done = True
|
|
ctx.last_test_ok = False
|
|
ctx.completion_verification_result = self._unsatisfied_verification()
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = len(previous_trajectory)
|
|
ctx.synthesized_block_offered_goal_complete = True
|
|
|
|
assert synthesized_persistence_reopened_after_failed_run(ctx) is False
|
|
assert _should_force_synthesized_block_persistence(ctx) is False
|
|
assert synthesized_block_persistence_signal(ctx, "evaluate") is None
|
|
|
|
def test_diagnose_offer_does_not_block_non_persistence_tool(self) -> None:
|
|
ctx = _Ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.DIAGNOSE,
|
|
authority=TurnIntentAuthority(may_read_run_context=True),
|
|
)
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = 4
|
|
|
|
assert synthesized_block_persistence_signal(ctx, "evaluate") is None
|
|
|
|
@pytest.mark.parametrize(
|
|
"trajectory, download_target, expected_complete",
|
|
[
|
|
([], None, False),
|
|
([{"tool_name": "click", "selector": "a.home", "accessible_name": "Home"}], None, False),
|
|
([{"tool_name": "type_text", "selector": "input[name='q']", "accessible_name": "Search"}], None, False),
|
|
(
|
|
[
|
|
{"tool_name": "type_text", "selector": "input[name='q']", "accessible_name": "Search"},
|
|
{"tool_name": "click", "selector": "button[data-action='search']", "accessible_name": "Search"},
|
|
],
|
|
None,
|
|
True,
|
|
),
|
|
(
|
|
[
|
|
{"tool_name": "select_option", "selector": "select#state", "value": "CA"},
|
|
{"tool_name": "click", "selector": "button[type='submit']", "accessible_name": "Continue"},
|
|
],
|
|
None,
|
|
True,
|
|
),
|
|
(
|
|
[
|
|
{"tool_name": "select_option", "selector": "select#state", "value": ""},
|
|
{"tool_name": "click", "selector": "button[type='submit']", "accessible_name": "Continue"},
|
|
],
|
|
None,
|
|
False,
|
|
),
|
|
(
|
|
[
|
|
{"tool_name": "fill_credential_field", "credential_field": "username"},
|
|
{"tool_name": "click", "selector": "button[type='submit']", "accessible_name": "Log in"},
|
|
],
|
|
None,
|
|
False,
|
|
),
|
|
(
|
|
[
|
|
{"tool_name": "fill_credential_field", "credential_id": "cred", "credential_field": "username"},
|
|
{"tool_name": "fill_credential_field", "credential_id": "cred", "credential_field": "password"},
|
|
{"tool_name": "click", "selector": "button[type='submit']", "accessible_name": "Log in"},
|
|
],
|
|
None,
|
|
True,
|
|
),
|
|
(
|
|
[
|
|
{"tool_name": "fill_credential_field", "credential_id": "cred", "credential_field": "username"},
|
|
{"tool_name": "click", "selector": "button"},
|
|
],
|
|
None,
|
|
False,
|
|
),
|
|
(
|
|
[
|
|
{"tool_name": "fill_credential_field", "credential_id": "cred", "credential_field": "username"},
|
|
{"tool_name": "fill_credential_field", "credential_id": "cred", "credential_field": "password"},
|
|
{"tool_name": "click", "selector": "button[type='submit']", "accessible_name": "Log in"},
|
|
{"tool_name": "type_text", "selector": "input[name='amount']", "accessible_name": "Amount"},
|
|
{"tool_name": "click", "selector": "button[data-action='pay']", "accessible_name": "Pay"},
|
|
],
|
|
None,
|
|
True,
|
|
),
|
|
(
|
|
[{"tool_name": "click", "selector": "a.report", "accessible_name": "Report"}],
|
|
ReachedDownloadTarget(
|
|
selector="a.report",
|
|
affordance_text="Report",
|
|
download_kind="extension",
|
|
source_step="trajectory_recency",
|
|
already_registered=False,
|
|
),
|
|
True,
|
|
),
|
|
],
|
|
ids=[
|
|
"empty",
|
|
"navigate_only",
|
|
"type_text_only",
|
|
"type_text_then_commit",
|
|
"select_with_value_then_commit",
|
|
"select_empty_value_dropped",
|
|
"credential_missing_id_dropped",
|
|
"valid_login_fill_and_submit",
|
|
"valid_fill_generic_opener_only",
|
|
"post_login_durable_entry_then_commit",
|
|
"reached_download_target",
|
|
],
|
|
)
|
|
def test_goal_completeness_drives_force_and_blocker_in_lockstep(
|
|
self,
|
|
trajectory: list[dict[str, object]],
|
|
download_target: ReachedDownloadTarget | None,
|
|
expected_complete: bool,
|
|
) -> None:
|
|
ctx = self._authoring_ctx(trajectory=trajectory, download_target=download_target)
|
|
|
|
assert synthesized_trajectory_is_goal_complete(ctx) is expected_complete
|
|
assert _should_force_synthesized_block_persistence(ctx) is expected_complete
|
|
blocked = synthesized_block_persistence_signal(ctx, "evaluate") is not None
|
|
assert blocked is expected_complete
|
|
|
|
def test_extract_shaped_lookup_submit_is_goal_complete_without_extract_step(self) -> None:
|
|
trajectory = [
|
|
{"tool_name": "type_text", "selector": "input[name='reference']", "accessible_name": "Reference"},
|
|
{"tool_name": "click", "selector": "button[data-action='lookup']", "accessible_name": "Look up"},
|
|
]
|
|
ctx = self._authoring_ctx(trajectory=trajectory, download_target=None)
|
|
|
|
assert not any(
|
|
str(item.get("tool_name") or "") in {"extract", "get_run_results", "evaluate"} for item in trajectory
|
|
)
|
|
assert synthesized_trajectory_is_goal_complete(ctx) is True
|
|
assert _should_force_synthesized_block_persistence(ctx) is True
|
|
|
|
@pytest.mark.parametrize(
|
|
"guard_attrs",
|
|
[
|
|
{"update_workflow_called": True},
|
|
{"synthesized_block_offered": False},
|
|
{"block_authoring_policy": BlockAuthoringPolicy.STANDARD},
|
|
],
|
|
ids=["already_authored", "not_offered", "policy_not_code_only"],
|
|
)
|
|
def test_early_guards_never_force_even_when_goal_complete(self, guard_attrs: dict[str, object]) -> None:
|
|
trajectory = [
|
|
{"tool_name": "type_text", "selector": "input[name='q']", "accessible_name": "Search"},
|
|
{"tool_name": "click", "selector": "button[data-action='search']", "accessible_name": "Search"},
|
|
]
|
|
ctx = self._authoring_ctx(trajectory=trajectory, download_target=None)
|
|
for key, value in guard_attrs.items():
|
|
setattr(ctx, key, value)
|
|
|
|
assert _should_force_synthesized_block_persistence(ctx) is False
|
|
assert synthesized_block_persistence_signal(ctx, "evaluate") is None
|
|
|
|
def test_diagnose_turn_intent_never_forces_even_when_goal_complete(self) -> None:
|
|
trajectory = [
|
|
{"tool_name": "type_text", "selector": "input[name='q']", "accessible_name": "Search"},
|
|
{"tool_name": "click", "selector": "button[data-action='search']", "accessible_name": "Search"},
|
|
]
|
|
ctx = self._authoring_ctx(trajectory=trajectory, download_target=None)
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.DIAGNOSE,
|
|
authority=TurnIntentAuthority(may_read_run_context=True),
|
|
)
|
|
|
|
assert _should_force_synthesized_block_persistence(ctx) is False
|
|
assert synthesized_block_persistence_signal(ctx, "evaluate") is None
|
|
|
|
def test_update_only_turn_intent_never_forces_update_and_run_blocks_even_when_goal_complete(self) -> None:
|
|
trajectory = [
|
|
{"tool_name": "type_text", "selector": "input[name='q']", "accessible_name": "Search"},
|
|
{"tool_name": "click", "selector": "button[data-action='search']", "accessible_name": "Search"},
|
|
]
|
|
ctx = self._authoring_ctx(trajectory=trajectory, download_target=None)
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.DRAFT_ONLY,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=False),
|
|
)
|
|
|
|
assert synthesized_trajectory_is_goal_complete(ctx) is True
|
|
assert _should_force_synthesized_block_persistence(ctx) is False
|
|
assert synthesized_block_persistence_signal(ctx, "evaluate") is None
|
|
|
|
def test_goal_complete_commit_refreshes_offer_below_threshold(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(
|
|
"skyvern.forge.sdk.copilot.enforcement.synthesize_code_block",
|
|
lambda *args, **kwargs: SynthesizedCodeBlock(code="await page.click('button')"),
|
|
)
|
|
trajectory = [
|
|
{"tool_name": "click", "selector": "a.home", "accessible_name": "Home"},
|
|
{"tool_name": "type_text", "selector": "input[name='q']", "accessible_name": "Search"},
|
|
{"tool_name": "click", "selector": "button[data-action='search']", "accessible_name": "Search"},
|
|
]
|
|
ctx = self._authoring_ctx(trajectory=trajectory, download_target=None)
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = 2
|
|
ctx.synthesized_block_offered_goal_complete = False
|
|
assert len(trajectory) < 2 + SYNTHESIZED_OFFER_REFRESH_STEP_THRESHOLD
|
|
|
|
message = _maybe_synthesized_block_offer_msg(ctx)
|
|
|
|
assert message is not None
|
|
assert ctx.synthesized_block_offered_trajectory_len == len(trajectory)
|
|
assert ctx.synthesized_block_offered_goal_complete is True
|
|
|
|
def test_goal_complete_offer_refresh_suppresses_near_duplicate_followup(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
"skyvern.forge.sdk.copilot.enforcement.synthesize_code_block",
|
|
lambda *args, **kwargs: SynthesizedCodeBlock(code="await page.click('button')"),
|
|
)
|
|
trajectory = [
|
|
{"tool_name": "type_text", "selector": "input[name='q']", "accessible_name": "Search"},
|
|
{"tool_name": "click", "selector": "button[data-action='search']", "accessible_name": "Search"},
|
|
{"tool_name": "click", "selector": "button[data-action='open']", "accessible_name": "Open"},
|
|
]
|
|
ctx = self._authoring_ctx(trajectory=trajectory, download_target=None)
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = 2
|
|
ctx.synthesized_block_offered_goal_complete = True
|
|
assert len(trajectory) < 2 + SYNTHESIZED_OFFER_REFRESH_STEP_THRESHOLD
|
|
|
|
message = _maybe_synthesized_block_offer_msg(ctx)
|
|
|
|
assert message is None
|
|
assert ctx.synthesized_block_offered_trajectory_len == 2
|
|
|
|
def test_failed_verified_run_with_new_commit_refreshes_offer_below_threshold(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
"skyvern.forge.sdk.copilot.enforcement.synthesize_code_block",
|
|
lambda *args, **kwargs: SynthesizedCodeBlock(code="await page.click('button')"),
|
|
)
|
|
previous_trajectory = [
|
|
{"tool_name": "type_text", "selector": "input[name='q']", "accessible_name": "Search"},
|
|
{"tool_name": "click", "selector": "button[data-action='search']", "accessible_name": "Search"},
|
|
]
|
|
trajectory = [
|
|
*previous_trajectory,
|
|
{"tool_name": "click", "selector": "button[data-action='details']", "accessible_name": "Details"},
|
|
]
|
|
ctx = self._authoring_ctx(trajectory=trajectory, download_target=None)
|
|
ctx.update_workflow_called = True
|
|
ctx.test_after_update_done = True
|
|
ctx.last_test_ok = False
|
|
ctx.completion_verification_result = self._unsatisfied_verification()
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = len(previous_trajectory)
|
|
ctx.synthesized_block_offered_goal_complete = True
|
|
assert len(trajectory) < len(previous_trajectory) + SYNTHESIZED_OFFER_REFRESH_STEP_THRESHOLD
|
|
|
|
message = _maybe_synthesized_block_offer_msg(ctx)
|
|
|
|
assert message is not None
|
|
assert ctx.synthesized_block_offered_trajectory_len == len(trajectory)
|
|
assert ctx.synthesized_block_reopened_after_failed_run is True
|
|
|
|
def test_sub_threshold_offer_stays_suppressed_when_not_goal_complete(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(
|
|
"skyvern.forge.sdk.copilot.enforcement.synthesize_code_block",
|
|
lambda *args, **kwargs: SynthesizedCodeBlock(code="await page.click('button')"),
|
|
)
|
|
trajectory = [
|
|
{"tool_name": "click", "selector": "a.home", "accessible_name": "Home"},
|
|
{"tool_name": "type_text", "selector": "input[name='q']", "accessible_name": "Search"},
|
|
]
|
|
ctx = self._authoring_ctx(trajectory=trajectory, download_target=None)
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = 1
|
|
assert len(trajectory) < 1 + SYNTHESIZED_OFFER_REFRESH_STEP_THRESHOLD
|
|
|
|
message = _maybe_synthesized_block_offer_msg(ctx)
|
|
|
|
assert message is None
|
|
assert ctx.synthesized_block_offered_trajectory_len == 1
|
|
|
|
def test_terminal_blocker_replaces_nonterminal_synthesized_persistence_blocker(self) -> None:
|
|
ctx = _Ctx()
|
|
persistence_signal = CopilotToolBlockerSignal(
|
|
blocker_kind="tool_error",
|
|
agent_steering_text="Persist the drafted workflow before more scouting.",
|
|
user_facing_reason="I need to save and test the drafted workflow before scouting more.",
|
|
recovery_hint="retry_with_different_tool",
|
|
cleared_by_tools=frozenset({"update_and_run_blocks"}),
|
|
preserves_workflow_draft=True,
|
|
renders_final_reply=False,
|
|
internal_reason_code=SYNTHESIZED_BLOCK_PERSISTENCE_REASON_CODE,
|
|
blocked_tool="click",
|
|
)
|
|
terminal_signal = CopilotToolBlockerSignal(
|
|
blocker_kind="loop_detected",
|
|
agent_steering_text="Report that repeated repair attempts did not verify the workflow outcome.",
|
|
user_facing_reason="The workflow was saved and tested, but repeated repairs did not reach the required review page.",
|
|
recovery_hint="report_blocker_to_user",
|
|
cleared_by_tools=frozenset(),
|
|
preserves_workflow_draft=True,
|
|
renders_final_reply=True,
|
|
internal_reason_code="repair_ceiling_reached",
|
|
blocked_tool="update_and_run_blocks",
|
|
)
|
|
|
|
stash_blocker_signal(ctx, persistence_signal)
|
|
stash_blocker_signal(ctx, terminal_signal)
|
|
|
|
assert ctx.blocker_signal is terminal_signal
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _is_meaningful_extracted_data
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_meaningful_data_none() -> None:
|
|
assert _is_meaningful_extracted_data(None) is False
|
|
|
|
|
|
def test_meaningful_data_empty_dict() -> None:
|
|
assert _is_meaningful_extracted_data({}) is False
|
|
|
|
|
|
def test_meaningful_data_all_null_dict() -> None:
|
|
# The regression: {"price": None} used to count as meaningful because
|
|
# the dict itself is truthy. It must NOT count as meaningful.
|
|
assert _is_meaningful_extracted_data({"price": None}) is False
|
|
|
|
|
|
def test_meaningful_data_nested_all_null() -> None:
|
|
assert _is_meaningful_extracted_data({"a": None, "b": {"c": None}}) is False
|
|
|
|
|
|
def test_meaningful_data_one_real_value() -> None:
|
|
assert _is_meaningful_extracted_data({"price": "260.48", "other": None}) is True
|
|
|
|
|
|
def test_meaningful_data_empty_list() -> None:
|
|
assert _is_meaningful_extracted_data([]) is False
|
|
|
|
|
|
def test_meaningful_data_list_of_nulls() -> None:
|
|
assert _is_meaningful_extracted_data([None, None]) is False
|
|
|
|
|
|
def test_meaningful_data_scalar_zero() -> None:
|
|
# A literal 0 is still meaningful output — it's a value, not absence of data.
|
|
assert _is_meaningful_extracted_data(0) is True
|
|
|
|
|
|
def test_meaningful_data_empty_string() -> None:
|
|
assert _is_meaningful_extracted_data("") is False
|
|
|
|
|
|
def test_meaningful_data_string() -> None:
|
|
assert _is_meaningful_extracted_data("$260.48") is True
|
|
|
|
|
|
def test_unrecoverable_browser_session_error_stops_after_second_failure() -> None:
|
|
from skyvern.forge.sdk.copilot.enforcement import (
|
|
CopilotUnrecoverableToolError,
|
|
_maybe_raise_unrecoverable_tool_error,
|
|
)
|
|
|
|
ctx = SimpleNamespace(last_artifact_health_blocker_reason=None, completion_verification_result=None)
|
|
output = {"ok": False, "error": "Browser session not found while taking screenshot (404)."}
|
|
|
|
_maybe_raise_unrecoverable_tool_error(ctx, "get_browser_screenshot", output)
|
|
assert ctx.unrecoverable_tool_error_streak_count == 1
|
|
|
|
with pytest.raises(CopilotUnrecoverableToolError) as exc_info:
|
|
_maybe_raise_unrecoverable_tool_error(ctx, "get_browser_screenshot", output)
|
|
|
|
assert "Browser session not found" in str(exc_info.value)
|
|
assert ctx.unrecoverable_tool_error_streak_count == 2
|
|
contract = ctx.latest_diagnosis_repair_contract
|
|
assert contract.repair_decision.next_action == "stop"
|
|
assert contract.verification_result.remaining_blocker == "Browser session not found while taking screenshot (404)."
|
|
|
|
|
|
def test_unrecoverable_tool_error_ignores_regular_website_404() -> None:
|
|
from skyvern.forge.sdk.copilot.enforcement import _maybe_raise_unrecoverable_tool_error
|
|
|
|
ctx = SimpleNamespace()
|
|
|
|
_maybe_raise_unrecoverable_tool_error(
|
|
ctx,
|
|
"navigate_browser",
|
|
{"ok": False, "error": "The page returned HTTP 404 page not found."},
|
|
)
|
|
|
|
assert getattr(ctx, "unrecoverable_tool_error_streak_count", 0) == 0
|
|
assert getattr(ctx, "latest_diagnosis_repair_contract", None) is None
|
|
|
|
|
|
def test_unrecoverable_contract_stop_preempts_failed_test_nudge() -> None:
|
|
from skyvern.forge.sdk.copilot.diagnosis_repair_contract import build_diagnosis_repair_contract
|
|
from skyvern.forge.sdk.copilot.enforcement import CopilotUnrecoverableToolError, _check_enforcement
|
|
|
|
ctx = _Ctx()
|
|
ctx.last_test_ok = False
|
|
reason = "Browser session not found while running blocks (404)."
|
|
ctx.latest_diagnosis_repair_contract = build_diagnosis_repair_contract(
|
|
source_tool="update_and_run_blocks",
|
|
result={
|
|
"ok": False,
|
|
"error": reason,
|
|
"data": {
|
|
"overall_status": "aborted",
|
|
"failure_reason": reason,
|
|
"failure_categories": [{"category": "UNRECOVERABLE_TOOL_ERROR"}],
|
|
},
|
|
},
|
|
ctx=ctx,
|
|
)
|
|
|
|
with pytest.raises(CopilotUnrecoverableToolError):
|
|
_check_enforcement(ctx)
|
|
|
|
assert ctx.failed_test_nudge_count == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _analyze_run_blocks — envelope-unwrap for EXTRACTION blocks
|
|
#
|
|
# ExtractionBlock stores TaskOutput.from_task() on block.output. Envelope
|
|
# fields (task_id, status, *_screenshot_artifact_ids) are always populated on
|
|
# a completed run and would short-circuit _is_meaningful_extracted_data to
|
|
# True even when the real payload fields (extracted_information,
|
|
# downloaded_files, downloaded_file_urls) are empty. The meaningful-data
|
|
# check must judge against the payload slice, not the envelope.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_EMPTY_EXTRACTION_ENVELOPE: dict[str, Any] = {
|
|
"task_id": "tsk_00000000000000000001",
|
|
"status": "completed",
|
|
"extracted_information": [],
|
|
"failure_reason": None,
|
|
"errors": [],
|
|
"failure_category": None,
|
|
"downloaded_files": [],
|
|
"downloaded_file_urls": None,
|
|
"task_screenshots": None,
|
|
"workflow_screenshots": None,
|
|
"task_screenshot_artifact_ids": ["a_00000000000000000001", "a_00000000000000000002"],
|
|
"workflow_screenshot_artifact_ids": ["a_00000000000000000001", "a_00000000000000000003"],
|
|
}
|
|
|
|
|
|
def _run_result(blocks: list[dict[str, Any]], ok: bool = True) -> dict[str, Any]:
|
|
return {"ok": ok, "data": {"blocks": blocks}}
|
|
|
|
|
|
def _envelope(**overrides: Any) -> dict[str, Any]:
|
|
"""Return a fresh copy of the empty-extraction envelope with field overrides."""
|
|
return {**_EMPTY_EXTRACTION_ENVELOPE, **overrides}
|
|
|
|
|
|
def _extraction_block(extracted_data: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"label": "extract_flights",
|
|
"block_type": "EXTRACTION",
|
|
"status": "completed",
|
|
"extracted_data": extracted_data,
|
|
}
|
|
|
|
|
|
def _text_prompt_block(extracted_data: Any) -> dict[str, Any]:
|
|
return {
|
|
"label": "summarize",
|
|
"block_type": "TEXT_PROMPT",
|
|
"status": "completed",
|
|
"extracted_data": extracted_data,
|
|
}
|
|
|
|
|
|
# Case id -> (envelope overrides, expected empty_data_blocks)
|
|
#
|
|
# empty_payload_trace_repro: extracted_information=[], downloaded_files=[],
|
|
# downloaded_file_urls=None, envelope metadata populated. Envelope-as-a-whole
|
|
# is truthy; real payload is empty; gate must flip. (SKY-9143 repro.)
|
|
# download_only_files / download_only_urls: legitimate extraction success where the
|
|
# block produced files but no structured payload — must NOT flip the gate.
|
|
_EXTRACTION_ENVELOPE_CASES: list[tuple[str, dict[str, Any], bool]] = [
|
|
("empty_payload_trace_repro", {}, True),
|
|
("real_extraction", {"extracted_information": [{"price": "260.48"}]}, False),
|
|
(
|
|
"nested_code_output_record",
|
|
{
|
|
"extracted_information": [],
|
|
"extract_record_status_info_output": {
|
|
"entity_found": True,
|
|
"entity_name": "Jordan Example",
|
|
"record_number": "1234567890",
|
|
"items": [
|
|
{
|
|
"item_name": "Sample Practice",
|
|
"address": "100 Main St, Example City, ST 12345",
|
|
"status": "Active",
|
|
}
|
|
],
|
|
"overall_status": "Active",
|
|
},
|
|
},
|
|
False,
|
|
),
|
|
(
|
|
"download_only_files",
|
|
{"downloaded_files": [{"url": "https://example.com/a.pdf", "checksum": "abc123"}]},
|
|
False,
|
|
),
|
|
(
|
|
"download_only_urls",
|
|
{"extracted_information": None, "downloaded_file_urls": ["https://example.com/a.pdf"]},
|
|
False,
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"overrides,expected_empty",
|
|
[(ovr, exp) for _, ovr, exp in _EXTRACTION_ENVELOPE_CASES],
|
|
ids=[case_id for case_id, _, _ in _EXTRACTION_ENVELOPE_CASES],
|
|
)
|
|
def test_analyze_extraction_envelope(overrides: dict[str, Any], expected_empty: bool) -> None:
|
|
_, empty, _ = _analyze_run_blocks(_run_result([_extraction_block(_envelope(**overrides))]))
|
|
assert empty is expected_empty
|
|
|
|
|
|
def test_analyze_text_prompt_default_schema_is_not_empty() -> None:
|
|
# TEXT_PROMPT blocks return the raw LLM response dict (no Task envelope).
|
|
# Default schema is {"llm_response": "<text>"}.
|
|
_, empty, _ = _analyze_run_blocks(_run_result([_text_prompt_block({"llm_response": "the sentiment is positive"})]))
|
|
assert empty is False
|
|
|
|
|
|
def test_analyze_text_prompt_user_schema_named_extracted_information_is_not_sliced() -> None:
|
|
# Guard against a too-broad unwrap: a user's json_schema may name a
|
|
# top-level field "extracted_information". The helper must not mistake
|
|
# that for an EXTRACTION envelope and discard sibling fields.
|
|
block = _text_prompt_block({"extracted_information": "ignored because this is TEXT_PROMPT", "summary": "x"})
|
|
_, empty, _ = _analyze_run_blocks(_run_result([block]))
|
|
assert empty is False
|
|
|
|
|
|
def test_analyze_text_prompt_all_null_is_empty() -> None:
|
|
# Symmetric to {"price": None} — a text-prompt response with all-null
|
|
# fields counts as no meaningful output.
|
|
_, empty, _ = _analyze_run_blocks(_run_result([_text_prompt_block({"summary": None})]))
|
|
assert empty is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _record_run_blocks_result — end-to-end flip of last_test_ok on empty envelope
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _fresh_ctx_for_record() -> SimpleNamespace:
|
|
"""SimpleNamespace shaped for _record_run_blocks_result + update_repeated_failure_state.
|
|
|
|
Mirrors the AgentContext field defaults the function under test reads directly,
|
|
so the stub populates the interesting fields without tripping AttributeError on
|
|
the downstream update_repeated_failure_state call.
|
|
"""
|
|
return SimpleNamespace(
|
|
code_artifact_metadata={},
|
|
composition_page_evidence=None,
|
|
unbound_required_parameter_keys=[],
|
|
last_test_ok=True,
|
|
last_test_failure_reason=None,
|
|
last_test_suspicious_success=False,
|
|
last_test_anti_bot=None,
|
|
last_failure_category_top=None,
|
|
last_test_non_retriable_nav_error=None,
|
|
failed_test_nudge_count=0,
|
|
last_failed_workflow_yaml=None,
|
|
last_good_workflow=None,
|
|
last_good_workflow_yaml=None,
|
|
non_retriable_nav_error_last_emitted_signature=None,
|
|
workflow_yaml=None,
|
|
last_workflow=None,
|
|
last_workflow_yaml=None,
|
|
last_frontier_start_label=None,
|
|
last_executed_block_labels=[],
|
|
last_full_workflow_test_ok=False,
|
|
last_unverified_block_labels=[],
|
|
last_failure_signature=None,
|
|
last_frontier_fingerprint=None,
|
|
repeated_failure_streak_count=0,
|
|
repeated_failure_nudge_emitted_at_streak=0,
|
|
pending_action_sequence_fingerprint=None,
|
|
last_action_sequence_fingerprint=None,
|
|
repeated_action_fingerprint_streak_count=0,
|
|
copilot_total_timeout_exceeded=False,
|
|
workflow_verification_evidence=WorkflowVerificationEvidence(),
|
|
output_contract_pending_run_evidence={},
|
|
)
|
|
|
|
|
|
def test_record_run_blocks_result_flips_last_test_ok_on_empty_extraction_envelope() -> None:
|
|
# End-to-end: a run reporting ok=true but whose sole EXTRACTION block
|
|
# produced the empty envelope must push last_test_ok from True to None,
|
|
# so _verified_workflow_or_none blocks the proposal. This is the user-
|
|
# visible SKY-9143 regression.
|
|
ctx = _fresh_ctx_for_record()
|
|
result = _run_result([_extraction_block(_envelope())])
|
|
_record_run_blocks_result(ctx, result)
|
|
assert ctx.last_test_ok is None
|
|
assert ctx.last_test_suspicious_success is True
|
|
assert ctx.last_test_failure_reason is not None
|
|
|
|
|
|
def test_record_run_blocks_result_does_not_promote_partial_frontier_to_full_workflow() -> None:
|
|
from types import SimpleNamespace
|
|
|
|
ctx = _fresh_ctx_for_record()
|
|
ctx.last_workflow = SimpleNamespace(
|
|
workflow_definition=SimpleNamespace(blocks=[SimpleNamespace(label="open"), SimpleNamespace(label="extract")])
|
|
)
|
|
ctx.last_workflow_yaml = "workflow: yaml"
|
|
ctx.verified_prefix_labels = ["open"]
|
|
|
|
result = {
|
|
"ok": True,
|
|
"data": {
|
|
"workflow_run_id": "wr_partial",
|
|
"requested_block_labels": ["open"],
|
|
"executed_block_labels": ["open"],
|
|
"blocks": [{"label": "open", "status": "completed"}],
|
|
},
|
|
}
|
|
|
|
_record_run_blocks_result(ctx, result)
|
|
|
|
assert ctx.last_test_ok is True
|
|
assert ctx.last_full_workflow_test_ok is False
|
|
assert ctx.last_unverified_block_labels == ["extract"]
|
|
assert ctx.last_good_workflow is None
|
|
assert "unverified workflow blocks remain" in (ctx.last_test_failure_reason or "")
|
|
|
|
|
|
def test_record_run_blocks_result_promotes_when_verified_prefix_covers_workflow() -> None:
|
|
from types import SimpleNamespace
|
|
|
|
ctx = _fresh_ctx_for_record()
|
|
ctx.last_workflow = SimpleNamespace(
|
|
workflow_definition=SimpleNamespace(blocks=[SimpleNamespace(label="open"), SimpleNamespace(label="extract")])
|
|
)
|
|
ctx.last_workflow_yaml = "workflow: yaml"
|
|
ctx.verified_prefix_labels = ["open", "extract"]
|
|
ctx.last_unverified_block_labels = ["stale_extract"]
|
|
|
|
result = {
|
|
"ok": True,
|
|
"data": {
|
|
"workflow_run_id": "wr_full",
|
|
"requested_block_labels": ["extract"],
|
|
"executed_block_labels": ["extract"],
|
|
"blocks": [{"label": "extract", "status": "completed", "extracted_data": {"value": "ok"}}],
|
|
},
|
|
}
|
|
|
|
_record_run_blocks_result(ctx, result)
|
|
|
|
assert ctx.last_test_ok is True
|
|
assert ctx.last_full_workflow_test_ok is True
|
|
assert ctx.last_unverified_block_labels == []
|
|
assert ctx.last_good_workflow is ctx.last_workflow
|
|
assert ctx.last_good_workflow_yaml == ctx.last_workflow_yaml
|
|
|
|
|
|
def test_record_run_blocks_result_promotes_structured_record_top_level_output_to_terminal_proposal() -> None:
|
|
ctx = _fresh_ctx_for_record()
|
|
ctx.last_workflow = SimpleNamespace(
|
|
workflow_definition=SimpleNamespace(
|
|
blocks=[
|
|
SimpleNamespace(label="open_search_search"),
|
|
SimpleNamespace(label="search_and_open_record_details"),
|
|
SimpleNamespace(label="extract_record_status_record"),
|
|
]
|
|
)
|
|
)
|
|
ctx.last_workflow_yaml = "title: Record lookup"
|
|
ctx.verified_prefix_labels = ["open_search_search"]
|
|
result = {
|
|
"ok": True,
|
|
"data": {
|
|
"workflow_run_id": "wr_structured_record",
|
|
"overall_status": "completed",
|
|
"executed_block_labels": ["extract_record_status_record"],
|
|
"blocks": [
|
|
{
|
|
"label": "extract_record_status_record",
|
|
"block_type": "CODE",
|
|
"status": "completed",
|
|
"extracted_data": {"extracted_information": []},
|
|
}
|
|
],
|
|
"output": {
|
|
"search_and_open_record_details_output": {
|
|
"found": True,
|
|
"entity_name": "Jordan Example",
|
|
"opened_record_details": True,
|
|
"evidence_text": "Opened Details page for the selected record.",
|
|
},
|
|
"extract_record_status_record_output": {
|
|
"found": True,
|
|
"entity_name": "Jordan Example",
|
|
"record_number": "1234567890",
|
|
"items": [
|
|
{
|
|
"item_label": "Sample Practice",
|
|
"address": "100 Main St, Example City, ST 12345",
|
|
"status": "Active",
|
|
}
|
|
],
|
|
"overall_status": "Active",
|
|
"evidence_text": "Opened Details page; read Overview/Affiliations items and More Details identifier.",
|
|
},
|
|
"extracted_information": [],
|
|
},
|
|
},
|
|
}
|
|
verification = CompletionVerificationResult(
|
|
status="evaluated",
|
|
criterion_ids=[
|
|
"fallback_record_identity",
|
|
"fallback_record_identifier",
|
|
"fallback_record_groups",
|
|
"fallback_record_status",
|
|
],
|
|
verdicts=[
|
|
CriterionVerdict(criterion_id=cid, state="satisfied", reason_code="evidence_confirms")
|
|
for cid in (
|
|
"fallback_record_identity",
|
|
"fallback_record_identifier",
|
|
"fallback_record_groups",
|
|
"fallback_record_status",
|
|
)
|
|
],
|
|
)
|
|
|
|
_record_run_blocks_result(ctx, result, completion_verification=verification)
|
|
|
|
assert ctx.verified_terminal_proposal_ready is True
|
|
assert ctx.last_test_ok is True
|
|
assert ctx.last_full_workflow_test_ok is True
|
|
assert ctx.last_test_suspicious_success is False
|
|
assert ctx.last_test_failure_reason is None
|
|
|
|
|
|
def test_record_run_blocks_result_resets_stale_verified_terminal_proposal_latch() -> None:
|
|
ctx = _fresh_ctx_for_record()
|
|
ctx.verified_terminal_proposal_ready = True
|
|
result = {
|
|
"ok": True,
|
|
"data": {
|
|
"workflow_run_id": "wr_unverified",
|
|
"overall_status": "completed",
|
|
"executed_block_labels": [],
|
|
"blocks": [],
|
|
"output": {},
|
|
},
|
|
}
|
|
|
|
_record_run_blocks_result(ctx, result, completion_verification=None)
|
|
|
|
assert ctx.verified_terminal_proposal_ready is False
|
|
|
|
|
|
def test_enforcement_stops_after_verified_terminal_proposal() -> None:
|
|
ctx = _Ctx()
|
|
ctx.verified_terminal_proposal_ready = True
|
|
ctx.last_test_ok = True
|
|
ctx.last_full_workflow_test_ok = True
|
|
ctx.completion_verification_result = CompletionVerificationResult(
|
|
status="evaluated",
|
|
criterion_ids=["c0"],
|
|
verdicts=[CriterionVerdict(criterion_id="c0", state="satisfied", reason_code="evidence_confirms")],
|
|
)
|
|
ctx.last_test_suspicious_success = True
|
|
|
|
with pytest.raises(CopilotGoalSatisfied):
|
|
_check_enforcement(ctx)
|
|
|
|
|
|
def test_verified_outcome_out_orders_same_turn_involuntary_blocker() -> None:
|
|
ctx = _Ctx()
|
|
ctx.completion_verification_result = CompletionVerificationResult(
|
|
status="evaluated",
|
|
criterion_ids=["c0"],
|
|
verdicts=[CriterionVerdict(criterion_id="c0", state="satisfied", reason_code="evidence_confirms")],
|
|
)
|
|
involuntary = CopilotToolBlockerSignal(
|
|
blocker_kind="loop_detected",
|
|
agent_steering_text="repeated failed step",
|
|
user_facing_reason="I'm stuck retrying the same step.",
|
|
recovery_hint="report_blocker_to_user",
|
|
cleared_by_tools=frozenset(),
|
|
preserves_workflow_draft=True,
|
|
renders_final_reply=True,
|
|
internal_reason_code="loop_detected_repeated_failed_step",
|
|
blocked_tool="update_and_run_blocks",
|
|
extra={},
|
|
)
|
|
ctx.turn_halt = None
|
|
ctx.blocker_signal = involuntary
|
|
ctx.latest_tool_blocker_signal = involuntary
|
|
|
|
with pytest.raises(CopilotGoalSatisfied):
|
|
_check_enforcement(ctx)
|
|
|
|
assert ctx.turn_halt is None
|
|
assert ctx.blocker_signal is None
|
|
|
|
|
|
def test_record_run_blocks_result_keeps_failure_when_watchdog_cancel_without_timeout() -> None:
|
|
"""Stagnation/ceiling cancels mid-session must still set last_test_ok=False
|
|
so the failed-test nudge can fire — only a coincident total timeout softens
|
|
to ``None`` for the unvalidated WIP rescue path."""
|
|
ctx = _fresh_ctx_for_record()
|
|
result = {
|
|
"ok": False,
|
|
"error": "Run ID: wr_stagnation. Stuck.",
|
|
_INTERNAL_RUN_CANCELLED_BY_WATCHDOG_KEY: True,
|
|
}
|
|
|
|
_record_run_blocks_result(ctx, result)
|
|
|
|
assert ctx.last_test_ok is False
|
|
assert ctx.last_test_failure_reason == "Run ID: wr_stagnation. Stuck."
|
|
|
|
|
|
def test_record_run_blocks_result_sets_last_test_ok_none_on_watchdog_cancel_at_timeout() -> None:
|
|
ctx = _fresh_ctx_for_record()
|
|
ctx.copilot_total_timeout_exceeded = True
|
|
result = {
|
|
"ok": False,
|
|
"error": "Run ID: wr_timeout. Outcome is uncertain.",
|
|
_INTERNAL_RUN_CANCELLED_BY_WATCHDOG_KEY: True,
|
|
}
|
|
|
|
_record_run_blocks_result(ctx, result)
|
|
|
|
assert ctx.last_test_ok is None
|
|
assert ctx.last_test_failure_reason == "Run ID: wr_timeout. Outcome is uncertain."
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Suspicious-success nudge
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_suspicious_success_fires_when_flag_set() -> None:
|
|
ctx = _Ctx()
|
|
ctx.last_test_suspicious_success = True
|
|
assert _needs_suspicious_success_nudge(ctx) is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool-output pruning
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _fco(call_id: str, output: str) -> dict:
|
|
return {"type": "function_call_output", "call_id": call_id, "output": output}
|
|
|
|
|
|
def test_recent_outputs_preserved_full() -> None:
|
|
# Build KEEP_RECENT_TOOL_OUTPUTS + 1 items so exactly one is "old".
|
|
items = []
|
|
short = '{"ok":true,"data":{"overall_status":"completed"}}'
|
|
for i in range(KEEP_RECENT_TOOL_OUTPUTS + 1):
|
|
items.append(_fco(f"c{i}", short))
|
|
|
|
pruned = _prune_input_list(items)
|
|
# Each recent item is unchanged (they're all short and JSON).
|
|
for i in range(1, KEEP_RECENT_TOOL_OUTPUTS + 1):
|
|
assert pruned[i]["output"] == short
|
|
|
|
|
|
def test_old_large_output_is_summarized() -> None:
|
|
# An older, large JSON tool output gets compressed into a synopsis.
|
|
heavy_payload = {
|
|
"ok": True,
|
|
"data": {
|
|
"workflow_run_id": "wr_123",
|
|
"overall_status": "completed",
|
|
"blocks": [
|
|
{
|
|
"label": "open_quote_page",
|
|
"status": "completed",
|
|
"block_type": "GOTO_URL",
|
|
"extracted_data": None,
|
|
},
|
|
{
|
|
"label": "extract_stock_price",
|
|
"status": "completed",
|
|
"block_type": "EXTRACTION",
|
|
"extracted_data": {"price": None},
|
|
"failure_reason": None,
|
|
},
|
|
],
|
|
"visible_elements_html": "<html>" + ("x" * 4000) + "</html>",
|
|
"screenshot_base64": "[base64 image omitted]",
|
|
},
|
|
}
|
|
heavy_output = json.dumps(heavy_payload)
|
|
assert len(heavy_output) > 4000
|
|
|
|
items = [_fco("c_old", heavy_output)]
|
|
# Add enough recent outputs to push the first one out of the recent window.
|
|
for i in range(KEEP_RECENT_TOOL_OUTPUTS):
|
|
items.append(_fco(f"c_new_{i}", '{"ok":true,"data":{"overall_status":"completed"}}'))
|
|
|
|
pruned = _prune_input_list(items)
|
|
summarized = pruned[0]["output"]
|
|
# The summary must be drastically shorter than the original.
|
|
assert len(summarized) < 1000
|
|
# It must preserve the key signal fields so the agent can still reason about past calls.
|
|
parsed = json.loads(summarized)
|
|
assert parsed["ok"] is True
|
|
assert parsed["overall_status"] == "completed"
|
|
assert parsed["workflow_run_id"] == "wr_123"
|
|
assert parsed["_summarized"]
|
|
assert len(parsed["blocks"]) == 2
|
|
assert parsed["blocks"][1]["label"] == "extract_stock_price"
|
|
assert parsed["blocks"][1]["status"] == "completed"
|
|
|
|
|
|
def test_summarize_non_json_output_falls_back_to_head_truncation() -> None:
|
|
text = "not-json " * 1000
|
|
result = _summarize_tool_output(text)
|
|
assert len(result) < len(text)
|
|
assert result.startswith("not-json")
|
|
assert "older tool output truncated" in result
|
|
|
|
|
|
def test_summarize_short_output_is_unchanged() -> None:
|
|
assert _summarize_tool_output("small") == "small"
|
|
|
|
|
|
def test_recent_large_output_is_head_truncated_not_summarized() -> None:
|
|
# Big JSON in the most-recent slot should be head-truncated at 2000 chars,
|
|
# NOT replaced with a summary.
|
|
large = '{"ok":true,"data":{"value":"' + ("y" * 3000) + '"}}'
|
|
items = [_fco("c_recent", large)]
|
|
pruned = _prune_input_list(items)
|
|
out = pruned[0]["output"]
|
|
assert out.startswith('{"ok":true,')
|
|
assert out.endswith("\n... [truncated]")
|
|
assert len(out) <= 2020
|
|
|
|
|
|
class TestEnforcement:
|
|
def _make_ctx(self, **overrides: Any) -> Any:
|
|
"""Create a mock context with enforcement attributes."""
|
|
ctx = MagicMock()
|
|
ctx.navigate_called = False
|
|
ctx.observation_after_navigate = False
|
|
ctx.navigate_enforcement_done = False
|
|
ctx.update_workflow_called = False
|
|
ctx.test_after_update_done = False
|
|
ctx.post_update_nudge_count = 0
|
|
ctx.coverage_nudge_count = 0
|
|
ctx.format_nudge_count = 0
|
|
ctx.explore_without_workflow_nudge_count = 0
|
|
ctx.last_test_suspicious_success = False
|
|
ctx.last_test_anti_bot = None
|
|
ctx.last_failure_category_top = None
|
|
ctx.per_tool_budget_nudge_count = 0
|
|
for k, v in overrides.items():
|
|
setattr(ctx, k, v)
|
|
return ctx
|
|
|
|
@staticmethod
|
|
def _reply_result(user_response: str = "") -> Any:
|
|
"""Build a RunResultStreaming-shaped mock whose final_output parses as REPLY."""
|
|
import json
|
|
|
|
result = MagicMock()
|
|
result.final_output = json.dumps({"type": "REPLY", "user_response": user_response})
|
|
result.new_items = []
|
|
return result
|
|
|
|
@staticmethod
|
|
def _empty_result() -> Any:
|
|
"""Build a mock with no final text — triggers the 'not sure how to help' fallback."""
|
|
result = MagicMock()
|
|
result.final_output = None
|
|
result.new_items = []
|
|
return result
|
|
|
|
def test_no_enforcement_when_nothing_pending(self) -> None:
|
|
from skyvern.forge.sdk.copilot.enforcement import _check_enforcement
|
|
|
|
ctx = self._make_ctx()
|
|
assert _check_enforcement(ctx) is None
|
|
|
|
def test_post_navigate_nudge(self) -> None:
|
|
from skyvern.forge.sdk.copilot.enforcement import _check_enforcement
|
|
|
|
ctx = self._make_ctx(navigate_called=True, observation_after_navigate=False)
|
|
nudge = _check_enforcement(ctx)
|
|
assert nudge is not None
|
|
assert "observe" in nudge.lower() or "inspect" in nudge.lower()
|
|
assert ctx.navigate_enforcement_done is True
|
|
|
|
def test_post_navigate_only_fires_once(self) -> None:
|
|
from skyvern.forge.sdk.copilot.enforcement import _check_enforcement
|
|
|
|
ctx = self._make_ctx(
|
|
navigate_called=True,
|
|
observation_after_navigate=False,
|
|
navigate_enforcement_done=True,
|
|
)
|
|
assert _check_enforcement(ctx) is None
|
|
|
|
def test_post_update_nudge(self) -> None:
|
|
from skyvern.forge.sdk.copilot.enforcement import _check_enforcement
|
|
|
|
ctx = self._make_ctx(update_workflow_called=True, test_after_update_done=False)
|
|
nudge = _check_enforcement(ctx)
|
|
assert nudge is not None
|
|
assert "test" in nudge.lower() or "run_blocks" in nudge.lower()
|
|
|
|
def test_navigate_takes_priority_over_update(self) -> None:
|
|
from skyvern.forge.sdk.copilot.enforcement import _check_enforcement
|
|
|
|
ctx = self._make_ctx(
|
|
navigate_called=True,
|
|
observation_after_navigate=False,
|
|
update_workflow_called=True,
|
|
test_after_update_done=False,
|
|
)
|
|
nudge = _check_enforcement(ctx)
|
|
assert "observe" in nudge.lower() or "inspect" in nudge.lower()
|
|
|
|
def test_intermediate_success_nudge_for_multistep_goal(self) -> None:
|
|
from skyvern.forge.sdk.copilot.enforcement import _check_enforcement
|
|
|
|
ctx = self._make_ctx(
|
|
update_workflow_called=True,
|
|
test_after_update_done=True,
|
|
last_test_ok=True,
|
|
last_update_block_count=1,
|
|
user_message="Go to france.fr and then download all french regulations",
|
|
coverage_nudge_count=0,
|
|
)
|
|
from skyvern.forge.sdk.copilot.enforcement import POST_INTERMEDIATE_SUCCESS_NUDGE
|
|
|
|
# Coverage gate only fires when the model tries to emit a REPLY.
|
|
nudge = _check_enforcement(ctx, self._reply_result("draft response"))
|
|
assert nudge == POST_INTERMEDIATE_SUCCESS_NUDGE
|
|
assert ctx.coverage_nudge_count == 1
|
|
|
|
def test_no_intermediate_success_nudge_for_single_step_goal(self) -> None:
|
|
from skyvern.forge.sdk.copilot.enforcement import _check_enforcement
|
|
|
|
ctx = self._make_ctx(
|
|
update_workflow_called=True,
|
|
test_after_update_done=True,
|
|
last_test_ok=True,
|
|
last_update_block_count=1,
|
|
user_message="Go to france.fr",
|
|
coverage_nudge_count=0,
|
|
)
|
|
assert _check_enforcement(ctx, self._reply_result("done")) is None
|
|
|
|
def test_intermediate_success_nudge_fires_for_two_blocks(self) -> None:
|
|
"""Key regression: nudge must fire even when block_count > 1."""
|
|
from skyvern.forge.sdk.copilot.enforcement import _check_enforcement
|
|
|
|
ctx = self._make_ctx(
|
|
update_workflow_called=True,
|
|
test_after_update_done=True,
|
|
last_test_ok=True,
|
|
last_update_block_count=2,
|
|
user_message="Go to france.fr and then download all french regulations and extract the titles",
|
|
coverage_nudge_count=0,
|
|
)
|
|
from skyvern.forge.sdk.copilot.enforcement import POST_INTERMEDIATE_SUCCESS_NUDGE
|
|
|
|
nudge = _check_enforcement(ctx, self._reply_result("two-block draft"))
|
|
assert nudge == POST_INTERMEDIATE_SUCCESS_NUDGE
|
|
|
|
def test_intermediate_nudge_respects_global_cap(self) -> None:
|
|
from skyvern.forge.sdk.copilot.enforcement import MAX_INTERMEDIATE_NUDGES, _check_enforcement
|
|
|
|
ctx = self._make_ctx(
|
|
update_workflow_called=True,
|
|
test_after_update_done=True,
|
|
last_test_ok=True,
|
|
last_update_block_count=2,
|
|
user_message="Go to france.fr and then download all french regulations",
|
|
coverage_nudge_count=MAX_INTERMEDIATE_NUDGES,
|
|
)
|
|
assert _check_enforcement(ctx, self._reply_result("capped")) is None
|
|
|
|
def test_intermediate_nudge_does_not_fire_for_ten_plus_blocks(self) -> None:
|
|
from skyvern.forge.sdk.copilot.enforcement import _check_enforcement
|
|
|
|
ctx = self._make_ctx(
|
|
update_workflow_called=True,
|
|
test_after_update_done=True,
|
|
last_test_ok=True,
|
|
last_update_block_count=10,
|
|
user_message="Go to france.fr and then download all french regulations",
|
|
coverage_nudge_count=0,
|
|
)
|
|
assert _check_enforcement(ctx, self._reply_result("ten blocks")) is None
|
|
|
|
def test_ask_question_always_passes_even_with_coverage_gap(self) -> None:
|
|
"""Regression guard: ASK_QUESTION must never be blocked by coverage."""
|
|
import json
|
|
|
|
from skyvern.forge.sdk.copilot.enforcement import _check_enforcement
|
|
|
|
ctx = self._make_ctx(
|
|
update_workflow_called=True,
|
|
test_after_update_done=True,
|
|
last_test_ok=True,
|
|
last_update_block_count=1,
|
|
user_message="Go to france.fr and then download all french regulations",
|
|
coverage_nudge_count=0,
|
|
)
|
|
ask = MagicMock()
|
|
ask.final_output = json.dumps({"type": "ASK_QUESTION", "user_response": "Which source?"})
|
|
ask.new_items = []
|
|
assert _check_enforcement(ctx, ask) is None
|
|
|
|
def test_plain_labeled_ask_question_passes_even_with_coverage_gap(self) -> None:
|
|
from skyvern.forge.sdk.copilot.enforcement import _check_enforcement
|
|
|
|
ctx = self._make_ctx(
|
|
update_workflow_called=True,
|
|
test_after_update_done=True,
|
|
last_test_ok=True,
|
|
last_update_block_count=1,
|
|
user_message="Go to france.fr and then download all french regulations",
|
|
coverage_nudge_count=0,
|
|
)
|
|
ask = MagicMock()
|
|
ask.final_output = "ASK_QUESTION\nWhich source?"
|
|
ask.new_items = []
|
|
assert _check_enforcement(ctx, ask) is None
|
|
|
|
def test_explore_without_workflow_nudge(self) -> None:
|
|
from skyvern.forge.sdk.copilot.enforcement import POST_EXPLORE_WITHOUT_WORKFLOW_NUDGE, _check_enforcement
|
|
|
|
ctx = self._make_ctx(
|
|
navigate_called=True,
|
|
observation_after_navigate=True,
|
|
update_workflow_called=False,
|
|
test_after_update_done=False,
|
|
)
|
|
nudge = _check_enforcement(ctx)
|
|
assert nudge == POST_EXPLORE_WITHOUT_WORKFLOW_NUDGE
|
|
assert ctx.explore_without_workflow_nudge_count == 1
|
|
|
|
def test_explore_without_workflow_not_when_update_called(self) -> None:
|
|
from skyvern.forge.sdk.copilot.enforcement import (
|
|
POST_EXPLORE_WITHOUT_WORKFLOW_NUDGE,
|
|
POST_UPDATE_NUDGE,
|
|
_check_enforcement,
|
|
)
|
|
|
|
ctx = self._make_ctx(
|
|
navigate_called=True,
|
|
observation_after_navigate=True,
|
|
update_workflow_called=True,
|
|
test_after_update_done=False,
|
|
)
|
|
nudge = _check_enforcement(ctx)
|
|
assert nudge == POST_UPDATE_NUDGE
|
|
assert nudge != POST_EXPLORE_WITHOUT_WORKFLOW_NUDGE
|
|
assert ctx.explore_without_workflow_nudge_count == 0
|
|
|
|
def test_update_without_test_allowed_for_explicit_untested_draft(self) -> None:
|
|
from skyvern.forge.sdk.copilot.enforcement import _check_enforcement
|
|
|
|
ctx = self._make_ctx(
|
|
allow_untested_workflow_draft=True,
|
|
update_workflow_called=True,
|
|
test_after_update_done=False,
|
|
)
|
|
|
|
assert _check_enforcement(ctx) is None
|
|
|
|
def test_explore_without_workflow_not_when_test_done(self) -> None:
|
|
from skyvern.forge.sdk.copilot.enforcement import POST_EXPLORE_WITHOUT_WORKFLOW_NUDGE, _check_enforcement
|
|
|
|
ctx = self._make_ctx(
|
|
navigate_called=True,
|
|
observation_after_navigate=True,
|
|
update_workflow_called=False,
|
|
test_after_update_done=True,
|
|
)
|
|
nudge = _check_enforcement(ctx)
|
|
assert nudge != POST_EXPLORE_WITHOUT_WORKFLOW_NUDGE
|
|
|
|
def test_explore_without_workflow_respects_cap(self) -> None:
|
|
from skyvern.forge.sdk.copilot.enforcement import (
|
|
MAX_EXPLORE_WITHOUT_WORKFLOW_NUDGES,
|
|
POST_EXPLORE_WITHOUT_WORKFLOW_NUDGE,
|
|
_check_enforcement,
|
|
)
|
|
|
|
ctx = self._make_ctx(
|
|
navigate_called=True,
|
|
observation_after_navigate=True,
|
|
update_workflow_called=False,
|
|
test_after_update_done=False,
|
|
explore_without_workflow_nudge_count=MAX_EXPLORE_WITHOUT_WORKFLOW_NUDGES,
|
|
)
|
|
nudge = _check_enforcement(ctx)
|
|
assert nudge != POST_EXPLORE_WITHOUT_WORKFLOW_NUDGE
|
|
|
|
def test_explore_without_workflow_not_without_observation(self) -> None:
|
|
from skyvern.forge.sdk.copilot.enforcement import POST_EXPLORE_WITHOUT_WORKFLOW_NUDGE, _check_enforcement
|
|
|
|
ctx = self._make_ctx(
|
|
navigate_called=True,
|
|
observation_after_navigate=False,
|
|
update_workflow_called=False,
|
|
test_after_update_done=False,
|
|
)
|
|
nudge = _check_enforcement(ctx)
|
|
# Should get navigate nudge, not explore-without-workflow
|
|
assert nudge != POST_EXPLORE_WITHOUT_WORKFLOW_NUDGE
|
|
assert ctx.explore_without_workflow_nudge_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_navigate_nudge_does_not_increment_post_update_counter(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from skyvern.forge.sdk.copilot.enforcement import run_with_enforcement
|
|
|
|
ctx = self._make_ctx(
|
|
navigate_called=True,
|
|
observation_after_navigate=False,
|
|
update_workflow_called=False,
|
|
post_update_nudge_count=0,
|
|
)
|
|
stream = MagicMock()
|
|
stream.is_disconnected = AsyncMock(return_value=False)
|
|
|
|
call_count = {"count": 0}
|
|
|
|
# final_output=None + new_items=[] makes extract_final_text return "",
|
|
# which parses to a REPLY fallback — safe for the response-peek path
|
|
# when the state-based branches may or may not short-circuit first.
|
|
fake_result = self._empty_result()
|
|
fake_result.to_input_list.return_value = []
|
|
|
|
def fake_run_streamed(*args: Any, **kwargs: Any) -> Any:
|
|
call_count["count"] += 1
|
|
return fake_result
|
|
|
|
async def fake_stream_to_sse(result: Any, s: Any, c: Any) -> None:
|
|
# Resolve post-navigate enforcement on second pass.
|
|
if call_count["count"] >= 2:
|
|
c.observation_after_navigate = True
|
|
|
|
monkeypatch.setattr("skyvern.forge.sdk.copilot.enforcement.Runner.run_streamed", fake_run_streamed)
|
|
monkeypatch.setattr(
|
|
"skyvern.forge.sdk.copilot.streaming_adapter.stream_to_sse",
|
|
fake_stream_to_sse,
|
|
)
|
|
|
|
returned = await run_with_enforcement(
|
|
agent=MagicMock(),
|
|
initial_input="hello",
|
|
ctx=ctx,
|
|
stream=stream,
|
|
)
|
|
assert returned is fake_result
|
|
assert ctx.post_update_nudge_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_update_nudge_increments_counter(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from skyvern.forge.sdk.copilot.enforcement import run_with_enforcement
|
|
|
|
ctx = self._make_ctx(
|
|
update_workflow_called=True,
|
|
test_after_update_done=False,
|
|
post_update_nudge_count=0,
|
|
)
|
|
stream = MagicMock()
|
|
stream.is_disconnected = AsyncMock(return_value=False)
|
|
|
|
call_count = {"count": 0}
|
|
fake_result = self._empty_result()
|
|
fake_result.to_input_list.return_value = []
|
|
|
|
def fake_run_streamed(*args: Any, **kwargs: Any) -> Any:
|
|
call_count["count"] += 1
|
|
return fake_result
|
|
|
|
async def fake_stream_to_sse(result: Any, s: Any, c: Any) -> None:
|
|
# Resolve post-update enforcement on second pass.
|
|
if call_count["count"] >= 2:
|
|
c.test_after_update_done = True
|
|
|
|
monkeypatch.setattr("skyvern.forge.sdk.copilot.enforcement.Runner.run_streamed", fake_run_streamed)
|
|
monkeypatch.setattr(
|
|
"skyvern.forge.sdk.copilot.streaming_adapter.stream_to_sse",
|
|
fake_stream_to_sse,
|
|
)
|
|
|
|
returned = await run_with_enforcement(
|
|
agent=MagicMock(),
|
|
initial_input="hello",
|
|
ctx=ctx,
|
|
stream=stream,
|
|
)
|
|
assert returned is fake_result
|
|
assert ctx.post_update_nudge_count == 1
|
|
|
|
|
|
class TestGoalLikelyNeedsMoreBlocks:
|
|
@staticmethod
|
|
def _check(user_message: str, block_count: int, completion_contract: str | None = None) -> bool:
|
|
from skyvern.forge.sdk.copilot.enforcement import _goal_likely_needs_more_blocks
|
|
|
|
return _goal_likely_needs_more_blocks(user_message, block_count, completion_contract)
|
|
|
|
def test_navigate_and_download_needs_two(self) -> None:
|
|
assert self._check("Go to france.fr and then download regulations", 1) is True
|
|
assert self._check("Go to france.fr and then download regulations", 2) is False
|
|
|
|
def test_login_search_and_extract_needs_three(self) -> None:
|
|
assert self._check("Login to the site, search for products, and extract prices", 1) is True
|
|
assert self._check("Login to the site, search for products, and extract prices", 2) is True
|
|
assert self._check("Login to the site, search for products, and extract prices", 3) is False
|
|
|
|
def test_single_action_does_not_need_more(self) -> None:
|
|
assert self._check("Go to france.fr", 1) is False
|
|
|
|
def test_completion_contract_does_not_force_extra_blocks_after_success(self) -> None:
|
|
user_message = "Go to example.com/contact, fill out the form, and submit it."
|
|
assert self._check(user_message, 1, "confirmation banner appears") is False
|
|
|
|
def test_completion_contract_still_requires_sequential_blocks(self) -> None:
|
|
user_message = "Go to example.com and then download the report."
|
|
assert self._check(user_message, 1, "download starts") is True
|
|
assert self._check(user_message, 2, "download starts") is False
|
|
|
|
def test_sequential_connector_needs_at_least_two(self) -> None:
|
|
assert self._check("Do X and then do Y", 1) is True
|
|
|
|
def test_ten_plus_blocks_always_false(self) -> None:
|
|
assert self._check("Go to X and then download Y and extract Z", 10) is False
|
|
|
|
def test_non_string_returns_false(self) -> None:
|
|
assert self._check(None, 1) is False # type: ignore[arg-type]
|
|
assert self._check(123, 1) is False # type: ignore[arg-type]
|
|
|
|
|
|
def _criterion(output_path: str, outcome: str) -> CompletionCriterion:
|
|
return CompletionCriterion(id=output_path, outcome=outcome, output_path=output_path)
|
|
|
|
|
|
def _turn_state(*criteria: CompletionCriterion) -> SimpleNamespace:
|
|
return SimpleNamespace(decision=SimpleNamespace(criteria=tuple(criteria)))
|
|
|
|
|
|
def _download_target() -> ReachedDownloadTarget:
|
|
return ReachedDownloadTarget(
|
|
selector="a.download",
|
|
affordance_text="Download",
|
|
download_kind="registered",
|
|
source_step="trajectory_recency",
|
|
already_registered=True,
|
|
)
|
|
|
|
|
|
def _entry_commit_trajectory() -> list[dict[str, object]]:
|
|
return [
|
|
{"tool_name": "type_text", "selector": "input[name='q']", "accessible_name": "Order number"},
|
|
{"tool_name": "click", "selector": "button[data-action='search']", "accessible_name": "Search"},
|
|
]
|
|
|
|
|
|
def _author_time_reject_outcome(*output_paths: str) -> RecordedBuildTestOutcome:
|
|
paths = sorted(output_paths)
|
|
return recorded_outcome_from_author_time_reject(
|
|
reason_code="metadata_reject",
|
|
block_labels=["extract_order"],
|
|
structural_payload={
|
|
"reason_code": "recorded_outcome_missing_output_coverage",
|
|
"missing_output_paths": paths,
|
|
"block_labels": ["extract_order"],
|
|
"recorded_reason_code": "outcome_not_demonstrated",
|
|
},
|
|
observed_evidence_summary="missing requested output coverage",
|
|
missing_requested_output_facts=[
|
|
{
|
|
"output_path": path,
|
|
"output_root": path.split(".", 1)[0],
|
|
"reason_code": "recorded_outcome_missing_output_coverage",
|
|
"value_status": "no_typed_value",
|
|
}
|
|
for path in paths
|
|
],
|
|
)
|
|
|
|
|
|
class TestScoutOutputCoverageGate:
|
|
def _authoring_ctx(self, *criteria: CompletionCriterion) -> _Ctx:
|
|
ctx = _Ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=True),
|
|
)
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
ctx.scout_trajectory = _entry_commit_trajectory()
|
|
ctx.reached_download_target = None
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = len(ctx.scout_trajectory)
|
|
ctx.completion_criteria_turn_state = _turn_state(*criteria)
|
|
ctx.synthesized_block_offered_goal_complete = synthesized_trajectory_is_goal_complete(ctx)
|
|
return ctx
|
|
|
|
def test_empty_output_set_falls_through_to_shape_heuristic(self) -> None:
|
|
ctx = self._authoring_ctx()
|
|
assert uncovered_requested_output_paths(ctx) == set()
|
|
assert synthesized_trajectory_is_goal_complete(ctx) is True
|
|
|
|
def test_post_run_only_evidence_source_is_exempt_from_pre_run_gate(self) -> None:
|
|
registered = CompletionCriterion(
|
|
id="output.confirmation_number",
|
|
outcome="the confirmation number is registered as a workflow output parameter",
|
|
output_path="output.confirmation_number",
|
|
requested_output_evidence_source="registered_output_parameter",
|
|
)
|
|
ctx = self._authoring_ctx(registered)
|
|
assert uncovered_requested_output_paths(ctx) == set()
|
|
assert synthesized_trajectory_is_goal_complete(ctx) is True
|
|
|
|
def test_runtime_output_stays_gated_alongside_exempt_source(self) -> None:
|
|
registered = CompletionCriterion(
|
|
id="output.confirmation_number",
|
|
outcome="the confirmation number is registered as a workflow output parameter",
|
|
output_path="output.confirmation_number",
|
|
requested_output_evidence_source="registered_artifact_content",
|
|
)
|
|
runtime = _criterion("output.document_name", "the order status document name is captured")
|
|
ctx = self._authoring_ctx(registered, runtime)
|
|
assert uncovered_requested_output_paths(ctx) == {"output.document_name"}
|
|
assert synthesized_trajectory_is_goal_complete(ctx) is False
|
|
|
|
def test_uncovered_output_keeps_gate_open_and_admits_scout_tools(self) -> None:
|
|
ctx = self._authoring_ctx(_criterion("output.document_name", "the order status document name is captured"))
|
|
assert uncovered_requested_output_paths(ctx) == {"output.document_name"}
|
|
assert synthesized_trajectory_is_goal_complete(ctx) is False
|
|
assert _should_force_synthesized_block_persistence(ctx) is False
|
|
assert synthesized_block_persistence_signal(ctx, "evaluate") is None
|
|
assert _should_block_mutating_tool_after_synthesized_offer(ctx, "click") is False
|
|
assert synthesized_block_persistence_signal(ctx, "click") is None
|
|
|
|
def test_value_bearing_container_covers_path_and_force_fires(self) -> None:
|
|
ctx = self._authoring_ctx(_criterion("output.document_name", "the order status document name is captured"))
|
|
page_evidence = {
|
|
"result_containers": [
|
|
{"text_excerpt": "Document Name Resale Certificate 2024 for order 5591"},
|
|
]
|
|
}
|
|
record_scouted_output_coverage(ctx, page_evidence)
|
|
assert ctx.scouted_output_covered_paths == {"output.document_name"}
|
|
assert uncovered_requested_output_paths(ctx) == set()
|
|
ctx.synthesized_block_offered_goal_complete = synthesized_trajectory_is_goal_complete(ctx)
|
|
assert synthesized_trajectory_is_goal_complete(ctx) is True
|
|
assert _should_force_synthesized_block_persistence(ctx) is True
|
|
|
|
def test_empty_shell_selector_tokens_do_not_credit(self) -> None:
|
|
ctx = self._authoring_ctx(_criterion("output.document_name", "the order status document name is captured"))
|
|
page_evidence = {
|
|
"result_containers": [
|
|
{
|
|
"selector": "#document-name-table",
|
|
"row_selector": "tr.document",
|
|
"text_excerpt": "Search results loaded for widgets",
|
|
}
|
|
]
|
|
}
|
|
record_scouted_output_coverage(ctx, page_evidence)
|
|
assert uncovered_requested_output_paths(ctx) == {"output.document_name"}
|
|
|
|
def test_registered_download_covered_by_reached_download_target(self) -> None:
|
|
ctx = self._authoring_ctx(_criterion("output.downloaded_files", "the downloaded files are captured"))
|
|
ctx.reached_download_target = _download_target()
|
|
assert uncovered_requested_output_paths(ctx) == set()
|
|
|
|
def test_download_target_covers_only_registered_download_paths(self) -> None:
|
|
ctx = self._authoring_ctx(
|
|
_criterion("output.downloaded_files", "the downloaded files are captured"),
|
|
_criterion("output.document_name", "the order status document name is captured"),
|
|
)
|
|
ctx.reached_download_target = _download_target()
|
|
assert uncovered_requested_output_paths(ctx) == {"output.document_name"}
|
|
|
|
def test_unreachable_output_never_completes_on_long_trajectory(self) -> None:
|
|
ctx = self._authoring_ctx(_criterion("output.document_name", "the order status document name is captured"))
|
|
ctx.scout_trajectory = _entry_commit_trajectory() * 12
|
|
ctx.synthesized_block_offered_trajectory_len = len(ctx.scout_trajectory)
|
|
assert synthesized_trajectory_is_goal_complete(ctx) is False
|
|
|
|
def test_none_criteria_source_shapes_are_byte_identical(self) -> None:
|
|
ctx = self._authoring_ctx()
|
|
ctx.completion_criteria_turn_state = None
|
|
assert uncovered_requested_output_paths(ctx) == set()
|
|
ctx.completion_criteria_turn_state = SimpleNamespace(decision=None)
|
|
assert uncovered_requested_output_paths(ctx) == set()
|
|
|
|
def test_all_generic_token_path_is_exempt_and_falls_through_to_shape(self) -> None:
|
|
ctx = self._authoring_ctx(_criterion("output.data", "the data is captured"))
|
|
assert uncovered_requested_output_paths(ctx) == set()
|
|
ctx.synthesized_block_offered_goal_complete = synthesized_trajectory_is_goal_complete(ctx)
|
|
assert synthesized_trajectory_is_goal_complete(ctx) is True
|
|
assert _should_force_synthesized_block_persistence(ctx) is True
|
|
|
|
def test_generic_path_exemption_keeps_specific_path_gating(self) -> None:
|
|
ctx = self._authoring_ctx(
|
|
_criterion("output.data", "the data is captured"),
|
|
_criterion("output.document_name", "the order status document name is captured"),
|
|
)
|
|
assert uncovered_requested_output_paths(ctx) == {"output.document_name"}
|
|
|
|
def test_repair_context_required_goal_value_paths_join_requested_set(self) -> None:
|
|
ctx = self._authoring_ctx()
|
|
ctx.last_code_authoring_repair_context = CodeAuthoringRepairContext(
|
|
block_label="extract_order",
|
|
reason_code="metadata_reject",
|
|
required_goal_value_paths=["document_name"],
|
|
)
|
|
assert uncovered_requested_output_paths(ctx) == {"output.document_name"}
|
|
record_scouted_output_coverage(
|
|
ctx, {"result_containers": [{"text_excerpt": "Document Name Resale Certificate 2024"}]}
|
|
)
|
|
assert uncovered_requested_output_paths(ctx) == set()
|
|
|
|
def test_reopen_fires_after_prior_run_then_author_reject(self) -> None:
|
|
ctx = self._authoring_ctx(_criterion("output.document_name", "the order status document name is captured"))
|
|
ctx.update_workflow_called = True
|
|
ctx.last_run_blocks_workflow_run_id = "wr_prior_run"
|
|
ctx.latest_recorded_build_test_outcome = _author_time_reject_outcome("output.document_name")
|
|
assert _uncovered_output_reject_admits_evaluate(ctx, "evaluate") is True
|
|
assert consume_uncovered_output_reopen_event(ctx) is True
|
|
assert ctx.synthesized_block_reopened_for_output_coverage is True
|
|
|
|
def test_stream_recorded_authoring_success_clears_latch_and_steer_key(self) -> None:
|
|
ctx = _Ctx()
|
|
ctx.synthesized_block_reopened_for_output_coverage = True
|
|
ctx.uncovered_output_rescout_steer_key = "steer-key"
|
|
ctx.uncovered_output_rescout_context_key = "context-key"
|
|
_update_enforcement_from_tool(ctx, "update_workflow", {"ok": True, "data": {"block_count": 1}})
|
|
assert ctx.synthesized_block_reopened_for_output_coverage is False
|
|
assert ctx.uncovered_output_rescout_steer_key is None
|
|
assert ctx.uncovered_output_rescout_context_key == "context-key"
|
|
|
|
def test_recorded_workflow_update_clears_latch_and_steer_key(self) -> None:
|
|
ctx = make_copilot_context("title: Updated")
|
|
ctx.synthesized_block_reopened_for_output_coverage = True
|
|
ctx.uncovered_output_rescout_steer_key = "steer-key"
|
|
ctx.uncovered_output_rescout_context_key = "context-key"
|
|
_record_workflow_update_result(
|
|
ctx,
|
|
{
|
|
"ok": True,
|
|
"data": {"block_count": 1},
|
|
"_workflow": SimpleNamespace(workflow_definition=SimpleNamespace(blocks=[SimpleNamespace()])),
|
|
},
|
|
)
|
|
assert ctx.synthesized_block_reopened_for_output_coverage is False
|
|
assert ctx.uncovered_output_rescout_steer_key is None
|
|
assert ctx.uncovered_output_rescout_context_key == "context-key"
|
|
|
|
def test_accessor_empty_when_no_recorded_outcome(self) -> None:
|
|
assert author_time_reject_missing_output_paths(None) == set()
|
|
|
|
def test_fact_paths_canonicalize_into_uncovered_set(self) -> None:
|
|
ctx = self._authoring_ctx(_criterion("output.document_name", "the order status document name is captured"))
|
|
outcome = _author_time_reject_outcome("output.document_name")
|
|
canonical = {_canonical_output_path(path) for path in author_time_reject_missing_output_paths(outcome)}
|
|
assert canonical & uncovered_requested_output_paths(ctx) == {"output.document_name"}
|
|
|
|
def test_admission_allows_evaluate_while_uncovered_output_reject_active(self) -> None:
|
|
ctx = self._authoring_ctx(_criterion("output.document_name", "the order status document name is captured"))
|
|
ctx.update_workflow_called = True
|
|
ctx.latest_recorded_build_test_outcome = _author_time_reject_outcome("output.document_name")
|
|
assert _uncovered_output_reject_admits_evaluate(ctx, "evaluate") is True
|
|
assert _uncovered_output_reject_admits_evaluate(ctx, "evaluate") is True
|
|
assert synthesized_block_persistence_signal(ctx, "evaluate") is None
|
|
|
|
def test_consume_reopen_event_arms_latch_fire_once(self) -> None:
|
|
ctx = self._authoring_ctx(_criterion("output.document_name", "the order status document name is captured"))
|
|
ctx.update_workflow_called = True
|
|
ctx.latest_recorded_build_test_outcome = _author_time_reject_outcome("output.document_name")
|
|
assert consume_uncovered_output_reopen_event(ctx) is True
|
|
assert ctx.synthesized_block_reopened_for_output_coverage is True
|
|
assert synthesized_persistence_reopened(ctx) is True
|
|
assert consume_uncovered_output_reopen_event(ctx) is False
|
|
|
|
def test_steer_redirects_reauthor_to_scout_once_then_lets_through(self) -> None:
|
|
ctx = self._authoring_ctx(_criterion("output.document_name", "the order status document name is captured"))
|
|
ctx.update_workflow_called = True
|
|
ctx.latest_recorded_build_test_outcome = _author_time_reject_outcome("output.document_name")
|
|
consume_uncovered_output_reopen_event(ctx)
|
|
steer = uncovered_output_reject_scout_steer_signal(ctx, "update_and_run_blocks")
|
|
assert isinstance(steer, CopilotToolBlockerSignal)
|
|
assert steer.cleared_by_tools == frozenset({"evaluate"})
|
|
assert steer.renders_final_reply is False
|
|
assert "output.document_name" in steer.agent_steering_text
|
|
assert steer.extra["uncovered_output_paths"] == ["output.document_name"]
|
|
assert uncovered_output_reject_scout_steer_signal(ctx, "update_and_run_blocks") is None
|
|
|
|
def test_steer_redirects_update_workflow_reauthor_before_clear_consumes_reopen(self) -> None:
|
|
ctx = self._authoring_ctx(_criterion("output.document_name", "the order status document name is captured"))
|
|
ctx.update_workflow_called = True
|
|
ctx.latest_recorded_build_test_outcome = _author_time_reject_outcome("output.document_name")
|
|
consume_uncovered_output_reopen_event(ctx)
|
|
steer = uncovered_output_reject_scout_steer_signal(ctx, "update_workflow")
|
|
assert isinstance(steer, CopilotToolBlockerSignal)
|
|
assert steer.cleared_by_tools == frozenset({"evaluate"})
|
|
assert steer.blocked_tool == "update_workflow"
|
|
|
|
def test_log_only_rescout_steer_records_without_consuming(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(settings, "ENV", "local")
|
|
monkeypatch.setattr(settings, "WORKFLOW_COPILOT_AUTHOR_TIME_GATE_LOG_ONLY", True)
|
|
ctx = self._authoring_ctx(_criterion("output.document_name", "the order status document name is captured"))
|
|
ctx.update_workflow_called = True
|
|
ctx.latest_recorded_build_test_outcome = _author_time_reject_outcome("output.document_name")
|
|
consume_uncovered_output_reopen_event(ctx)
|
|
|
|
steer = uncovered_output_reject_scout_steer_signal(ctx, "update_workflow")
|
|
|
|
assert steer is None
|
|
assert ctx.uncovered_output_rescout_steer_key is None
|
|
event = ctx.author_time_gate_ablation_events[-1]
|
|
assert event.gate_id == "uncovered_output_rescout_steer"
|
|
assert event.reason_code == UNCOVERED_OUTPUT_RESCOUT_STEER_REASON_CODE
|
|
assert event.blocked_tool == "update_workflow"
|
|
assert event.fingerprint == (
|
|
f"{ctx.latest_recorded_build_test_outcome.structural_failure_identity}|output.document_name"
|
|
)
|
|
assert event.log_only is True
|
|
|
|
def test_steer_inert_without_reopen_latch(self) -> None:
|
|
ctx = self._authoring_ctx(_criterion("output.document_name", "the order status document name is captured"))
|
|
ctx.latest_recorded_build_test_outcome = _author_time_reject_outcome("output.document_name")
|
|
assert uncovered_output_reject_scout_steer_signal(ctx, "update_and_run_blocks") is None
|
|
|
|
def test_no_contradictory_blockers_steer_and_force_persist_disjoint(self) -> None:
|
|
ctx = self._authoring_ctx(_criterion("output.document_name", "the order status document name is captured"))
|
|
ctx.update_workflow_called = True
|
|
ctx.latest_recorded_build_test_outcome = _author_time_reject_outcome("output.document_name")
|
|
consume_uncovered_output_reopen_event(ctx)
|
|
assert uncovered_output_reject_scout_steer_signal(ctx, "update_and_run_blocks") is not None
|
|
assert _should_force_synthesized_block_persistence(ctx) is False
|
|
ctx.scouted_output_covered_paths = {"output.document_name"}
|
|
ctx.uncovered_output_rescout_steer_key = None
|
|
assert uncovered_output_reject_scout_steer_signal(ctx, "update_and_run_blocks") is None
|
|
|
|
def test_persisted_run_outcome_does_not_trigger_author_reopen(self) -> None:
|
|
ctx = self._authoring_ctx(_criterion("output.document_name", "the order status document name is captured"))
|
|
ctx.completion_verification_result = TestSynthesizedOfferPersistenceGate._unsatisfied_verification()
|
|
ctx.latest_recorded_build_test_outcome = RecordedBuildTestOutcome(
|
|
phase="persisted_block_run",
|
|
attempted_tool="update_and_run_blocks",
|
|
verdict="repairable_failure",
|
|
reason_code="outcome_not_demonstrated",
|
|
structural_failure_identity="completion:unsatisfied-output",
|
|
missing_requested_output_facts=[
|
|
{
|
|
"output_path": "output.document_name",
|
|
"output_root": "output",
|
|
"reason_code": "outcome_not_demonstrated",
|
|
"value_status": "no_typed_value",
|
|
}
|
|
],
|
|
)
|
|
assert author_time_reject_missing_output_paths(ctx.latest_recorded_build_test_outcome) == set()
|
|
assert _uncovered_output_reject_admits_evaluate(ctx, "evaluate") is False
|
|
assert consume_uncovered_output_reopen_event(ctx) is False
|
|
assert ctx.synthesized_block_reopened_for_output_coverage is False
|
|
assert uncovered_output_reject_scout_steer_signal(ctx, "update_and_run_blocks") is None
|
|
assert isinstance(synthesized_block_persistence_signal(ctx, "click"), CopilotToolBlockerSignal)
|
|
|
|
def test_author_reject_inert_when_no_missing_output_facts(self) -> None:
|
|
ctx = self._authoring_ctx(_criterion("output.document_name", "the order status document name is captured"))
|
|
ctx.update_workflow_called = True
|
|
ctx.latest_recorded_build_test_outcome = recorded_outcome_from_author_time_reject(
|
|
reason_code="metadata_reject",
|
|
block_labels=["extract_order"],
|
|
structural_payload={"reason_code": "recorded_outcome_missing_output_coverage"},
|
|
)
|
|
assert _uncovered_output_reject_admits_evaluate(ctx, "evaluate") is False
|
|
assert consume_uncovered_output_reopen_event(ctx) is False
|
|
assert ctx.synthesized_block_reopened_for_output_coverage is False
|
|
|
|
def test_author_reject_inert_when_paths_already_covered(self) -> None:
|
|
ctx = self._authoring_ctx(_criterion("output.document_name", "the order status document name is captured"))
|
|
ctx.update_workflow_called = True
|
|
ctx.scouted_output_covered_paths = {"output.document_name"}
|
|
ctx.latest_recorded_build_test_outcome = _author_time_reject_outcome("output.document_name")
|
|
assert _uncovered_output_reject_admits_evaluate(ctx, "evaluate") is False
|
|
assert consume_uncovered_output_reopen_event(ctx) is False
|
|
|
|
def test_coverage_reopen_refreshes_synthesized_offer_after_authoring(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(
|
|
"skyvern.forge.sdk.copilot.enforcement.synthesize_code_block",
|
|
lambda *args, **kwargs: SynthesizedCodeBlock(code="await page.click('button')"),
|
|
)
|
|
ctx = self._authoring_ctx(_criterion("output.document_name", "the order status document name is captured"))
|
|
ctx.update_workflow_called = True
|
|
assert _maybe_synthesized_block_offer_msg(ctx) is None
|
|
ctx.synthesized_block_reopened_for_output_coverage = True
|
|
assert _maybe_synthesized_block_offer_msg(ctx) is not None
|
|
|
|
def test_post_hook_failure_rolls_back_coverage_credit(self) -> None:
|
|
assert "scouted_output_covered_paths" in _POST_HOOK_CONTEXT_ROLLBACK_FIELDS
|
|
assert "synthesized_block_reopened_for_output_coverage" in _POST_HOOK_CONTEXT_ROLLBACK_FIELDS
|
|
ctx = _Ctx()
|
|
ctx.scouted_output_covered_paths = {"output.document_name"}
|
|
ctx.synthesized_block_reopened_for_output_coverage = False
|
|
snapshot = _snapshot_post_hook_context(ctx)
|
|
ctx.scouted_output_covered_paths.add("output.leaked")
|
|
ctx.synthesized_block_reopened_for_output_coverage = True
|
|
_restore_post_hook_context(ctx, snapshot)
|
|
assert ctx.scouted_output_covered_paths == {"output.document_name"}
|
|
assert ctx.synthesized_block_reopened_for_output_coverage is False
|
|
|
|
|
|
class TestAdvisoryRunDispatchForceLane:
|
|
"""A granted output-contract advisory run is forced onto update_and_run_blocks through the same
|
|
tool_choice forcing lane as the synthesized-persistence force, and releases on consume or terminal."""
|
|
|
|
def _granted_ctx(self) -> _Ctx:
|
|
ctx = _Ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=True),
|
|
)
|
|
ctx.block_authoring_policy = BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
ctx.turn_halt = None
|
|
ctx.blocker_signal = None
|
|
ctx.output_contract_actuation_by_signature = {"sig_a": OutputContractAdvisoryState.GRANTED}
|
|
return ctx
|
|
|
|
def test_granted_advisory_forces_run_dispatch(self) -> None:
|
|
assert _should_force_advisory_run_dispatch(self._granted_ctx()) is True
|
|
|
|
def test_consumed_advisory_releases_the_force(self) -> None:
|
|
ctx = self._granted_ctx()
|
|
ctx.output_contract_actuation_by_signature = {"sig_a": OutputContractAdvisoryState.CONSUMED}
|
|
assert _should_force_advisory_run_dispatch(ctx) is False
|
|
|
|
def test_no_grant_does_not_force(self) -> None:
|
|
ctx = self._granted_ctx()
|
|
ctx.output_contract_actuation_by_signature = {}
|
|
assert _should_force_advisory_run_dispatch(ctx) is False
|
|
|
|
def test_authority_forbidding_run_never_forces(self) -> None:
|
|
ctx = self._granted_ctx()
|
|
ctx.turn_intent = TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(may_update_workflow=True, may_run_blocks=False),
|
|
)
|
|
assert _should_force_advisory_run_dispatch(ctx) is False
|
|
|
|
def test_held_genuinely_terminal_blocker_releases_the_force(self) -> None:
|
|
ctx = self._granted_ctx()
|
|
ctx.blocker_signal = CopilotToolBlockerSignal(
|
|
blocker_kind="tool_error",
|
|
agent_steering_text="",
|
|
user_facing_reason="",
|
|
recovery_hint="stop",
|
|
internal_reason_code=OUTPUT_SOURCE_UNOBSERVABLE_REASON_CODE,
|
|
)
|
|
assert _should_force_advisory_run_dispatch(ctx) is False
|
|
|
|
def test_non_code_only_policy_does_not_force(self) -> None:
|
|
ctx = self._granted_ctx()
|
|
ctx.block_authoring_policy = None
|
|
assert _should_force_advisory_run_dispatch(ctx) is False
|
|
|
|
def test_granted_advisory_survives_model_churn_and_stays_force_eligible(self) -> None:
|
|
ctx = self._granted_ctx()
|
|
ctx.latest_tool_blocker_signal = None
|
|
ctx.tool_blocker_signals = []
|
|
ctx.output_contract_actuation_count_by_signature = {}
|
|
ctx.output_contract_run_output_observed_by_signature = {}
|
|
ctx.output_contract_page_extraction_imposed_by_signature = {}
|
|
ctx.output_contract_pending_run_evidence = {"sig_a": ["output.confirmation_number"]}
|
|
churn = CopilotToolBlockerSignal(
|
|
blocker_kind="loop_detected",
|
|
agent_steering_text="",
|
|
user_facing_reason="",
|
|
recovery_hint="stop",
|
|
internal_reason_code="code_authoring_guardrail_churn",
|
|
)
|
|
stash_turn_halt_from_blocker_signal(ctx, churn, source="enforcement_backstop")
|
|
stash_turn_halt_from_blocker_signal(ctx, churn, source="enforcement_backstop")
|
|
assert ctx.turn_halt is None
|
|
assert ctx.output_contract_actuation_by_signature["sig_a"] == OutputContractAdvisoryState.GRANTED
|
|
assert _should_force_advisory_run_dispatch(ctx) is True
|