mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* fix(harness): keep timestamped output-name fallbacks distinct `output_name_candidates()` sampled `datetime.now(UTC)` inside the yield loop, so its five timestamped fallbacks were only distinct when the host clock advanced between two generator steps. On a coarse clock (Windows advances in sub-millisecond steps) all five produced the same name, and the capture tools lost retries before failing with `could not reserve an output filename`. Sample the clock once when the first fallback is reached and offset each attempt by one microsecond, so distinctness no longer depends on the host clock. Add a regression test that freezes the clock, and update the exhaustion test to hold all five distinct stamps. * docs: describe the coarse clock accurately --------- Co-authored-by: PerryLink <255665900+PerryLink@users.noreply.github.com>
101 lines
4.1 KiB
Python
101 lines
4.1 KiB
Python
# SPDX-License-Identifier: MIT
|
|
"""Both capture tools take output names from one shared candidate sequence."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
from datetime import UTC, datetime
|
|
from itertools import islice
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from deerflow.utils import output_names
|
|
|
|
browser_automation_tools = importlib.import_module("deerflow.community.browser_automation.tools")
|
|
browserless_tools = importlib.import_module("deerflow.community.browserless.tools")
|
|
|
|
|
|
class _StepClock(datetime):
|
|
"""Each call advances one microsecond, so every fallback stamp is distinct."""
|
|
|
|
ticks = 0
|
|
|
|
@classmethod
|
|
def now(cls, tz=None):
|
|
cls.ticks += 1
|
|
return datetime(2026, 10, 18, 12, 0, 0, cls.ticks, tzinfo=UTC)
|
|
|
|
|
|
def test_candidates_keep_the_name_then_number_then_timestamp(monkeypatch):
|
|
monkeypatch.setattr(output_names, "MAX_COLLISION_PROBES", 2)
|
|
monkeypatch.setattr(_StepClock, "ticks", 0)
|
|
monkeypatch.setattr(output_names, "datetime", _StepClock)
|
|
|
|
assert list(output_names.output_name_candidates("report.png")) == [
|
|
"report.png",
|
|
"report-1.png",
|
|
"report-2.png",
|
|
*(f"report-20261018-120000-{tick:06d}.png" for tick in range(1, output_names.MAX_TIMESTAMP_ATTEMPTS + 1)),
|
|
]
|
|
|
|
|
|
class _FrozenClock(datetime):
|
|
"""A coarse host clock: every sample inside one burst returns the same instant."""
|
|
|
|
@classmethod
|
|
def now(cls, tz=None):
|
|
return datetime(2026, 10, 18, 12, 0, 0, tzinfo=UTC)
|
|
|
|
|
|
def test_timestamped_fallbacks_stay_distinct_on_a_coarse_clock(monkeypatch):
|
|
# A host clock whose tick is coarser than the microsecond stamp (Windows'
|
|
# system-time tick is ~15 ms) hands back one value for all five samples in a
|
|
# burst. The fallback bound must not depend on the clock advancing between
|
|
# yields.
|
|
monkeypatch.setattr(output_names, "MAX_COLLISION_PROBES", 1)
|
|
monkeypatch.setattr(output_names, "datetime", _FrozenClock)
|
|
|
|
names = list(output_names.output_name_candidates("report.png"))
|
|
|
|
assert len(names) == 1 + 1 + output_names.MAX_TIMESTAMP_ATTEMPTS
|
|
assert len(set(names)) == len(names)
|
|
|
|
|
|
def test_candidates_read_the_clock_only_when_a_fallback_is_reached(monkeypatch):
|
|
monkeypatch.setattr(_StepClock, "ticks", 0)
|
|
monkeypatch.setattr(output_names, "datetime", _StepClock)
|
|
|
|
assert len(list(islice(output_names.output_name_candidates("a.png"), output_names.MAX_COLLISION_PROBES + 1))) == output_names.MAX_COLLISION_PROBES + 1
|
|
assert _StepClock.ticks == 0
|
|
|
|
|
|
def test_both_capture_tools_share_the_candidate_sequence():
|
|
for module in (browser_automation_tools, browserless_tools):
|
|
assert module.output_name_candidates is output_names.output_name_candidates
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_screenshot_retries_a_held_timestamp_fallback(tmp_path, monkeypatch):
|
|
# Browserless already retried a held timestamped name; the screenshot tool
|
|
# now shares that bound instead of giving up after one stamp.
|
|
monkeypatch.setattr(output_names, "MAX_COLLISION_PROBES", 1)
|
|
monkeypatch.setattr(_StepClock, "ticks", 0)
|
|
monkeypatch.setattr(output_names, "datetime", _StepClock)
|
|
held = ["report.png", "report-1.png", "report-20261018-120000-000001.png"]
|
|
for name in held:
|
|
(tmp_path / name).write_bytes(b"earlier")
|
|
|
|
session = MagicMock()
|
|
session.screenshot_bytes = AsyncMock(return_value=b"shot")
|
|
manager = MagicMock()
|
|
manager.get_session.return_value = session
|
|
runtime = SimpleNamespace(context={"thread_id": "thread-1"}, state={"thread_data": {"outputs_path": str(tmp_path)}})
|
|
with patch.object(browser_automation_tools, "get_browser_session_manager", return_value=manager), patch.object(browser_automation_tools, "_get_tool_config", return_value={}):
|
|
result = await browser_automation_tools.browser_screenshot_tool.coroutine(runtime=runtime, tool_call_id="t1", filename="report.png")
|
|
|
|
assert result.update["artifacts"] == ["/mnt/user-data/outputs/report-20261018-120000-000002.png"]
|
|
assert (tmp_path / "report-20261018-120000-000002.png").read_bytes() == b"shot"
|
|
assert all((tmp_path / name).read_bytes() == b"earlier" for name in held)
|