621 lines
20 KiB
Python
621 lines
20 KiB
Python
"""Unit tests for the user_input step executor (PR3, design §8.1)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from opensquilla.skills.meta.executors.user_input import (
|
|
_deterministic_upstream_prefill,
|
|
_render_clarify_config,
|
|
run_user_input_step,
|
|
)
|
|
from opensquilla.skills.meta.types import (
|
|
ClarifyField,
|
|
ClarifyStepConfig,
|
|
MetaPaused,
|
|
MetaStep,
|
|
)
|
|
|
|
|
|
def _cfg(skip_if: str = "") -> ClarifyStepConfig:
|
|
return ClarifyStepConfig(
|
|
mode="form",
|
|
fields=(ClarifyField(name="destination", type="string", required=True),),
|
|
skip_if=skip_if,
|
|
)
|
|
|
|
|
|
def _step(cfg: ClarifyStepConfig) -> MetaStep:
|
|
return MetaStep(
|
|
id="collect",
|
|
skill="collect",
|
|
kind="user_input",
|
|
clarify_config=cfg,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skip_if_true_returns_empty_without_pausing():
|
|
"""skip_if='True' (or any truthy expression) bypasses the pause."""
|
|
dao = MagicMock()
|
|
text = await run_user_input_step(
|
|
_step(_cfg(skip_if="True")),
|
|
inputs={"user_message": "hi", "collected": {}},
|
|
outputs={},
|
|
run_id="r1",
|
|
session_id="S1",
|
|
dao=dao,
|
|
now=lambda: 1700000000.0,
|
|
)
|
|
assert text == ""
|
|
dao.try_claim_awaiting.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_skip_raises_meta_paused_after_successful_cas():
|
|
dao = MagicMock()
|
|
dao.try_claim_awaiting.return_value = True
|
|
with pytest.raises(MetaPaused) as exc:
|
|
await run_user_input_step(
|
|
_step(_cfg()),
|
|
inputs={"user_message": "hi", "collected": {}},
|
|
outputs={"upstream": "some output"},
|
|
run_id="r1",
|
|
session_id="S1",
|
|
dao=dao,
|
|
now=lambda: 1700000000.0,
|
|
)
|
|
assert exc.value.run_id == "r1"
|
|
assert exc.value.step_id == "collect"
|
|
assert exc.value.schema.fields[0].name == "destination"
|
|
|
|
dao.try_claim_awaiting.assert_called_once()
|
|
kwargs = dao.try_claim_awaiting.call_args.kwargs
|
|
assert kwargs["run_id"] == "r1"
|
|
assert kwargs["session_id"] == "S1"
|
|
assert kwargs["step_id"] == "collect"
|
|
assert json.loads(kwargs["inputs_json"])["user_message"] == "hi"
|
|
assert json.loads(kwargs["step_outputs_json"])["upstream"] == "some output"
|
|
assert kwargs["awaiting_since"] == 1700000000.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_claim_failure_reports_missing_run_instead_of_generic_rejection():
|
|
dao = MagicMock()
|
|
dao.try_claim_awaiting.return_value = False
|
|
dao.get_run.return_value = None
|
|
|
|
with pytest.raises(RuntimeError) as exc:
|
|
await run_user_input_step(
|
|
_step(_cfg()),
|
|
inputs={"user_message": "hi", "collected": {}},
|
|
outputs={"upstream": "some output"},
|
|
run_id="missing-run",
|
|
session_id="S1",
|
|
dao=dao,
|
|
now=lambda: 1700000000.0,
|
|
)
|
|
|
|
message = str(exc.value)
|
|
assert "meta run 'missing-run' was not found" in message
|
|
assert "awaiting claim rejected" not in message
|
|
|
|
|
|
def test_clarify_copy_renders_against_inputs_and_outputs():
|
|
cfg = ClarifyStepConfig(
|
|
mode="form",
|
|
intro=(
|
|
"{% if 'LANGUAGE: zh' in outputs.paper_collect %}"
|
|
"请补充论文信息"
|
|
"{% else %}"
|
|
"Please add paper details"
|
|
"{% endif %}"
|
|
),
|
|
fields=(
|
|
ClarifyField(
|
|
name="topic",
|
|
type="string",
|
|
required=True,
|
|
prompt=(
|
|
"{% if 'LANGUAGE: zh' in outputs.paper_collect %}"
|
|
"论文主题"
|
|
"{% else %}"
|
|
"Paper topic"
|
|
"{% endif %}"
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
rendered = _render_clarify_config(
|
|
cfg,
|
|
inputs={"user_message": "写一篇论文", "collected": {}},
|
|
outputs={"paper_collect": "LANGUAGE: zh\nNEEDS_CLARIFICATION: yes"},
|
|
)
|
|
|
|
assert rendered.intro == "请补充论文信息"
|
|
assert rendered.fields[0].prompt == "论文主题"
|
|
assert rendered.fields[0].name == "topic"
|
|
assert rendered.fields[0].required is True
|
|
|
|
rendered_en = _render_clarify_config(
|
|
cfg,
|
|
inputs={"user_message": "write a paper", "collected": {}},
|
|
outputs={"paper_collect": "LANGUAGE: en\nNEEDS_CLARIFICATION: yes"},
|
|
)
|
|
|
|
assert rendered_en.intro == "Please add paper details"
|
|
assert rendered_en.fields[0].prompt == "Paper topic"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_english_pause_filters_cjk_cancel_keywords_and_prompts():
|
|
cfg = ClarifyStepConfig(
|
|
mode="form",
|
|
fields=(
|
|
ClarifyField(
|
|
name="topic",
|
|
type="string",
|
|
required=True,
|
|
prompt="报告主题 / Report topic",
|
|
),
|
|
),
|
|
intro="报告主题或决策场景还不够明确。",
|
|
cancel_keywords=("算了", "取消", "cancel", "stop"),
|
|
)
|
|
dao = MagicMock()
|
|
dao.try_claim_awaiting.return_value = True
|
|
|
|
with pytest.raises(MetaPaused) as exc:
|
|
await run_user_input_step(
|
|
_step(cfg),
|
|
inputs={
|
|
"user_message": "Please research this.",
|
|
"user_language": "en",
|
|
"collected": {},
|
|
},
|
|
outputs={},
|
|
run_id="r1",
|
|
session_id="S1",
|
|
dao=dao,
|
|
now=lambda: 1700000000.0,
|
|
)
|
|
|
|
paused = exc.value
|
|
assert paused.language == "en"
|
|
assert paused.schema.intro == (
|
|
"A few required details are still missing. Please provide the fields "
|
|
"below so I can continue."
|
|
)
|
|
assert paused.schema.fields[0].prompt == "Report topic"
|
|
assert paused.schema.cancel_keywords == ("cancel", "stop")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pause_uses_explicit_localized_intro_and_prompts():
|
|
cfg = ClarifyStepConfig(
|
|
mode="form",
|
|
fields=(
|
|
ClarifyField(
|
|
name="topic",
|
|
type="string",
|
|
required=True,
|
|
prompt="主题 / Topic",
|
|
prompt_by_language={"zh": "主题", "en": "Topic"},
|
|
),
|
|
),
|
|
intro="补充信息 / Add details",
|
|
intro_by_language={
|
|
"zh": "请补充信息。",
|
|
"en": "Please add details.",
|
|
},
|
|
)
|
|
|
|
dao = MagicMock()
|
|
dao.try_claim_awaiting.return_value = True
|
|
with pytest.raises(MetaPaused) as zh_exc:
|
|
await run_user_input_step(
|
|
_step(cfg),
|
|
inputs={"user_message": "写一份报告", "user_language": "zh", "collected": {}},
|
|
outputs={},
|
|
run_id="r1",
|
|
session_id="S1",
|
|
dao=dao,
|
|
now=lambda: 1700000000.0,
|
|
)
|
|
|
|
assert zh_exc.value.schema.intro == "请补充信息。"
|
|
assert zh_exc.value.schema.fields[0].prompt == "主题"
|
|
|
|
with pytest.raises(MetaPaused) as en_exc:
|
|
await run_user_input_step(
|
|
_step(cfg),
|
|
inputs={"user_message": "write a report", "user_language": "en", "collected": {}},
|
|
outputs={},
|
|
run_id="r2",
|
|
session_id="S1",
|
|
dao=dao,
|
|
now=lambda: 1700000001.0,
|
|
)
|
|
|
|
assert en_exc.value.schema.intro == "Please add details."
|
|
assert en_exc.value.schema.fields[0].prompt == "Topic"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cas_failure_does_not_raise_meta_paused():
|
|
"""When the DAO rejects the claim, the executor signals a normal failure
|
|
by raising RuntimeError. The orchestrator treats it as a regular step
|
|
failure — on_failure substitute may fire (design §10)."""
|
|
dao = MagicMock()
|
|
dao.try_claim_awaiting.return_value = False
|
|
with pytest.raises(RuntimeError, match="awaiting claim rejected"):
|
|
await run_user_input_step(
|
|
_step(_cfg()),
|
|
inputs={"user_message": "hi", "collected": {}},
|
|
outputs={},
|
|
run_id="r1",
|
|
session_id="S1",
|
|
dao=dao,
|
|
now=lambda: 1700000000.0,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skip_if_uses_inputs_and_outputs_context():
|
|
"""Verify Jinja context wiring matches the rest of the meta-skill engine."""
|
|
dao = MagicMock()
|
|
cfg = _cfg(skip_if='"done" in outputs.upstream')
|
|
text = await run_user_input_step(
|
|
_step(cfg),
|
|
inputs={"user_message": "hi", "collected": {}},
|
|
outputs={"upstream": "done with prep"},
|
|
run_id="r1",
|
|
session_id="S1",
|
|
dao=dao,
|
|
now=lambda: 1700000000.0,
|
|
)
|
|
assert text == ""
|
|
dao.try_claim_awaiting.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_error_propagates_unchanged():
|
|
"""If the DAO call is cancelled mid-call, the executor must not swallow
|
|
the CancelledError — the scheduler relies on it to tear down siblings.
|
|
|
|
`try_claim_awaiting` is a SYNCHRONOUS method on MetaRunWriter. We
|
|
simulate the raise by giving the MagicMock a sync `side_effect`."""
|
|
dao = MagicMock()
|
|
dao.try_claim_awaiting = MagicMock(side_effect=asyncio.CancelledError())
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await run_user_input_step(
|
|
_step(_cfg()),
|
|
inputs={"user_message": "hi", "collected": {}},
|
|
outputs={},
|
|
run_id="r1",
|
|
session_id="S1",
|
|
dao=dao,
|
|
now=lambda: 1700000000.0,
|
|
)
|
|
|
|
|
|
# ── Step (c): pre-fill scan ──
|
|
|
|
|
|
def _cfg_with_nl_extract(*fields: ClarifyField) -> ClarifyStepConfig:
|
|
"""Schema variant with ``nl_extract`` enabled for prefill tests."""
|
|
return ClarifyStepConfig(
|
|
mode="form",
|
|
fields=fields or (
|
|
ClarifyField(name="destination", type="string", required=True),
|
|
ClarifyField(name="days", type="int", required=True, min=1, max=30),
|
|
),
|
|
nl_extract=True,
|
|
)
|
|
|
|
|
|
def _llm_returning(payload):
|
|
"""Build a fake llm_chat that yields ``payload`` (dict → JSON, str passthrough)."""
|
|
if isinstance(payload, dict):
|
|
payload = json.dumps(payload)
|
|
|
|
async def _chat(_system: str, _user: str) -> str:
|
|
return payload
|
|
|
|
return _chat
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prefill_scan_seeds_awaiting_filled_with_known_values() -> None:
|
|
"""When ``llm_chat`` and ``prefill_context`` are wired, the
|
|
executor must run a single NL extract pass over the context
|
|
BEFORE claiming the awaiting state, and merge any high-confidence
|
|
values into ``awaiting_filled_json``. The user must still confirm
|
|
via the surface, so MetaPaused still fires."""
|
|
dao = MagicMock()
|
|
dao.try_claim_awaiting.return_value = True
|
|
chat = _llm_returning({
|
|
"intent": "FILL",
|
|
"fields": {"destination": "Tokyo"},
|
|
"ambiguous_fields": [{"name": "days", "reason": "duration not stated"}],
|
|
"unknown_mentions": [],
|
|
})
|
|
with pytest.raises(MetaPaused):
|
|
await run_user_input_step(
|
|
_step(_cfg_with_nl_extract()),
|
|
inputs={"user_message": "plan our Tokyo trip", "collected": {}},
|
|
outputs={},
|
|
run_id="r1",
|
|
session_id="S1",
|
|
dao=dao,
|
|
now=lambda: 1700000000.0,
|
|
llm_chat=chat,
|
|
prefill_context={
|
|
"original_user_message": "plan our Tokyo trip",
|
|
},
|
|
)
|
|
kwargs = dao.try_claim_awaiting.call_args.kwargs
|
|
filled = json.loads(kwargs["awaiting_filled_json"])
|
|
assert filled["destination"] == "Tokyo", (
|
|
"high-confidence prefill must seed awaiting_filled_json"
|
|
)
|
|
audit = filled.get("__prefill_audit__")
|
|
assert audit, "prefill audit payload must be present"
|
|
assert audit["source"] == "auto_prefill"
|
|
assert "destination" in audit["fields"]
|
|
# Ambiguous fields must NOT be silently pre-filled — the user
|
|
# still has to answer those.
|
|
assert "days" not in filled
|
|
ambiguous = {a["name"] for a in audit["ambiguous"]}
|
|
assert "days" in ambiguous
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prefill_scan_ignores_empty_sentinel_from_catch_all_field() -> None:
|
|
"""Prefill runs before the user has answered, so an extractor that follows
|
|
a catch-all prompt's empty-input instruction must not make ``(empty)`` look
|
|
like a real user confirmation."""
|
|
dao = MagicMock()
|
|
dao.try_claim_awaiting.return_value = True
|
|
chat = _llm_returning({
|
|
"intent": "FILL",
|
|
"fields": {"review": "(empty)"},
|
|
"ambiguous_fields": [],
|
|
"unknown_mentions": [],
|
|
})
|
|
cfg = _cfg_with_nl_extract(
|
|
ClarifyField(
|
|
name="review",
|
|
type="string",
|
|
required=True,
|
|
prompt=(
|
|
"The user's verbatim reply about the script draft. "
|
|
"If the user's reply is empty or pure whitespace, emit \"(empty)\"."
|
|
),
|
|
),
|
|
)
|
|
|
|
with pytest.raises(MetaPaused) as exc:
|
|
await run_user_input_step(
|
|
_step(cfg),
|
|
inputs={"user_message": "生成一个短剧,啥都行", "collected": {}},
|
|
outputs={"script_draft": "draft text"},
|
|
run_id="r1",
|
|
session_id="S1",
|
|
dao=dao,
|
|
now=lambda: 1700000000.0,
|
|
llm_chat=chat,
|
|
prefill_context={
|
|
"original_user_message": "生成一个短剧,啥都行",
|
|
"prior_step_outputs": {"script_draft": "draft text"},
|
|
},
|
|
)
|
|
|
|
kwargs = dao.try_claim_awaiting.call_args.kwargs
|
|
filled = json.loads(kwargs["awaiting_filled_json"])
|
|
assert "review" not in filled
|
|
audit = filled.get("__prefill_audit__")
|
|
assert audit
|
|
assert "review" not in audit.get("fields", [])
|
|
assert audit.get("dropped_empty_sentinels") == ["review"]
|
|
assert exc.value.confirmed_fields is None
|
|
|
|
|
|
def test_deterministic_prefill_skips_empty_list_and_unspecified_sentinels() -> None:
|
|
cfg = ClarifyStepConfig(
|
|
mode="form",
|
|
fields=(
|
|
ClarifyField(name="dimensions", type="string", required=True),
|
|
ClarifyField(
|
|
name="time_window",
|
|
type="enum",
|
|
choices=("LAST_WEEK", "LAST_MONTH", "LAST_QUARTER"),
|
|
),
|
|
),
|
|
)
|
|
|
|
hits = _deterministic_upstream_prefill(
|
|
cfg,
|
|
{
|
|
"prior_step_outputs": {
|
|
"preferences": "DIMENSIONS: []\nTIME_WINDOW: UNSPECIFIED",
|
|
},
|
|
},
|
|
)
|
|
|
|
assert hits == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prefill_scan_skipped_when_no_llm_chat_wired() -> None:
|
|
"""Backwards compatibility: callers that don't pass ``llm_chat``
|
|
must see exactly the legacy behaviour — no prefill, no audit
|
|
payload, ``awaiting_filled_json`` is the empty object."""
|
|
dao = MagicMock()
|
|
dao.try_claim_awaiting.return_value = True
|
|
with pytest.raises(MetaPaused):
|
|
await run_user_input_step(
|
|
_step(_cfg_with_nl_extract()),
|
|
inputs={"user_message": "hi", "collected": {}},
|
|
outputs={},
|
|
run_id="r1",
|
|
session_id="S1",
|
|
dao=dao,
|
|
now=lambda: 1700000000.0,
|
|
# llm_chat=None is the default
|
|
)
|
|
kwargs = dao.try_claim_awaiting.call_args.kwargs
|
|
filled = json.loads(kwargs["awaiting_filled_json"])
|
|
assert filled == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prefill_scan_skipped_when_nl_extract_false() -> None:
|
|
"""A clarify schema that opts out of NL extract must NOT trigger
|
|
a prefill scan even if ``llm_chat`` is wired — operator declared
|
|
"this step uses deterministic parsing only" and must be honoured."""
|
|
dao = MagicMock()
|
|
dao.try_claim_awaiting.return_value = True
|
|
cfg = ClarifyStepConfig(
|
|
mode="form",
|
|
fields=(ClarifyField(name="destination", type="string", required=True),),
|
|
nl_extract=False,
|
|
)
|
|
chat_called = {"count": 0}
|
|
|
|
async def counting_chat(_s, _u):
|
|
chat_called["count"] += 1
|
|
return "{}"
|
|
|
|
with pytest.raises(MetaPaused):
|
|
await run_user_input_step(
|
|
_step(cfg),
|
|
inputs={"user_message": "Tokyo!", "collected": {}},
|
|
outputs={},
|
|
run_id="r1",
|
|
session_id="S1",
|
|
dao=dao,
|
|
now=lambda: 1700000000.0,
|
|
llm_chat=counting_chat,
|
|
prefill_context={"original_user_message": "Tokyo!"},
|
|
)
|
|
assert chat_called["count"] == 0, "nl_extract=false must skip prefill"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prefill_scan_failure_falls_back_to_pause() -> None:
|
|
"""A pre-fill LLM call that raises must downgrade silently to
|
|
"no prefill" — the resolver still reaches MetaPaused so the user
|
|
can answer normally. A regression that surfaced the LLM error
|
|
would block legitimate clarify runs whenever the upstream
|
|
provider hiccupped."""
|
|
dao = MagicMock()
|
|
dao.try_claim_awaiting.return_value = True
|
|
|
|
async def raising_chat(_s, _u):
|
|
raise RuntimeError("provider blew up")
|
|
|
|
with pytest.raises(MetaPaused):
|
|
await run_user_input_step(
|
|
_step(_cfg_with_nl_extract()),
|
|
inputs={"user_message": "Tokyo!", "collected": {}},
|
|
outputs={},
|
|
run_id="r1",
|
|
session_id="S1",
|
|
dao=dao,
|
|
now=lambda: 1700000000.0,
|
|
llm_chat=raising_chat,
|
|
prefill_context={"original_user_message": "Tokyo!"},
|
|
)
|
|
kwargs = dao.try_claim_awaiting.call_args.kwargs
|
|
filled = json.loads(kwargs["awaiting_filled_json"])
|
|
# No real fields landed; only the audit error trace.
|
|
assert "destination" not in filled
|
|
audit = filled.get("__prefill_audit__")
|
|
assert audit, "prefill audit must record the failure"
|
|
# ``extract`` catches the LLM exception itself and surfaces it
|
|
# through ``errors``; outer-level guard captures truly raised
|
|
# exceptions under ``error``. Either signal is acceptable evidence.
|
|
assert audit.get("errors") or audit.get("error")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prefill_scan_passes_awaiting_filled_json_to_dao() -> None:
|
|
"""The executor must always pass ``awaiting_filled_json`` to the
|
|
DAO. The previous behaviour of swallowing ``TypeError`` and
|
|
retrying with the legacy signature silently dropped prefill on the
|
|
floor when a stub didn't keep up with the contract; that fallback
|
|
is gone. A DAO that doesn't accept the kwarg is now treated as a
|
|
real signature mismatch."""
|
|
modern_dao = MagicMock()
|
|
captured: dict = {}
|
|
|
|
def claim(**kwargs):
|
|
captured.update(kwargs)
|
|
return True
|
|
|
|
modern_dao.try_claim_awaiting.side_effect = claim
|
|
chat = _llm_returning({
|
|
"intent": "FILL",
|
|
"fields": {"destination": "Tokyo"},
|
|
"ambiguous_fields": [],
|
|
"unknown_mentions": [],
|
|
})
|
|
with pytest.raises(MetaPaused):
|
|
await run_user_input_step(
|
|
_step(_cfg_with_nl_extract()),
|
|
inputs={"user_message": "Tokyo trip", "collected": {}},
|
|
outputs={},
|
|
run_id="r1",
|
|
session_id="S1",
|
|
dao=modern_dao,
|
|
now=lambda: 1700000000.0,
|
|
llm_chat=chat,
|
|
prefill_context={"original_user_message": "Tokyo trip"},
|
|
)
|
|
# Modern claim signature receives the prefill JSON unconditionally.
|
|
assert "awaiting_filled_json" in captured
|
|
filled = json.loads(captured["awaiting_filled_json"])
|
|
assert filled.get("destination") == "Tokyo"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prefill_scan_propagates_dao_typeerror_now_that_fallback_is_gone() -> None:
|
|
"""If a misconfigured DAO doesn't accept ``awaiting_filled_json``,
|
|
we surface the ``TypeError`` immediately rather than silently
|
|
retrying with the legacy signature. Live traffic must never
|
|
silently drop prefill — partial migrations now fail loud."""
|
|
busted_dao = MagicMock()
|
|
|
|
def claim(**kwargs):
|
|
# Simulate the old MetaRunWriter signature: reject the new kwarg.
|
|
if "awaiting_filled_json" in kwargs:
|
|
raise TypeError("unexpected keyword argument 'awaiting_filled_json'")
|
|
return True
|
|
|
|
busted_dao.try_claim_awaiting.side_effect = claim
|
|
chat = _llm_returning({
|
|
"intent": "FILL",
|
|
"fields": {"destination": "Tokyo"},
|
|
"ambiguous_fields": [],
|
|
"unknown_mentions": [],
|
|
})
|
|
with pytest.raises(TypeError):
|
|
await run_user_input_step(
|
|
_step(_cfg_with_nl_extract()),
|
|
inputs={"user_message": "Tokyo trip", "collected": {}},
|
|
outputs={},
|
|
run_id="r1",
|
|
session_id="S1",
|
|
dao=busted_dao,
|
|
now=lambda: 1700000000.0,
|
|
llm_chat=chat,
|
|
prefill_context={"original_user_message": "Tokyo trip"},
|
|
)
|