Stop reporting a silent workflow as a completed task

n8n answers 200 with an empty body when a node throws before the Respond node
runs — a refused token exchange is the common way in. The adapter read that as
a completed task carrying "(the workflow returned nothing)", which tells the
caller the task succeeded: the one thing certainly false.

Off-contract replies get the same treatment. They are still returned raw so
drift is visible, but a body that is not the contract cannot be read as
agreement.

Also removes a backlog section that described the events as unbuilt and said an
event should carry sub and azp from the caller's token — the design the user
overruled. Anyone reading it would have built the wrong consumer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Stéphane Tailland
2026-08-31 14:39:03 +02:00
parent 78456c7902
commit 6eb77d862f
5 changed files with 85 additions and 58 deletions
+15 -7
View File
@@ -11,7 +11,7 @@ import httpx
import pytest
from a2a_n8n_adapter.config import Settings
from a2a_n8n_adapter.server import _read, build_agent_card
from a2a_n8n_adapter.server import Outcome, _read, build_agent_card
def settings(**over) -> Settings:
@@ -35,27 +35,35 @@ def response(payload, status=200) -> httpx.Response:
class TestReadingTheWorkflow:
def test_the_contracted_shape(self) -> None:
assert _read(response({"answer": "yes", "escalate": False})) == ("yes", False)
assert _read(response({"answer": "yes", "escalate": False})) == ("yes", Outcome.ANSWERED)
def test_n8n_wraps_its_last_node_in_an_array(self) -> None:
assert _read(response([{"answer": "yes"}])) == ("yes", False)
assert _read(response([{"answer": "yes"}])) == ("yes", Outcome.ANSWERED)
def test_escalation_is_carried_not_swallowed(self) -> None:
# An autonomous agent meeting a rule that needs approval must escalate.
# Losing this flag would turn "a human must look" into "done".
assert _read(response({"answer": "needs approval", "escalate": True})) == (
"needs approval",
True,
Outcome.ESCALATED,
)
def test_an_off_contract_reply_is_returned_raw(self) -> None:
# Visible rather than reinterpreted: a workflow that has drifted should
# look wrong, not be quietly guessed at.
text, escalate = _read(response("plain text"))
assert text == "plain text" and escalate is False
# Off contract is not agreement. Returned raw so the drift is visible,
# but never as a completed answer.
text, outcome = _read(response("plain text"))
assert text == "plain text" and outcome is Outcome.OFF_CONTRACT
def test_an_empty_reply_says_so(self) -> None:
assert _read(response("")) == ("(the workflow returned nothing)", False)
# HTTP 200 with an empty body is what n8n returns when a node throws
# before the reply is built — a refused token exchange, usually. Reading
# it as a completed answer told the caller the task succeeded, which was
# the one thing certainly false.
text, outcome = _read(response(""))
assert outcome is Outcome.SILENT
assert "no answer" in text
class TestAgentCard: