fix: improve cross-device evidence, request diagnostics and prompt efficiency
This commit is contained in:
1 parent
621f30e437
commit
a66803651a
60 files changed
+3097
-160
No files matched your search
@@ -583,7 +583,11 @@ async def test_fail_fast_includes_skipped_reports(isolated, monkeypatch):
|
||||
assert [j["status"] for j in isolated.jobs(batch["id"])] == ["failed", "skipped"]
|
||||
archive = reports.export_batch(batch["id"])
|
||||
with zipfile.ZipFile(archive) as z:
|
||||
assert len(z.namelist()) == 8 # summaries + two reports and evidence index per task
|
||||
assert len(z.namelist()) == 10 # summaries + reports, diagnostics and evidence per task
|
||||
diagnostics = [name for name in z.namelist() if name.endswith("模型请求诊断.json")]
|
||||
assert len(diagnostics) == 2
|
||||
for name in diagnostics:
|
||||
assert json.loads(z.read(name))["schema_version"] == 1
|
||||
assert len([name for name in z.namelist() if name.endswith("证据索引.json")]) == 2
|
||||
assert sum(n.endswith(".docx") for n in z.namelist()) == 3
|
||||
|
||||
|
||||
@@ -16,10 +16,13 @@
|
||||
rejects such requests with 400 "Requests ending with a model turn are not
|
||||
supported"), which is the natural state after the loop's tool-free reply."""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
||||
from pydantic import ValidationError
|
||||
|
||||
from artemis.agents.checker.checker import CheckReport, _structured_report
|
||||
|
||||
@@ -53,3 +56,93 @@ async def test_structured_report_keeps_user_ending_conversation_as_is():
|
||||
await _structured_report(llm, messages)
|
||||
|
||||
assert structured.ainvoke.call_args.args[0] is messages
|
||||
|
||||
|
||||
def _result(status="passed", evidence=None):
|
||||
return {
|
||||
"status": status,
|
||||
"reason": "双端检查与报告已完成,已知风险不代表所有布局无缺陷。",
|
||||
"evidence": ["步骤 18、27:追加下滚后位置不变,双端已到页底。"]
|
||||
if evidence is None
|
||||
else evidence,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status", ["passed", "failed", "inconclusive", "blocked"])
|
||||
async def test_complete_checker_json_does_not_request_another_verdict(status):
|
||||
payload = {"test_result": _result(status)}
|
||||
llm, structured = _llm(CheckReport())
|
||||
answer = AIMessage(
|
||||
content="核查步骤 [18]、[27],控件 [28]。最终裁定\n```json\n"
|
||||
+ json.dumps(payload)
|
||||
+ "\n```\n范围仅限本次检查。"
|
||||
)
|
||||
|
||||
result = await _structured_report(llm, [HumanMessage(content="review"), answer])
|
||||
|
||||
assert result.test_result.status == status
|
||||
assert result.test_result.evidence == payload["test_result"]["evidence"]
|
||||
structured.ainvoke.assert_not_awaited()
|
||||
llm.with_structured_output.assert_not_called()
|
||||
|
||||
|
||||
def test_nested_json_string_result_is_decoded_and_still_validated():
|
||||
result = _result()
|
||||
assert (
|
||||
CheckReport.model_validate({"test_result": json.dumps(result)}).test_result.model_dump()
|
||||
== result
|
||||
)
|
||||
for invalid in ("passed", "null", "[]", '{"status":"passed"}', json.dumps(_result("unknown"))):
|
||||
with pytest.raises(ValidationError):
|
||||
CheckReport.model_validate({"test_result": invalid})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"kind", ["empty", "malformed", "truncated", "tool_call", "missing_check", "ambiguous", "prose"]
|
||||
)
|
||||
async def test_incomplete_or_ambiguous_answer_requires_structured_verdict(kind):
|
||||
text = json.dumps({"test_result": _result()})
|
||||
items = []
|
||||
kwargs = {}
|
||||
if kind == "empty":
|
||||
text = "{}"
|
||||
elif kind == "malformed":
|
||||
text = '{"test_result":{"status":"passed"}}'
|
||||
elif kind == "truncated":
|
||||
kwargs["response_metadata"] = {"finish_reason": "length"}
|
||||
elif kind == "tool_call":
|
||||
kwargs["tool_calls"] = [{"id": "read-1", "name": "search_history", "args": {}}]
|
||||
elif kind == "missing_check":
|
||||
items = [SimpleNamespace(kind="assert", text="must pass")]
|
||||
elif kind == "ambiguous":
|
||||
text += "\n" + json.dumps({"test_result": _result("failed")})
|
||||
elif kind == "prose":
|
||||
text = "测试通过。"
|
||||
llm, structured = _llm(CheckReport())
|
||||
|
||||
await _structured_report(llm, [AIMessage(content=text, **kwargs)], items)
|
||||
|
||||
structured.ainvoke.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_verdict_preserves_evidence_normalization(monkeypatch):
|
||||
from artemis.agents.checker import checker
|
||||
|
||||
llm, structured = _llm(CheckReport())
|
||||
monkeypatch.setattr(checker, "get_llm", lambda **_: llm)
|
||||
monkeypatch.setattr(
|
||||
checker,
|
||||
"acomplete",
|
||||
AsyncMock(
|
||||
return_value=AIMessage(
|
||||
content=json.dumps({"test_result": _result(evidence=[])}),
|
||||
)
|
||||
),
|
||||
)
|
||||
ctx = SimpleNamespace(execution_setup=SimpleNamespace(checker_max_iterations=3))
|
||||
report = await checker._run_check_loop(ctx, [HumanMessage(content="review")], [], [])
|
||||
assert report.test_result.status == "inconclusive"
|
||||
structured.ainvoke.assert_not_awaited()
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -123,3 +124,35 @@ async def test_cancellation_of_acceptance_propagates(tmp_path, monkeypatch):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await flash.settle_flash_outcome(context(tmp_path), "任务", {"status": "completed"})
|
||||
assert not (tmp_path / "run_outcome.json").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_checker_answer_is_persisted_without_reformatting(tmp_path, monkeypatch):
|
||||
from artemis.agents.checker import checker
|
||||
|
||||
ctx = context(tmp_path)
|
||||
ctx.exploration_id = None
|
||||
ctx.on_execution_review = None
|
||||
result = CaseTestResult(
|
||||
status="passed",
|
||||
reason="双端布局探索和报告达标;不代表网站无缺陷。",
|
||||
evidence=["步骤 18、27:双端真实页底;报告列出待核实风险。"],
|
||||
)
|
||||
answer = AIMessage(
|
||||
content="最终裁定\n```json\n" + json.dumps({"test_result": result.model_dump()}) + "\n```"
|
||||
)
|
||||
llm = MagicMock()
|
||||
monkeypatch.setattr(checker, "get_llm", lambda **_: llm)
|
||||
monkeypatch.setattr(checker, "acomplete", AsyncMock(return_value=answer))
|
||||
monkeypatch.setattr(checker, "build_checker_tools", lambda *args: [])
|
||||
monkeypatch.setattr(checker, "_capture_final_screen", AsyncMock(return_value=(None, "footer")))
|
||||
monkeypatch.setattr(checker, "_load_steps", lambda *args: [])
|
||||
|
||||
outcome = await flash.settle_flash_outcome(
|
||||
ctx, "如实报告布局风险,不要求网站无缺陷", {"status": "completed"}
|
||||
)
|
||||
|
||||
llm.with_structured_output.assert_not_called()
|
||||
assert outcome["test_result"] == result.model_dump()
|
||||
saved = json.loads((tmp_path / "run_outcome.json").read_text(encoding="utf-8"))
|
||||
assert saved["test_result"] == result.model_dump()
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Cross-target observations save work without executing stale phone decisions."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, ToolMessage
|
||||
|
||||
from artemis.agents.flash import runner as flash
|
||||
from artemis.agents.validator.tool_declarations import ToolExecutionResult
|
||||
from artemis.mcp.observation import DeviceObservationError
|
||||
from artemis.memory.transcript import TranscriptLedger
|
||||
from artemis.runtime.task_control import TaskControl
|
||||
|
||||
|
||||
PC = "plug_pc_action"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cross_target(monkeypatch):
|
||||
plugins = SimpleNamespace(
|
||||
bindings={PC: ("pc", None)},
|
||||
tool_names={PC},
|
||||
previous_image=lambda *_: b"pc-before",
|
||||
)
|
||||
monkeypatch.setattr("artemis.plugins.runtime.get_plugin_runtime", lambda _: plugins)
|
||||
runner = flash.FlashRunner.__new__(flash.FlashRunner)
|
||||
runner.ctx = SimpleNamespace(execution_target="mixed", data_engine=None, task_control=None)
|
||||
runner._android_observation_stale = False
|
||||
runner.controller, runner.summarizer, runner._llm = Mock(), None, None
|
||||
state = SimpleNamespace(
|
||||
latest_screenshot="old-phone.png",
|
||||
indexed_elements=[{"index": 1, "text": "Old button"}],
|
||||
indexed_points=[[1, 2]],
|
||||
)
|
||||
|
||||
async def execute(name, args, tc_id, current, **kwargs):
|
||||
if name == PC:
|
||||
return ToolExecutionResult(
|
||||
tool_name=name,
|
||||
tool_call_id=tc_id,
|
||||
status="success",
|
||||
screenshot_bytes=b"pc-after",
|
||||
ui_elements_text="PC controls",
|
||||
text_summary="PC result",
|
||||
metadata={"plugin_id": "pc"},
|
||||
raw_result=[
|
||||
{"type": "text", "text": "PC controls"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64,cGM="},
|
||||
},
|
||||
],
|
||||
)
|
||||
current.indexed_elements = [{"index": 1, "text": "Fresh button"}]
|
||||
current.indexed_points = [[10, 20]]
|
||||
current.latest_screenshot = "fresh-phone.png"
|
||||
return ToolExecutionResult(
|
||||
tool_name=name,
|
||||
tool_call_id=tc_id,
|
||||
status="success",
|
||||
text_summary="OK",
|
||||
screenshot_bytes=b"fresh-phone",
|
||||
ui_elements_text="Fresh button",
|
||||
)
|
||||
|
||||
runner.executor = SimpleNamespace(
|
||||
action_tool_names={PC, "click", "wait_for_delay"},
|
||||
execute=AsyncMock(side_effect=execute),
|
||||
)
|
||||
|
||||
async def capture(ctx, current, controller, **kwargs):
|
||||
current.indexed_elements = [{"index": 1, "text": "Fresh button"}]
|
||||
current.indexed_points = [[10, 20]]
|
||||
return "fresh-phone.png", b"fresh-phone", "Fresh button"
|
||||
|
||||
capture_mock = AsyncMock(side_effect=capture)
|
||||
monkeypatch.setattr(flash, "capture_screenshot_and_parse_ui", capture_mock)
|
||||
return runner, state, capture_mock
|
||||
|
||||
|
||||
def _call(name, call_id, **args):
|
||||
return {"name": name, "id": call_id, "args": args}
|
||||
|
||||
|
||||
async def _dispatch(runner, state, calls, image=None, tree=""):
|
||||
messages = []
|
||||
turn = flash._TurnRecord()
|
||||
result = await runner._process_tool_calls(calls, state, messages, "", {}, image, tree, 0, turn)
|
||||
return result, messages, turn
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_continuous_pc_calls_do_not_capture_or_resend_the_phone(cross_target):
|
||||
runner, state, capture = cross_target
|
||||
for i in range(3):
|
||||
result, messages, _ = await _dispatch(runner, state, [_call(PC, str(i))])
|
||||
assert result[1] is None
|
||||
assert "不是安卓画面" in result[2]
|
||||
assert "data:image/png;base64,cGM=" in str(messages)
|
||||
assert not state.indexed_elements and not state.indexed_points
|
||||
assert state.latest_screenshot is None
|
||||
capture.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"args", [{"target": 1}, {"target": [100, 200], "target_description": "Button"}]
|
||||
)
|
||||
async def test_stale_phone_action_and_remainder_are_deferred_until_new_decision(cross_target, args):
|
||||
runner, state, capture = cross_target
|
||||
calls = [
|
||||
_call(PC, "pc"),
|
||||
_call("click", "tap", **args),
|
||||
_call("report_task_status", "end", status="completed"),
|
||||
]
|
||||
result, messages, turn = await _dispatch(runner, state, calls, b"old-phone", "Old button")
|
||||
assert result[0] is None
|
||||
assert result[1:3] == (b"fresh-phone", "Fresh button")
|
||||
assert [c.args[0] for c in runner.executor.execute.call_args_list] == [PC]
|
||||
assert [m.tool_call_id for m in messages if isinstance(m, ToolMessage)] == ["pc", "tap", "end"]
|
||||
assert all("未执行" in str(m.content) for m in messages[-2:])
|
||||
assert [action[0] for action in turn.actions] == [PC]
|
||||
capture.assert_awaited_once()
|
||||
|
||||
# A subsequent model decision sees the new indices; the old index snapshot
|
||||
# from the mixed batch must not leak into this new click.
|
||||
await _dispatch(runner, state, [_call("click", "fresh-tap", target=1)], *result[1:3])
|
||||
assert runner.executor.execute.call_args.kwargs["index_elements"][0]["text"] == "Fresh button"
|
||||
capture.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_refresh_executes_wait_once_but_defers_same_turn_click(cross_target):
|
||||
runner, state, capture = cross_target
|
||||
await _dispatch(runner, state, [_call(PC, "pc")])
|
||||
result, messages, _ = await _dispatch(
|
||||
runner,
|
||||
state,
|
||||
[
|
||||
_call("wait_for_delay", "observe", time_in_ms=0),
|
||||
_call("click", "tap", target=1),
|
||||
],
|
||||
)
|
||||
assert [c.args[0] for c in runner.executor.execute.call_args_list] == [PC, "wait_for_delay"]
|
||||
assert result[1:3] == (b"fresh-phone", "Fresh button")
|
||||
assert messages[-1].status == "error" and messages[-1].tool_call_id == "tap"
|
||||
assert not runner._android_observation_stale
|
||||
capture.assert_not_awaited() # The wait result already contains a fresh phone observation.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_pc_call_also_invalidates_the_phone_without_a_capture(cross_target):
|
||||
runner, state, capture = cross_target
|
||||
runner.executor.execute.side_effect = RuntimeError("PC connection lost")
|
||||
result, messages, _ = await _dispatch(runner, state, [_call(PC, "pc")])
|
||||
assert result[1] is None and runner._android_observation_stale
|
||||
assert state.latest_screenshot is None and state.indexed_elements == []
|
||||
assert messages[0].status == "error"
|
||||
capture.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_phone_refresh_never_dispatches_stale_click(cross_target):
|
||||
runner, state, capture = cross_target
|
||||
await _dispatch(runner, state, [_call(PC, "pc")])
|
||||
capture.side_effect = DeviceObservationError("phone disconnected")
|
||||
with pytest.raises(DeviceObservationError):
|
||||
await _dispatch(runner, state, [_call("click", "tap", target=1)])
|
||||
assert runner.executor.execute.await_count == 1
|
||||
assert runner._android_observation_stale
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("intervention", ["guidance", "resume", "resume_with_guidance"])
|
||||
async def test_user_intervention_refreshes_phone_before_next_model_decision(
|
||||
cross_target, tmp_path, intervention
|
||||
):
|
||||
runner, state, capture = cross_target
|
||||
runner.max_turns, runner.goal = 3, "cross-target task"
|
||||
runner._init_llm = Mock(return_value=Mock())
|
||||
runner._get_tools = Mock(return_value=[SimpleNamespace(name="report_task_status")])
|
||||
runner._build_ledger = Mock(return_value=TranscriptLedger())
|
||||
runner._render_system_prompt = Mock(return_value="test")
|
||||
runner._read_injected_instruction = AsyncMock(
|
||||
side_effect=[
|
||||
(None, None),
|
||||
("按新指导继续", None) if intervention != "resume" else (None, None),
|
||||
]
|
||||
)
|
||||
if intervention.startswith("resume"):
|
||||
control = Mock(spec=TaskControl)
|
||||
control.directory = tmp_path
|
||||
control.checkpoint = AsyncMock(side_effect=[False, True])
|
||||
|
||||
async def uninterrupted(operation, **kwargs):
|
||||
return False, await operation
|
||||
|
||||
control.run_interruptibly = AsyncMock(side_effect=uninterrupted)
|
||||
runner.ctx.task_control = control
|
||||
seen = []
|
||||
answers = [
|
||||
AIMessage(content="Observe PC", tool_calls=[_call(PC, "pc")]),
|
||||
AIMessage(
|
||||
content="Done", tool_calls=[_call("report_task_status", "end", status="completed")]
|
||||
),
|
||||
]
|
||||
|
||||
async def model(llm, tools, messages):
|
||||
seen.append(messages[-1])
|
||||
return answers.pop(0)
|
||||
|
||||
runner._invoke_model = AsyncMock(side_effect=model)
|
||||
result = await runner._run(state)
|
||||
assert result["status"] == "completed"
|
||||
assert capture.await_count == 2 # Initial phone observation + one refresh for the intervention.
|
||||
assert "Fresh button" in str(seen[-1].content)
|
||||
assert "不是安卓画面" not in str(seen[-1].content)
|
||||
assert not runner._android_observation_stale
|
||||
@@ -141,6 +141,74 @@ def _transcript_state(**overrides):
|
||||
return state
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("exploration", [False, True])
|
||||
@pytest.mark.parametrize("transcript", [False, True])
|
||||
async def test_plugin_result_is_delivered_with_pc_image_and_separate_phone_observation(
|
||||
tmp_path, exploration, transcript, monkeypatch
|
||||
):
|
||||
from langchain_openai import ChatOpenAI
|
||||
from PIL import Image
|
||||
|
||||
ctx = _transcript_ctx()
|
||||
ctx.exploration_id = "explore" if exploration else None
|
||||
monkeypatch.setattr(
|
||||
"artemis.exploration.runtime.exploration_messages",
|
||||
lambda _: [SystemMessage(content="Explore"), HumanMessage(content="Guidance")],
|
||||
)
|
||||
ctx.data_engine = MagicMock()
|
||||
ctx.data_engine.base_dir = str(tmp_path)
|
||||
pc = tmp_path / "pc.jpg"
|
||||
Image.new("RGB", (200, 100), "blue").save(pc)
|
||||
ctx.data_engine.get_image_path.return_value = pc
|
||||
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=transcript))
|
||||
ledger = node._ensure_transcript_ledger()
|
||||
tool = "plug_test_get_app_state"
|
||||
ledger.stage_turn(
|
||||
[
|
||||
AIMessage(content="", tool_calls=[{"name": tool, "args": {}, "id": "pc_call"}]),
|
||||
ToolMessage(content="Action Recorded", tool_call_id="pc_call"),
|
||||
]
|
||||
)
|
||||
report = {
|
||||
"status": "dispatched",
|
||||
"execution": [
|
||||
{
|
||||
"action": tool,
|
||||
"tool_call_id": "pc_call",
|
||||
"plugin_id": "test",
|
||||
"plugin_status": "success",
|
||||
"result": "PC Address bar",
|
||||
"post_image_name": "pc",
|
||||
}
|
||||
],
|
||||
}
|
||||
state = _transcript_state(structured_decisions="[]", last_execution_result=report)
|
||||
messages = await node._build_prompt(state, SCREENSHOT_B64, [], "PHONE_ELEMENTS", 2, [], "")
|
||||
if transcript:
|
||||
output = next(m for m in messages if isinstance(m, ToolMessage))
|
||||
assert output.tool_call_id == "pc_call" and "PC Address bar" in str(output.content)
|
||||
else:
|
||||
assert "PC Address bar" in str(messages)
|
||||
assert base64.b64encode(pc.read_bytes()).decode() in str(messages)
|
||||
assert SCREENSHOT_B64 not in str(messages) and "PHONE_ELEMENTS" in str(messages)
|
||||
wire = ChatOpenAI(model="fixture", api_key="fixture", use_responses_api=True, store=False)
|
||||
payload = wire._get_request_payload(messages)["input"]
|
||||
assert len([x for x in payload if x.get("type") == "function_call_output"]) == int(transcript)
|
||||
guided = await node._build_prompt(
|
||||
_transcript_state(
|
||||
structured_decisions="[]", last_execution_result=report, injected_instruction="回到手机"
|
||||
),
|
||||
SCREENSHOT_B64,
|
||||
[],
|
||||
"PHONE_ELEMENTS",
|
||||
3,
|
||||
[],
|
||||
"",
|
||||
)
|
||||
assert SCREENSHOT_B64 in str(guided)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"exploration,plugins", [(False, False), (True, False), (False, True), (True, True)]
|
||||
@@ -415,3 +483,38 @@ async def test_flag_off_keeps_legacy_two_message_build_and_no_ledger():
|
||||
assert isinstance(captured[0][0], SystemMessage)
|
||||
assert isinstance(captured[0][1], HumanMessage)
|
||||
assert ctx.transcript_ledger is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exploration_guidance_appends_without_rewriting_static_prefix(monkeypatch):
|
||||
ctx = _transcript_ctx()
|
||||
ctx.exploration_id = "explore"
|
||||
guidance = ["initial guidance"]
|
||||
monkeypatch.setattr(
|
||||
"artemis.exploration.runtime.exploration_messages",
|
||||
lambda _: [SystemMessage(content="rules"), HumanMessage(content=guidance[0])],
|
||||
)
|
||||
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True))
|
||||
state = _transcript_state()
|
||||
first = await node._build_prompt_transcript(state, "", "", 1, [], "")
|
||||
prefix = first[0].content
|
||||
assert "initial guidance" not in prefix
|
||||
assert "initial guidance" in str(first[-1].content)
|
||||
node._stage_transcript_turn(first)
|
||||
second = await node._build_prompt_transcript(state, "", "", 2, [], "")
|
||||
assert second[0].content == prefix
|
||||
assert "initial guidance" not in str(second[-1].content)
|
||||
node._stage_transcript_turn(second)
|
||||
guidance[0] = "new direction"
|
||||
third = await node._build_prompt_transcript(state, "", "", 3, [], "")
|
||||
assert third[0].content == prefix
|
||||
assert "new direction" in str(third[-1].content)
|
||||
# An interrupted/uncommitted build must not acknowledge away the guidance.
|
||||
retried = await node._build_prompt_transcript(state, "", "", 3, [], "")
|
||||
assert "new direction" in str(retried[-1].content)
|
||||
node._stage_transcript_turn(retried)
|
||||
await node._build_prompt_transcript(state, "", "", 4, [], "")
|
||||
ctx.transcript_ledger.freeze_turns(3, [HumanMessage(content="compressed history")])
|
||||
compacted = await node._build_prompt_transcript(state, "", "", 4, [], "")
|
||||
assert compacted[0].content == prefix
|
||||
assert "new direction" in str(compacted[-1].content)
|
||||
@@ -106,6 +106,7 @@ async def test_summarizer_dispatches_visual_lens(mock_context):
|
||||
exec_outcome="dispatched",
|
||||
data_engine_step_id="12345678-1234-5678-1234-567812345678",
|
||||
focus=None,
|
||||
receipt_only=False,
|
||||
)
|
||||
# No direct capsule write from the node: the lens owns the versioned
|
||||
# summary write when its background job completes.
|
||||
@@ -217,3 +218,48 @@ async def test_summarizer_noop_without_step_id(mock_context):
|
||||
|
||||
assert result == {}
|
||||
mock_context.step_memory.dispatch.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("target", ["android", "mixed", "computer"])
|
||||
async def test_plugin_summary_uses_actual_result_and_pc_evidence(mock_context, tmp_path, target):
|
||||
pc = tmp_path / "pc.jpg"
|
||||
pc.write_bytes(b"PC_IMAGE")
|
||||
mock_context.execution_target = target
|
||||
mock_context.data_engine.get_image_path.return_value = pc
|
||||
state = DummyState(
|
||||
current_step_id="step",
|
||||
last_execution_result={
|
||||
"status": "dispatched",
|
||||
"execution": [
|
||||
{
|
||||
"action": "plug_test_get_app_state",
|
||||
"plugin_id": "test",
|
||||
"result": "来源:电脑插件 / Browser window",
|
||||
"post_image_name": "pc",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
await SummarizerNode(mock_context)(state)
|
||||
kwargs = mock_context.step_memory.dispatch.call_args.kwargs
|
||||
assert kwargs["pre_img_bytes"] is None
|
||||
assert kwargs["post_img_bytes"] == b"PC_IMAGE"
|
||||
assert "Browser window" in kwargs["exec_outcome"]
|
||||
|
||||
# Cancelled/invalid calls can lack plugin metadata; the tool name still
|
||||
# identifies the resource boundary and must not fall back to the phone.
|
||||
mock_context.step_memory.reset_mock()
|
||||
state.last_execution_result = {
|
||||
"status": "failed",
|
||||
"execution": [
|
||||
{
|
||||
"action": "plug_test_get_app_state",
|
||||
"result": "操作已打断,结果不确定。",
|
||||
}
|
||||
],
|
||||
}
|
||||
await SummarizerNode(mock_context)(state)
|
||||
kwargs = mock_context.step_memory.dispatch.call_args.kwargs
|
||||
assert kwargs["pre_img_bytes"] is None and kwargs["post_img_bytes"] is None
|
||||
assert "打断" in kwargs["exec_outcome"]
|
||||
@@ -40,9 +40,9 @@ def text_output(text):
|
||||
|
||||
@pytest.fixture
|
||||
def transport_model(monkeypatch):
|
||||
import langchain_openai
|
||||
from langchain_openai import ChatOpenAI
|
||||
import openai
|
||||
|
||||
original = langchain_openai.ChatOpenAI
|
||||
requests = []
|
||||
outputs = []
|
||||
|
||||
@@ -52,16 +52,16 @@ def transport_model(monkeypatch):
|
||||
200, json=response_body(outputs.pop(0) if outputs else text_output("OK"))
|
||||
)
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handle))
|
||||
monkeypatch.setattr(
|
||||
langchain_openai,
|
||||
"ChatOpenAI",
|
||||
lambda **kwargs: original(**kwargs, http_async_client=client),
|
||||
openai,
|
||||
"DefaultAsyncHttpxClient",
|
||||
lambda **kwargs: httpx.AsyncClient(transport=httpx.MockTransport(handle), **kwargs),
|
||||
)
|
||||
monkeypatch.setattr(settings, "OPENAI_API_KEY", SecretStr("test-secret"))
|
||||
monkeypatch.setattr(settings, "OPENAI_BASE_URL", "https://responses.example/v1")
|
||||
monkeypatch.delenv("ARTEMIS_FAKE_LLM", raising=False)
|
||||
model = ModelFactory.create_model(ModelEndpoint(model_name="test-model", timeout_seconds=23))
|
||||
assert isinstance(model, ChatOpenAI)
|
||||
return model, requests, outputs
|
||||
|
||||
|
||||
@@ -284,7 +284,10 @@ def test_video_text_fallback_extracts_responses_blocks():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrapper_structured_output_uses_responses_function_call(transport_model):
|
||||
@pytest.mark.parametrize("mode", ["parsed", "raw", "pipeline"])
|
||||
async def test_wrapper_structured_output_uses_responses_function_call(
|
||||
transport_model, monkeypatch, mode
|
||||
):
|
||||
class Verdict(BaseModel):
|
||||
ok: bool
|
||||
|
||||
@@ -302,9 +305,53 @@ async def test_wrapper_structured_output_uses_responses_function_call(transport_
|
||||
]
|
||||
)
|
||||
wrapper = RobustChatModelWrapper(model, endpoint=ModelEndpoint(model_name="test-model"))
|
||||
result = await wrapper.with_structured_output(Verdict).ainvoke("Check")
|
||||
assert result.ok is True
|
||||
metered = []
|
||||
monkeypatch.setattr(
|
||||
"artemis.services.llm.record_llm_usage",
|
||||
lambda engine, response, **kw: metered.append(response),
|
||||
)
|
||||
structured = wrapper.with_structured_output(Verdict, include_raw=mode == "raw")
|
||||
if mode == "pipeline":
|
||||
from langchain_core.runnables import RunnableLambda
|
||||
|
||||
result = await (structured | RunnableLambda(lambda value: value.ok)).ainvoke("Check")
|
||||
assert result is True
|
||||
else:
|
||||
result = await structured.complete("Check")
|
||||
assert (result["parsed"] if mode == "raw" else result).ok is True
|
||||
if mode == "raw":
|
||||
assert result["parsing_error"] is None
|
||||
assert result["raw"].tool_calls
|
||||
payload = json.loads(requests[0].content)
|
||||
assert requests[0].url.path == "/v1/responses"
|
||||
assert payload["tools"][0]["name"] == "Verdict"
|
||||
assert payload.get("text", {}).get("format", {}).get("type") != "json_schema"
|
||||
from artemis.llm.usage import usage_from_message
|
||||
|
||||
assert len(metered) == 1
|
||||
assert usage_from_message(metered[0])["total_tokens"] == 13
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_key_is_stable_for_exploration_but_separates_roles(
|
||||
transport_model, monkeypatch
|
||||
):
|
||||
from types import SimpleNamespace
|
||||
|
||||
model, requests, _ = transport_model
|
||||
engine = SimpleNamespace(current_session_id="run-1")
|
||||
ctx = SimpleNamespace(data_engine=engine, exploration_id="exploration-1")
|
||||
monkeypatch.setattr("artemis.services.llm.record_llm_usage", lambda *a, **k: None)
|
||||
wrapper = RobustChatModelWrapper(
|
||||
model, ctx, endpoint=ModelEndpoint(model_name="test-model"), role="operator"
|
||||
)
|
||||
await wrapper.ainvoke([HumanMessage(content="first")])
|
||||
engine.current_session_id = "resumed-run-2"
|
||||
await wrapper.ainvoke([HumanMessage(content="first"), HumanMessage(content="next")])
|
||||
wrapper.role = "checker"
|
||||
await wrapper.ainvoke([HumanMessage(content="verify")])
|
||||
payloads = [json.loads(r.content) for r in requests]
|
||||
assert payloads[0]["prompt_cache_key"] == payloads[1]["prompt_cache_key"]
|
||||
assert payloads[1]["prompt_cache_key"] != payloads[2]["prompt_cache_key"]
|
||||
assert "exploration-1" not in payloads[0]["prompt_cache_key"]
|
||||
assert payloads[1]["input"][:1] == payloads[0]["input"]
|
||||
@@ -324,8 +324,8 @@ async def test_v2_observation_without_task_context_never_starts_recording(monkey
|
||||
[
|
||||
("computer", False, True),
|
||||
("computer", True, False),
|
||||
("android", False, False),
|
||||
("mixed", False, False),
|
||||
("android", False, True),
|
||||
("mixed", False, True),
|
||||
],
|
||||
)
|
||||
async def test_pro_cross_window_evidence_does_not_reuse_another_source_before_image(
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Cross-device results must reach the model and history on their own resource."""
|
||||
|
||||
import base64
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
from PIL import Image
|
||||
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.agents.validator.execution_loop import _process_action
|
||||
from artemis.agents.validator.tool_declarations import ToolExecutionResult
|
||||
from artemis.data_engine.engine import DataEngine
|
||||
from artemis.data_engine.history_reader import OfflineHistoryReader
|
||||
from artemis.data_engine.models import ImageRecord, SessionMetadata, StepRecord
|
||||
from artemis.data_engine.storage import StorageManager
|
||||
from artemis.memory.transcript import TranscriptLedger
|
||||
from artemis.plugins.results import plugin_result_messages
|
||||
from artemis.tools.history.screenshot import load_step_screenshot
|
||||
|
||||
|
||||
def _action(call_id, name="plug_test_get_app_state", **kwargs):
|
||||
return dict(
|
||||
action=name,
|
||||
tool_call_id=call_id,
|
||||
plugin_id="test",
|
||||
plugin_status="success",
|
||||
result=f"来源:电脑插件 / {call_id}\nAddress bar",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _stage(ledger, actions):
|
||||
ledger.stage_turn(
|
||||
[
|
||||
AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{"name": a["action"], "id": a["tool_call_id"], "args": {}} for a in actions
|
||||
],
|
||||
),
|
||||
*[
|
||||
ToolMessage(content="Action Recorded", tool_call_id=a["tool_call_id"])
|
||||
for a in actions
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("image_format", ["JPEG", "PNG", "WEBP"])
|
||||
def test_burst_results_reach_responses_once_with_images_before_compression(tmp_path, image_format):
|
||||
image = tmp_path / "pc.jpg"
|
||||
Image.new("RGB", (200, 100), "blue").save(image, format=image_format)
|
||||
engine = SimpleNamespace(get_image_path=lambda _: image)
|
||||
actions = [_action(f"call_{i}", post_image_name="pc") for i in range(4)]
|
||||
# A long accessibility tree and the tail must both survive delivery.
|
||||
actions[0]["result"] += " UI node" * 12000 + " END_OF_TREE"
|
||||
actions[1]["plugin_status"] = "error"
|
||||
actions[1]["result"] = "窗口已关闭,请重新观察。"
|
||||
actions[2].pop("plugin_status")
|
||||
actions[2].pop("result")
|
||||
actions[2].pop("post_image_name")
|
||||
actions[2]["attempts"] = ["Skipped"]
|
||||
report = {"status": "failed", "execution": actions}
|
||||
ledger = TranscriptLedger(image_scrub_depth=1, pending_grace_steps=0)
|
||||
_stage(ledger, actions)
|
||||
ledger.commit_staged(
|
||||
step_key="step1",
|
||||
validator_result=report,
|
||||
tool_results=plugin_result_messages(report, engine),
|
||||
)
|
||||
messages = ledger.render([])
|
||||
outputs = [m for m in messages if isinstance(m, ToolMessage)]
|
||||
assert [m.tool_call_id for m in outputs] == [f"call_{i}" for i in range(4)]
|
||||
assert [m.status for m in outputs] == ["success", "error", "error", "success"]
|
||||
assert "END_OF_TREE" in str(outputs[0].content)
|
||||
assert "未完成或未执行" in str(outputs[2].content)
|
||||
assert "Action Recorded" not in str(messages)
|
||||
wire = ChatOpenAI(model="fixture", api_key="fixture", use_responses_api=True, store=False)
|
||||
payload = wire._get_request_payload(messages)["input"]
|
||||
assert len([item for item in payload if item.get("type") == "function_call_output"]) == 4
|
||||
assert str(payload).count(base64.b64encode(image.read_bytes()).decode()) == 3
|
||||
assert f"data:{Image.MIME[image_format]};base64," in str(payload)
|
||||
# No duplicate outputs at the following turn; old images are eligible for scrubbing again.
|
||||
ledger.commit_staged(
|
||||
validator_result=report, tool_results=plugin_result_messages(report, engine)
|
||||
)
|
||||
later = ledger.render([])
|
||||
assert len([m for m in later if isinstance(m, ToolMessage)]) == 4
|
||||
assert str(later).count(base64.b64encode(image.read_bytes()).decode()) < 3
|
||||
|
||||
|
||||
def test_missing_plugin_screenshot_keeps_real_result_and_reports_missing_evidence(tmp_path):
|
||||
action = _action("call", post_image_name="missing")
|
||||
messages = plugin_result_messages(
|
||||
{"execution": [action]}, SimpleNamespace(get_image_path=lambda _: tmp_path / "missing.jpg")
|
||||
)["call"]
|
||||
assert "Address bar" in str(messages)
|
||||
assert "截图无法读取" in str(messages)
|
||||
assert not any(isinstance(m, HumanMessage) for m in messages)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validator_preserves_model_call_id_and_actual_plugin_result(monkeypatch):
|
||||
name = "plug_test_get_app_state"
|
||||
plugins = SimpleNamespace(
|
||||
bindings={name: None},
|
||||
execute=AsyncMock(
|
||||
return_value=ToolExecutionResult(
|
||||
tool_name=name,
|
||||
tool_call_id="original",
|
||||
status="success",
|
||||
text_summary="PC tree",
|
||||
screenshot_bytes=b"pc",
|
||||
metadata={"plugin_id": "test", "plugin_status": "success"},
|
||||
)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("artemis.plugins.runtime.get_plugin_runtime", lambda _: plugins)
|
||||
ctx = ArtemisContext.model_construct()
|
||||
outcome = await _process_action(
|
||||
SimpleNamespace(ctx=ctx),
|
||||
None,
|
||||
None,
|
||||
{"action": name, "args": {"app": "browser"}, "tool_call_id": "original"},
|
||||
name,
|
||||
"phone",
|
||||
burst=False,
|
||||
)
|
||||
plugins.execute.assert_awaited_once_with(name, {"app": "browser"}, "original")
|
||||
assert outcome.action_item["tool_call_id"] == "original"
|
||||
assert outcome.action_item["result"] == "PC tree"
|
||||
assert base64.b64decode(outcome.post_screenshot_b64) == b"pc"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("live", [False, True])
|
||||
def test_old_mixed_step_history_returns_pc_post_and_never_phone_pre(tmp_path, live):
|
||||
(tmp_path / "images").mkdir()
|
||||
for name, size in [("phone", (100, 200)), ("pc", (200, 100))]:
|
||||
Image.new("RGB", size).save(tmp_path / "images" / f"{name}.jpg")
|
||||
storage = StorageManager(tmp_path / "data_engine.db", tmp_path)
|
||||
session_id = uuid4()
|
||||
storage.create_session(SessionMetadata(session_id=session_id, initial_goal="fixture"))
|
||||
for name in ("phone", "pc"):
|
||||
storage.create_image(ImageRecord(image_name=name))
|
||||
storage.create_step(
|
||||
StepRecord(
|
||||
step_id=uuid4(),
|
||||
session_id=session_id,
|
||||
step_number=24,
|
||||
pre_image_name="phone",
|
||||
last_execution_result={"execution": [_action("original", post_image_name="pc")]},
|
||||
)
|
||||
)
|
||||
reader = OfflineHistoryReader(tmp_path / "data_engine.db", tmp_path, session_id)
|
||||
if live:
|
||||
engine = DataEngine.__new__(DataEngine)
|
||||
engine.get_step_record = reader.get_step_record
|
||||
engine.get_image_path = reader.get_image_path
|
||||
reader = engine
|
||||
assert load_step_screenshot(reader, 24, "pre").image_bytes is None
|
||||
assert (
|
||||
load_step_screenshot(reader, 24, "post").image_bytes
|
||||
== (tmp_path / "images/pc.jpg").read_bytes()
|
||||
)
|
||||
|
||||
|
||||
def test_cross_resource_burst_has_no_single_step_image():
|
||||
from artemis.data_engine.step_evidence import step_image_name
|
||||
|
||||
record = {
|
||||
"pre_image_name": "phone",
|
||||
"post_image_name": "pc",
|
||||
"last_execution_result": {
|
||||
"execution": [{"action": "tap"}, _action("pc", post_image_name="pc")]
|
||||
},
|
||||
}
|
||||
assert step_image_name(record, "pre") is None
|
||||
assert step_image_name(record, "post") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interrupted_plugin_call_is_not_reported_as_executed(monkeypatch):
|
||||
from artemis.runtime.task_control import TaskControl
|
||||
|
||||
name = "plug_test_click"
|
||||
plugins = SimpleNamespace(bindings={name: None}, execute=AsyncMock())
|
||||
monkeypatch.setattr("artemis.plugins.runtime.get_plugin_runtime", lambda _: plugins)
|
||||
control = TaskControl.__new__(TaskControl)
|
||||
|
||||
async def interrupt(operation, **_kwargs):
|
||||
operation.close()
|
||||
return True, None
|
||||
|
||||
control.run_interruptibly = interrupt
|
||||
outcome = await _process_action(
|
||||
SimpleNamespace(ctx=SimpleNamespace(task_control=control)),
|
||||
None,
|
||||
None,
|
||||
{"action": name, "args": {}, "tool_call_id": "interrupted"},
|
||||
name,
|
||||
"phone",
|
||||
burst=False,
|
||||
)
|
||||
assert not outcome.success
|
||||
messages = plugin_result_messages({"execution": [outcome.action_item]}, None)["interrupted"]
|
||||
assert messages[0].status == "error"
|
||||
assert "打断" in str(messages[0].content)
|
||||
plugins.execute.assert_not_awaited()
|
||||
@@ -178,7 +178,11 @@ def test_server_substitutions_do_not_use_shell_or_expand_unknown_values(tmp_path
|
||||
async def test_skill_load_and_reference_cannot_read_outside_package(store):
|
||||
enable(store, manifest())
|
||||
runtime = PluginRuntime(store=store)
|
||||
assert {t.name for t in runtime.declarations()} == {"plugin_load", "plugin_read_file"}
|
||||
assert {t.name for t in runtime.declarations()} == {
|
||||
"plugin_load",
|
||||
"plugin_read_file",
|
||||
"plugin_read_result",
|
||||
}
|
||||
result = await runtime.call("plugin_load", {"plugin_id": "test-plugin"})
|
||||
assert result.status == "success" and "先观察" in result.text()
|
||||
assert (
|
||||
@@ -659,7 +663,7 @@ server.run()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("target", ["computer", "mixed"])
|
||||
@pytest.mark.parametrize("target", ["computer", "mixed", "android"])
|
||||
async def test_flash_plugin_images_never_replace_the_mixed_phone_observation(monkeypatch, target):
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, AsyncMock
|
||||
@@ -689,6 +693,16 @@ async def test_flash_plugin_images_never_replace_the_mixed_phone_observation(mon
|
||||
screenshot_bytes=b"desktop-after",
|
||||
ui_elements_text="desktop-tree",
|
||||
text_summary="Desktop observation",
|
||||
raw_result=[
|
||||
{"type": "text", "text": "Desktop observation"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "data:image/jpeg;base64,"
|
||||
+ base64.b64encode(b"desktop-after").decode()
|
||||
},
|
||||
},
|
||||
],
|
||||
metadata={"plugin_id": "test-plugin"},
|
||||
)
|
||||
),
|
||||
@@ -696,12 +710,13 @@ async def test_flash_plugin_images_never_replace_the_mixed_phone_observation(mon
|
||||
runner._build_action_record = Mock(return_value={"action": name})
|
||||
runner._record_action_step = Mock(return_value="step")
|
||||
state = SimpleNamespace(latest_screenshot="old-phone-path")
|
||||
messages = []
|
||||
result = await runner._execute_and_record_action(
|
||||
name,
|
||||
{},
|
||||
"tc",
|
||||
state,
|
||||
[],
|
||||
messages,
|
||||
"",
|
||||
{},
|
||||
b"phone-before",
|
||||
@@ -714,10 +729,17 @@ async def test_flash_plugin_images_never_replace_the_mixed_phone_observation(mon
|
||||
"",
|
||||
b"desktop-after",
|
||||
)
|
||||
if target == "mixed":
|
||||
assert result[:2] == (b"phone-after", "phone-tree-after")
|
||||
assert state.latest_screenshot == "phone-path"
|
||||
capture.assert_awaited_once()
|
||||
assert messages[0].tool_call_id == "tc"
|
||||
assert "Desktop observation" in str(messages[0].content)
|
||||
assert base64.b64encode(b"desktop-after").decode() in str(messages)
|
||||
if target != "computer":
|
||||
assert result[0] is None
|
||||
assert "安卓旧截图、元素编号和坐标已失效" in result[1]
|
||||
assert state.latest_screenshot is None
|
||||
assert state.indexed_elements == []
|
||||
assert state.indexed_points == []
|
||||
assert runner._android_observation_stale
|
||||
capture.assert_not_awaited()
|
||||
else:
|
||||
assert result[:2] == (b"desktop-after", "desktop-tree")
|
||||
capture.assert_not_awaited()
|
||||
@@ -1012,3 +1034,57 @@ async def test_plugin_image_text_stays_with_its_own_observation(store, tmp_path,
|
||||
assert engine.storage.get_image(names[1]).ui_tree == "changed"
|
||||
finally:
|
||||
await runtime.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_plugin_result_roundtrips_through_task_scoped_reader(store, tmp_path):
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from mcp.types import Tool, ToolAnnotations
|
||||
from artemis.config.constants import PLUGIN_PROMPT_TEXT_CHARS
|
||||
|
||||
plugin = manifest()
|
||||
enable(store, plugin)
|
||||
engine = MagicMock(base_dir=tmp_path / "task-a")
|
||||
ctx = SimpleNamespace(data_engine=engine)
|
||||
runtime = PluginRuntime(store=store, ctx=ctx)
|
||||
tool = Tool(
|
||||
name="observe",
|
||||
inputSchema={"type": "object"},
|
||||
annotations=ToolAnnotations(readOnlyHint=True),
|
||||
)
|
||||
original = "private-value\n" + "node 中文\n" * 9000 + "END"
|
||||
runtime.bindings["plug_test_observe"] = (plugin.id, tool)
|
||||
session = SimpleNamespace(
|
||||
call=AsyncMock(
|
||||
return_value=CallToolResult(content=[TextContent(type="text", text=original)])
|
||||
)
|
||||
)
|
||||
runtime.sessions[plugin.id] = session
|
||||
try:
|
||||
outcome = await runtime.call("plug_test_observe", {})
|
||||
assert outcome.status == "success"
|
||||
assert outcome.text_ref == outcome.call_id
|
||||
assert runtime.is_read_only("plug_test_observe")
|
||||
shown = runtime.content_blocks(outcome)[0]["text"]
|
||||
assert len(shown) <= PLUGIN_PROMPT_TEXT_CHARS
|
||||
assert outcome.text_ref in shown
|
||||
text = outcome.text()
|
||||
pages = []
|
||||
for offset in range(0, len(text), PLUGIN_PROMPT_TEXT_CHARS):
|
||||
page = await runtime.call(
|
||||
"plugin_read_result", {"call_id": outcome.text_ref, "offset": offset}
|
||||
)
|
||||
assert page.status == "success"
|
||||
delivered = runtime.content_blocks(page)[0]["text"]
|
||||
pages.append(delivered.split("\n", 1)[1])
|
||||
assert "".join(pages) == text
|
||||
assert "private-value" not in text and "已隐藏密钥" in text
|
||||
assert text.endswith("END")
|
||||
engine.base_dir = tmp_path / "task-b"
|
||||
inaccessible = await runtime.call("plugin_read_result", {"call_id": outcome.text_ref})
|
||||
assert inaccessible.status == "error"
|
||||
session.call.assert_awaited_once()
|
||||
finally:
|
||||
runtime.sessions.clear()
|
||||
await runtime.aclose()
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Real result preservation, bounded context, and zero-call observation receipts."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from artemis.config.constants import PLUGIN_PROMPT_TEXT_CHARS, PLUGIN_SUMMARY_TEXT_CHARS
|
||||
from artemis.plugins.prompt_budget import bounded_result_text, read_result_text, save_result_text
|
||||
from artemis.plugins.models import PluginCallResult
|
||||
from artemis.plugins.runtime import PluginRuntime
|
||||
|
||||
|
||||
def test_long_plugin_result_is_bounded_but_paged_evidence_is_lossless(tmp_path):
|
||||
engine = SimpleNamespace(base_dir=tmp_path)
|
||||
call_id = "a" * 32
|
||||
original = "Address bar\n" + "关键控件与真实观察\n" * 10000 + "ERROR: window unavailable"
|
||||
save_result_text(engine, call_id, original)
|
||||
prompt = bounded_result_text(original, call_id=call_id)
|
||||
assert len(prompt) <= PLUGIN_PROMPT_TEXT_CHARS
|
||||
assert "Address bar" in prompt and "ERROR: window unavailable" in prompt
|
||||
assert call_id in prompt and "节选" in prompt
|
||||
recovered = "".join(
|
||||
read_result_text(engine, call_id, offset).split("\n", 1)[1]
|
||||
for offset in range(0, len(original), PLUGIN_PROMPT_TEXT_CHARS)
|
||||
)
|
||||
assert recovered == original
|
||||
assert "已到结尾" in read_result_text(engine, call_id, len(original))
|
||||
with pytest.raises(ValueError):
|
||||
read_result_text(engine, call_id, len(original) + 1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["../outside", "a" * 31, "a" * 32 + ".txt", "A" * 32])
|
||||
def test_plugin_evidence_reader_rejects_arbitrary_paths(tmp_path, bad):
|
||||
with pytest.raises(ValueError):
|
||||
read_result_text(SimpleNamespace(base_dir=tmp_path), bad)
|
||||
|
||||
|
||||
def test_flash_plugin_output_is_bounded_without_losing_image_or_error():
|
||||
outcome = PluginCallResult(
|
||||
status="error",
|
||||
plugin_id="pc",
|
||||
text_ref="b" * 32,
|
||||
content=[
|
||||
{"type": "text", "text": "X" * 50000 + "Permission denied"},
|
||||
{"type": "image", "mimeType": "image/png", "data": "AA=="},
|
||||
],
|
||||
)
|
||||
result = PluginRuntime.content_blocks(outcome)
|
||||
assert len(result[0]["text"]) <= PLUGIN_PROMPT_TEXT_CHARS
|
||||
assert "Permission denied" in result[0]["text"]
|
||||
assert result[1]["image_url"]["url"] == "data:image/png;base64,AA=="
|
||||
assert len(outcome.content[0]["text"]) > PLUGIN_PROMPT_TEXT_CHARS
|
||||
|
||||
|
||||
def test_read_only_observation_needs_no_summary_model(monkeypatch):
|
||||
from artemis.agents.flash.summarizer import VisualStepSummarizer
|
||||
|
||||
model = MagicMock()
|
||||
monkeypatch.setattr("artemis.agents.flash.summarizer.get_llm", lambda *a, **k: model)
|
||||
engine = MagicMock()
|
||||
service = VisualStepSummarizer(SimpleNamespace(data_engine=engine))
|
||||
service.dispatch(
|
||||
step_number=2,
|
||||
action_name="plug_test_get_app_state",
|
||||
action_args={},
|
||||
pre_img_bytes=None,
|
||||
post_img_bytes=b"actual screenshot",
|
||||
exec_outcome="UI" * 20000,
|
||||
data_engine_step_id="step-2",
|
||||
action_key="call-2",
|
||||
receipt_only=True,
|
||||
)
|
||||
assert not service.is_pending("call-2")
|
||||
summary = service.get_summary("call-2")
|
||||
assert summary and "不代表视觉验收通过" in summary
|
||||
assert len(summary) < PLUGIN_SUMMARY_TEXT_CHARS + 150
|
||||
assert service.get_job_payload("call-2")["post_img_bytes"] is None
|
||||
assert service.get_job_payload("call-2")["data_engine_step_id"] == "step-2"
|
||||
assert not service._pending_tasks
|
||||
model.ainvoke.assert_not_called()
|
||||
assert engine.update_step_summary.call_args.kwargs["source"] == "tool_receipt"
|
||||
|
||||
|
||||
def test_assistant_message_count_does_not_change_exploration_context(monkeypatch):
|
||||
from artemis.exploration.runtime import conversation_message
|
||||
|
||||
session = {"goal": "inspect website", "message_count": 10}
|
||||
history = [{"sequence": 1, "role": "user", "text": "inspect website"}]
|
||||
store = SimpleNamespace(
|
||||
get=lambda _: session, recent_messages=lambda *a, **k: history, case=lambda _: None
|
||||
)
|
||||
monkeypatch.setattr("artemis.exploration.runtime.explorations", store)
|
||||
monkeypatch.setattr("artemis.exploration.editorial.prior_context", lambda *a, **k: [])
|
||||
ctx = SimpleNamespace(exploration_id="explore", exploration_run_id="run")
|
||||
first = conversation_message(ctx).content
|
||||
session["message_count"] = 99
|
||||
assert conversation_message(ctx).content == first
|
||||
history.append({"sequence": 99, "role": "user", "text": "return home"})
|
||||
assert "return home" in conversation_message(ctx).content
|
||||
|
||||
|
||||
def test_observation_steps_do_not_force_plan_bookkeeping():
|
||||
from artemis.agents.operator.prompts import unwritten_action_streak
|
||||
|
||||
write = {"action_taken": "click"}
|
||||
read = {
|
||||
"action_taken": "get_app_state",
|
||||
"last_execution_result": {"execution": [{"read_only": True}]},
|
||||
}
|
||||
assert unwritten_action_streak([write, read, read, read, read]) == 1
|
||||
assert unwritten_action_streak([write] * 4) == 4
|
||||
|
||||
|
||||
def test_flash_plugin_summary_caps_text_before_model_submission(monkeypatch):
|
||||
from artemis.agents.flash.summarizer import VisualStepSummarizer
|
||||
|
||||
monkeypatch.setattr("artemis.agents.flash.summarizer.get_llm", lambda *a, **k: MagicMock())
|
||||
service = VisualStepSummarizer(SimpleNamespace(data_engine=None))
|
||||
submit = MagicMock()
|
||||
monkeypatch.setattr(service, "submit", submit)
|
||||
service.dispatch(
|
||||
step_number=1,
|
||||
action_name="plug_test_click",
|
||||
action_args={},
|
||||
pre_img_bytes=None,
|
||||
post_img_bytes=b"image",
|
||||
exec_outcome="target clicked" * 10000,
|
||||
post_ui_elements_text="control tree" * 10000,
|
||||
)
|
||||
payload = submit.call_args.args[1]
|
||||
assert len(payload["exec_outcome"]) <= PLUGIN_SUMMARY_TEXT_CHARS
|
||||
assert len(payload["post_ui_elements_text"]) <= PLUGIN_SUMMARY_TEXT_CHARS
|
||||
assert payload["post_img_bytes"] == b"image"
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Per-task archive quality overrides outrank JSON and saved environment defaults."""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -77,3 +78,37 @@ def test_worker_env_keeps_task_quality_separate_from_saved_defaults(monkeypatch)
|
||||
{}, "test-run", None, "fixture", "flash", AdbTarget(AdbEndpoint.local())
|
||||
)
|
||||
assert "ARTEMIS_TASK_RECORDING_CONFIG" not in clean_environment
|
||||
|
||||
|
||||
def test_sdk_preserves_task_recording_scope_through_recorder(tmp_path, monkeypatch):
|
||||
from artemis.plugins.models import EvidenceSource
|
||||
from artemis.plugins.recording import PluginRecordings, capture_rectangle
|
||||
from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder
|
||||
|
||||
monkeypatch.setattr(
|
||||
settings,
|
||||
"ARTEMIS_TASK_RECORDING_CONFIG",
|
||||
{"computer_scope": "window", "codec": "h264", "fps": 7, "max_long_edge": 960},
|
||||
)
|
||||
builder = AgentConfigBuilder()
|
||||
config = builder.build()
|
||||
ctx = SimpleNamespace(
|
||||
agent_config=config,
|
||||
data_engine=SimpleNamespace(base_dir=tmp_path, session_start_time=100),
|
||||
execution_setup=SimpleNamespace(record_video_enabled=True),
|
||||
)
|
||||
recorder = PluginRecordings(ctx)
|
||||
source = EvidenceSource(
|
||||
kind="computer",
|
||||
target_id="fixture",
|
||||
window={"left": 40, "top": 50, "width": 800, "height": 600},
|
||||
screen={"left": 0, "top": 0, "width": 1920, "height": 1080},
|
||||
)
|
||||
assert recorder.config.computer_scope == "window"
|
||||
assert capture_rectangle(source, recorder.config.computer_scope) == source.window
|
||||
assert recorder.config.codec == "h264"
|
||||
assert recorder.config.fps == 7
|
||||
assert recorder.config.max_long_edge == 960
|
||||
# Building another task must not share a mutable recording configuration.
|
||||
config.recording.fps = 12
|
||||
assert builder.build().recording.fps == 7
|
||||
@@ -0,0 +1,340 @@
|
||||
import asyncio
|
||||
from copy import deepcopy
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
from langchain_core.messages import AIMessageChunk, HumanMessage
|
||||
import pytest
|
||||
|
||||
from artemis.llm.diagnostics import (
|
||||
ACTIVE_ATTEMPT,
|
||||
RequestAttempt,
|
||||
error_text,
|
||||
input_images,
|
||||
observe_response_headers,
|
||||
)
|
||||
from artemis.llm.router import ModelEndpoint
|
||||
from artemis.services import llm
|
||||
|
||||
|
||||
class Engine:
|
||||
current_session_id = "diagnostic-test"
|
||||
current_step_id = None
|
||||
|
||||
def __init__(self):
|
||||
self.records = []
|
||||
|
||||
def record_trace(self, **record):
|
||||
self.records.append(deepcopy(record))
|
||||
return record.get("trace_id", uuid4())
|
||||
|
||||
def _publish(self, *_):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_headers_without_body_timeout_then_retry_keeps_evidence(monkeypatch):
|
||||
engine = Engine()
|
||||
monkeypatch.setattr(llm, "_CURRENT_DATA_ENGINE", engine)
|
||||
monkeypatch.setattr(
|
||||
llm, "retry_policy_for", lambda _: SimpleNamespace(delay_for=lambda _: 0, max_attempts=2)
|
||||
)
|
||||
llm._ENDPOINT_BREAKER._states.clear()
|
||||
calls = 0
|
||||
|
||||
class StalledBody(httpx.AsyncByteStream):
|
||||
async def __aiter__(self):
|
||||
await asyncio.sleep(10)
|
||||
yield b""
|
||||
|
||||
async def transport(request):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
headers = {"x-request-id": f"server-{calls}", "set-cookie": "secret-cookie"}
|
||||
return httpx.Response(
|
||||
200, headers=headers, stream=StalledBody() if calls == 1 else httpx.ByteStream(b"ok")
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(transport),
|
||||
event_hooks={"response": [observe_response_headers]},
|
||||
) as client:
|
||||
|
||||
class Model:
|
||||
async def astream(self, *_args, **_kwargs):
|
||||
async with client.stream(
|
||||
"POST",
|
||||
"https://example.test/responses",
|
||||
headers={"Authorization": "Bearer secret-key"},
|
||||
) as response:
|
||||
await response.aread()
|
||||
yield AIMessageChunk(
|
||||
content="ready",
|
||||
usage_metadata={"input_tokens": 10, "output_tokens": 2, "total_tokens": 12},
|
||||
)
|
||||
|
||||
wrapper = llm.RobustChatModelWrapper(
|
||||
Model(), endpoint=ModelEndpoint(timeout_seconds=0.05, max_retries=1)
|
||||
)
|
||||
result = await wrapper.complete([])
|
||||
assert result.content == "ready"
|
||||
snapshots = [r["payload"] for r in engine.records if r["type"] == "llm_diagnostic"]
|
||||
failed = next(p for p in snapshots if p["phase"] == "failed")
|
||||
final = snapshots[-1]
|
||||
assert failed["http_status"] == 200
|
||||
assert failed["server_request_id"] == "server-1"
|
||||
assert failed["failure_phase"] == "waiting_output"
|
||||
assert failed["error_type"] == "TimeoutError" and "0.05 秒" in failed["error"]
|
||||
assert failed["usage"] is None and "first_output_at" not in failed
|
||||
assert final["request_id"] == failed["request_id"]
|
||||
assert final["attempt_id"] != failed["attempt_id"] and final["attempt"] == 2
|
||||
assert final["phase"] == "completed" and final["usage"]["total_tokens"] == 12
|
||||
assert (
|
||||
final["started_at"] <= final["headers_at"] <= final["first_output_at"] <= final["ended_at"]
|
||||
)
|
||||
assert "secret" not in json.dumps(snapshots)
|
||||
assert ACTIVE_ATTEMPT.get() is None
|
||||
assert any(r["name"] == "llm_retry" and r["payload"]["error"] for r in engine.records)
|
||||
|
||||
|
||||
def test_tool_deltas_are_counted_once_without_retaining_arguments():
|
||||
engine = Engine()
|
||||
attempt = RequestAttempt(
|
||||
engine, request_id="tools", attempt=1, timeout=300, max_retries=1, metadata={}
|
||||
)
|
||||
deltas = [
|
||||
AIMessageChunk(
|
||||
content=[], tool_call_chunks=[{"name": "click", "args": "", "id": "c1", "index": 0}]
|
||||
),
|
||||
AIMessageChunk(
|
||||
content=[{"type": "function_call", "arguments": '{"secret":'}],
|
||||
tool_call_chunks=[{"args": '{"secret":', "index": 0}],
|
||||
),
|
||||
AIMessageChunk(content=[], tool_call_chunks=[{"args": '"private"}', "index": 0}]),
|
||||
AIMessageChunk(
|
||||
content=[],
|
||||
tool_call_chunks=[{"name": "wait", "args": '{"seconds":1}', "id": "c2", "index": 1}],
|
||||
),
|
||||
]
|
||||
combined = deltas[0]
|
||||
for i, delta in enumerate(deltas):
|
||||
attempt.chunk(delta)
|
||||
if i:
|
||||
combined += delta
|
||||
attempt.chunk(AIMessageChunk(content=[], response_metadata={"status": "completed"}))
|
||||
attempt.finish("completed", response=combined)
|
||||
assert attempt.data["output_chunks"] == 4
|
||||
assert attempt.data["tool_argument_chars"] == len('{"secret":"private"}{"seconds":1}')
|
||||
assert attempt.data["tools"] == [
|
||||
{"name": "click", "argument_chars": len('{"secret":"private"}')},
|
||||
{"name": "wait", "argument_chars": len('{"seconds":1}')},
|
||||
]
|
||||
assert attempt.data["response_status"] == "completed"
|
||||
assert "private" not in json.dumps(engine.records, default=str)
|
||||
assert "secret" not in json.dumps(engine.records, default=str)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_during_tool_generation_keeps_last_output_and_tool_name(monkeypatch):
|
||||
engine = Engine()
|
||||
monkeypatch.setattr(llm, "_CURRENT_DATA_ENGINE", engine)
|
||||
llm._ENDPOINT_BREAKER._states.clear()
|
||||
calls = 0
|
||||
|
||||
class Model:
|
||||
async def astream(self, *_args, **_kwargs):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
yield AIMessageChunk(
|
||||
content=[],
|
||||
tool_call_chunks=[{"name": "click", "args": '{"x":', "id": "c1", "index": 0}],
|
||||
)
|
||||
if calls == 1:
|
||||
await asyncio.sleep(10)
|
||||
yield AIMessageChunk(content=[], tool_call_chunks=[{"args": "1}", "index": 0}])
|
||||
|
||||
monkeypatch.setattr(
|
||||
llm, "retry_policy_for", lambda _: SimpleNamespace(delay_for=lambda _: 0, max_attempts=2)
|
||||
)
|
||||
wrapper = llm.RobustChatModelWrapper(
|
||||
Model(), endpoint=ModelEndpoint(timeout_seconds=0.03, max_retries=1, max_tokens=12000)
|
||||
)
|
||||
await wrapper.complete([])
|
||||
failed = next(
|
||||
r["payload"]
|
||||
for r in engine.records
|
||||
if r["type"] == "llm_diagnostic" and r["payload"]["phase"] == "failed"
|
||||
)
|
||||
assert failed["failure_phase"] == "generating_tool_arguments"
|
||||
assert failed["last_output_at"] >= failed["first_output_at"]
|
||||
assert failed["tools"] == [{"name": "click", "argument_chars": 5}]
|
||||
assert failed["usage"] is None
|
||||
assert failed["max_output_tokens"] == 12000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_requests_do_not_mix_server_ids():
|
||||
engine = Engine()
|
||||
|
||||
async def run(name):
|
||||
attempt = RequestAttempt(
|
||||
engine, request_id=name, attempt=1, timeout=5, max_retries=0, metadata={"model": name}
|
||||
)
|
||||
token = ACTIVE_ATTEMPT.set(attempt)
|
||||
try:
|
||||
await asyncio.sleep(0)
|
||||
await observe_response_headers(httpx.Response(200, headers={"x-request-id": name}))
|
||||
finally:
|
||||
ACTIVE_ATTEMPT.reset(token)
|
||||
return attempt.data
|
||||
|
||||
first, second = await asyncio.gather(run("one"), run("two"))
|
||||
assert first["server_request_id"] == "one"
|
||||
assert second["server_request_id"] == "two"
|
||||
|
||||
|
||||
def test_error_redaction_and_image_headers():
|
||||
import base64
|
||||
import io
|
||||
from PIL import Image
|
||||
|
||||
out = io.BytesIO()
|
||||
Image.new("RGB", (32, 48)).save(out, "PNG")
|
||||
message = HumanMessage(
|
||||
content=[
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "data:image/png;base64," + base64.b64encode(out.getvalue()).decode()
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
assert input_images(([message],), {}) == [{"width": 32, "height": 48}]
|
||||
text = error_text(
|
||||
RuntimeError("Authorization: Bearer sk-my-secret https://host/path?token=private")
|
||||
)
|
||||
assert "sk-my-secret" not in text and "private" not in text
|
||||
assert error_text(RuntimeError()) == "RuntimeError"
|
||||
|
||||
|
||||
def test_snapshots_survive_out_of_order_writes_and_export(tmp_path):
|
||||
from artemis.data_engine.models import TraceRecord
|
||||
from artemis.data_engine.storage import StorageManager
|
||||
from apps.admin_console.database.connection import db_session
|
||||
from apps.admin_console.services.request_diagnostics import request_diagnostics
|
||||
|
||||
path = tmp_path / "data.db"
|
||||
storage = StorageManager(db_path=path, base_trace_dir=tmp_path)
|
||||
session_id, trace_id = uuid4(), uuid4()
|
||||
with db_session(path) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO sessions(session_id,status) VALUES (?, 'completed')", (str(session_id),)
|
||||
)
|
||||
conn.commit()
|
||||
for sequence, phase in [(3, "completed"), (2, "receiving_output"), (1, "waiting_output")]:
|
||||
storage.create_trace(
|
||||
TraceRecord(
|
||||
trace_id=trace_id,
|
||||
session_id=session_id,
|
||||
type="llm_diagnostic",
|
||||
name="llm_request",
|
||||
timestamp=10 + sequence,
|
||||
status="success" if sequence == 3 else "running",
|
||||
payload={
|
||||
"sequence": sequence,
|
||||
"phase": phase,
|
||||
"request_id": "call-one",
|
||||
"started_at": 10,
|
||||
"ended_at": 13 if sequence == 3 else None,
|
||||
},
|
||||
)
|
||||
)
|
||||
result = request_diagnostics(str(session_id), db_path=path)
|
||||
assert len(result["requests"]) == 1
|
||||
assert result["requests"][0]["phase"] == "completed"
|
||||
assert result["requests"][0]["active"] is False
|
||||
assert result["requests"][0]["timestamps_utc"]["started_at"].endswith("+00:00")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancellation_closes_attempt_and_does_not_retry(monkeypatch):
|
||||
engine = Engine()
|
||||
monkeypatch.setattr(llm, "_CURRENT_DATA_ENGINE", engine)
|
||||
llm._ENDPOINT_BREAKER._states.clear()
|
||||
|
||||
async def cancelled():
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await llm._run_with_recovery(cancelled, max_retries=5)
|
||||
assert engine.records[-1]["payload"]["phase"] == "cancelled"
|
||||
assert ACTIVE_ATTEMPT.get() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelling_retry_wait_does_not_leave_a_live_request(monkeypatch):
|
||||
waiting = asyncio.Event()
|
||||
|
||||
class RetryEngine(Engine):
|
||||
def record_trace(self, **record):
|
||||
result = super().record_trace(**record)
|
||||
if record["payload"].get("phase") == "retry_wait":
|
||||
waiting.set()
|
||||
return result
|
||||
|
||||
engine = RetryEngine()
|
||||
monkeypatch.setattr(llm, "_CURRENT_DATA_ENGINE", engine)
|
||||
monkeypatch.setattr(
|
||||
llm, "retry_policy_for", lambda _: SimpleNamespace(delay_for=lambda _: 60, max_attempts=2)
|
||||
)
|
||||
|
||||
async def timeout():
|
||||
raise TimeoutError()
|
||||
|
||||
task = asyncio.create_task(llm._run_with_recovery(timeout, max_retries=1))
|
||||
await asyncio.wait_for(waiting.wait(), 2)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
assert engine.records[-1]["payload"]["phase"] == "cancelled"
|
||||
assert "模型请求" in engine.records[-1]["payload"]["error"]
|
||||
|
||||
|
||||
def test_diagnostic_api_limits_preview_but_downloads_complete_history(tmp_path, monkeypatch):
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from apps.admin_console.database import connection
|
||||
from apps.admin_console.routers.sessions import router
|
||||
from artemis.data_engine.storage import StorageManager
|
||||
|
||||
path = tmp_path / "data.db"
|
||||
StorageManager(db_path=path, base_trace_dir=tmp_path)
|
||||
monkeypatch.setattr(connection, "DB_PATH", path)
|
||||
with connection.db_session() as conn:
|
||||
conn.execute("INSERT INTO sessions(session_id,status) VALUES ('task', 'running')")
|
||||
for i in range(105):
|
||||
value = {
|
||||
"request_id": "one-request",
|
||||
"attempt_id": str(i),
|
||||
"started_at": i,
|
||||
"phase": "completed" if i == 104 else "retry_wait",
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO traces(trace_id,session_id,type,timestamp,payload) VALUES (?, 'task', 'llm_diagnostic', ?, ?)",
|
||||
(str(i), i, json.dumps(value)),
|
||||
)
|
||||
conn.commit()
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
with TestClient(app) as client:
|
||||
preview = client.get("/api/sessions/task/request-diagnostics")
|
||||
assert preview.status_code == 200
|
||||
assert len(preview.json()["requests"]) == 100
|
||||
assert not any(r["active"] for r in preview.json()["requests"])
|
||||
download = client.get("/api/sessions/task/request-diagnostics?download=true")
|
||||
assert download.headers["content-disposition"].startswith("attachment;")
|
||||
assert len(download.json()["requests"]) == 105
|
||||
assert download.json()["session_id"] == "task"
|
||||
Reference in new issue
Block a user