diff --git a/backend/tests/test_agent_teams_plugin.py b/backend/tests/test_agent_teams_plugin.py index 0f790e9d6d..93f25c0be4 100644 --- a/backend/tests/test_agent_teams_plugin.py +++ b/backend/tests/test_agent_teams_plugin.py @@ -1,6 +1,7 @@ """Team extension contracts exercised through the installed contribution.""" import asyncio +import json from pathlib import Path from types import SimpleNamespace @@ -943,3 +944,107 @@ async def test_cancel_reconciles_a_clarification_response_with_lost_admission_ac view = await actions["get"]({"team_id": team["id"]}, context(runs)) assert view["jobs"][0]["status"] == "cancelled" assert len(view["jobs"]) == 1 and len(runs.starts) == 2 + + +async def waiting_job(actions, service, runs, *, clarification=False): + """Park a job for either an explicit interrupt or an ordinary clarification.""" + team = await create(actions, runs) + sent = await actions["send"]({"team_id": team["id"], "member_id": team["members"][0]["id"], "text": "Check release", "request_id": "one"}, context(runs)) + await service.tick() + runs.finish(sent["id"], interrupted=not clarification) + if clarification: + from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware + + request = SimpleNamespace(tool_call={"name": "ask_clarification", "id": "question", "args": {"question": "Which environment?", "clarification_type": "missing_info"}}, runtime=None) + command = ClarificationMiddleware().wrap_tool_call(request, lambda _: pytest.fail("intercept clarification")) + runs.threads[team["members"][0]["thread_id"]]["values"]["messages"].extend(m.model_dump() for m in command.update["messages"]) + await service.tick() + assert (await actions["get"]({"team_id": team["id"]}, context(runs)))["jobs"][0]["status"] == "waiting_input" + return team, sent + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "answer", + [ + pytest.param("漢" * 2000, id="cjk-2000-chars"), + pytest.param("🙂" * 1000, id="emoji-1000-chars"), + pytest.param("a" * 4000, id="ascii-4000-chars"), + pytest.param('"' * 4000, id="json-quotes"), + pytest.param("\n" * 3999 + "a", id="json-newlines"), + ], +) +async def test_a_clarification_answer_within_the_documented_bounds_is_accepted(plugin, answer): + # README: tasks and clarification answers accept up to 4,000 characters / 8,000 UTF-8 bytes, + # and the UI validates exactly those two bounds before submitting. + _, actions, service = plugin + runs = Runs() + team, sent = await waiting_job(actions, service, runs) + await actions["resume"]({"team_id": team["id"], "job_id": sent["id"], "response": answer, "request_id": "answer"}, context(runs)) + assert (await actions["get"]({"team_id": team["id"]}, context(runs)))["jobs"][0]["status"] == "resuming" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("answer", ["漢" * 3000, {"text": "x" * 9000}]) +async def test_an_oversized_clarification_answer_is_still_refused(plugin, answer): + _, actions, service = plugin + runs = Runs() + team, sent = await waiting_job(actions, service, runs) + with pytest.raises(ValueError, match="8 KiB"): + await actions["resume"]({"team_id": team["id"], "job_id": sent["id"], "response": answer, "request_id": "answer"}, context(runs)) + assert (await actions["get"]({"team_id": team["id"]}, context(runs)))["jobs"][0]["status"] == "waiting_input" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("clarification", [False, True], ids=["interrupt", "clarification"]) +@pytest.mark.parametrize("size", [7999, 8000, 8001]) +async def test_string_response_uses_the_raw_utf8_byte_boundary(plugin, clarification, size): + _, actions, service = plugin + runs = Runs() + team, sent = await waiting_job(actions, service, runs, clarification=clarification) + answer = "漢" * 2666 + "a" * (size - 7998) + payload = {"team_id": team["id"], "job_id": sent["id"], "response": answer, "request_id": "answer"} + if size > 8000: + with pytest.raises(ValueError, match="8 KiB"): + await actions["resume"](payload, context(runs)) + else: + await actions["resume"](payload, context(runs)) + job = (await service.db("get", "alice", team["id"]))["jobs"][0] + assert job["status"] == ("waiting_input" if size > 8000 else "resuming") + if size <= 8000: + assert job["resume"]["response"] == answer + if clarification: + assert job["resume"]["input"]["messages"][0]["content"] == answer + + +@pytest.mark.asyncio +@pytest.mark.parametrize("size", [7999, 8000, 8001]) +async def test_structured_response_keeps_the_utf8_json_byte_boundary(plugin, size): + _, actions, service = plugin + runs = Runs() + team, sent = await waiting_job(actions, service, runs) + # The JSON object adds 12 bytes around its string value. + answer = {"text": "漢" * 2660 + "a" * (size - 7980 - 12)} + payload = {"team_id": team["id"], "job_id": sent["id"], "response": answer, "request_id": "answer"} + if size > 8000: + with pytest.raises(ValueError, match="8 KiB"): + await actions["resume"](payload, context(runs)) + else: + await actions["resume"](payload, context(runs)) + job = (await service.db("get", "alice", team["id"]))["jobs"][0] + assert job["status"] == ("waiting_input" if size > 8000 else "resuming") + if size <= 8000: + assert job["resume"]["response"] == answer + + +@pytest.mark.asyncio +@pytest.mark.parametrize("response_json", ['"\\ud800"', '"\\udc00"', '{"text": "\\ud800"}', '{"\\udc00": true}']) +async def test_malformed_unicode_response_uses_the_friendly_validation_error(plugin, response_json): + _, actions, service = plugin + runs = Runs() + team, sent = await waiting_job(actions, service, runs) + with pytest.raises(ValueError, match="^An explicit response of at most 8 KiB is required$"): + await actions["resume"]({"team_id": team["id"], "job_id": sent["id"], "response": json.loads(response_json), "request_id": "answer"}, context(runs)) + job = (await service.db("get", "alice", team["id"]))["jobs"][0] + assert job["status"] == "waiting_input" + assert job["resume"] is None diff --git a/examples/deerflow-extension-agent-teams/AGENTS.md b/examples/deerflow-extension-agent-teams/AGENTS.md index 48b3419d80..93b900ac28 100644 --- a/examples/deerflow-extension-agent-teams/AGENTS.md +++ b/examples/deerflow-extension-agent-teams/AGENTS.md @@ -27,6 +27,8 @@ backend character/UTF-8 byte limits. Use host theme tokens for action colors. Validate user-entered team names, goals, tasks and clarification answers against both backend bounds without truncating the draft. Native mention labels must fit the host's 120 UTF-16-unit limit; keep routing IDs independent of display labels. +Measure string responses as raw UTF-8 and structured interrupt responses as UTF-8 +JSON. Reject malformed Unicode before changing a waiting job or admitting a run. Ordinary human-input artifacts end runs successfully without a graph interrupt. Keep unanswered requests waiting; persist text-response inputs and idempotency diff --git a/examples/deerflow-extension-agent-teams/README.md b/examples/deerflow-extension-agent-teams/README.md index fae4c6691e..e78685c101 100644 --- a/examples/deerflow-extension-agent-teams/README.md +++ b/examples/deerflow-extension-agent-teams/README.md @@ -109,7 +109,11 @@ own teams. Sharing a host conversation does **not** share its team's private dat - Team names accept up to 80 characters / 160 UTF-8 bytes; goals accept up to 2,000 characters / 4,000 UTF-8 bytes. Tasks and clarification answers accept up to 4,000 characters / 8,000 UTF-8 bytes. The UI validates both bounds before - submission and preserves oversized text for editing. Native mention labels + submission and preserves oversized text for editing. String answers count their + raw UTF-8 bytes, including the 8,000-byte ceiling; JSON quotes and escapes do not + reduce that allowance. Structured graph-interrupt responses retain an 8,000-byte + UTF-8 JSON limit. Responses with unpaired Unicode surrogates are rejected through + the normal response validation. Native mention labels fall back to the member name when the combined team/member label exceeds the host's 120 UTF-16-unit limit; selection still routes by team/member IDs. Shared context contains @@ -151,7 +155,9 @@ creation), request capacity independent of receipts, ambiguous admission, and lifecycle locking. Host-run transport is controlled for deterministic tests. Clarification tests execute the host's real tool and middleware in an Agent graph, including repeated questions, peer/mention/result-receipt paths, lost response -acknowledgements and restart. Queued-request regressions pin handoff attribution +acknowledgements and restart. Response regressions cover the 7,999/8,000/8,001-byte +boundaries for both input paths, structured JSON, escaping and malformed Unicode. +Queued-request regressions pin handoff attribution and the chain limit while the first request is running or awaiting admission acknowledgement. For a browser check, install the repo's frontend dependencies, then start the diff --git a/examples/deerflow-extension-agent-teams/deerflow_extension_agent_teams/service.py b/examples/deerflow-extension-agent-teams/deerflow_extension_agent_teams/service.py index 2b13411ca2..54b9dd3f74 100644 --- a/examples/deerflow-extension-agent-teams/deerflow_extension_agent_teams/service.py +++ b/examples/deerflow-extension-agent-teams/deerflow_extension_agent_teams/service.py @@ -318,7 +318,13 @@ class Teams: async def resume(self, payload, context): fields(payload, "team_id job_id response request_id") - if payload["response"] is None or len(json.dumps(payload["response"]).encode()) > 8000: + response = payload["response"] + try: + content = response if isinstance(response, str) else json.dumps(response, ensure_ascii=False) + valid = response is not None and len(content.encode("utf-8")) <= 8000 + except UnicodeEncodeError: + valid = False + if not valid: raise ValueError("An explicit response of at most 8 KiB is required") text(payload, "request_id", 128) return await self.control(payload, context, cancel=False)