Last validated: Langflow 1.12.x (nightly 1.12.0.dev18)
Issue: #1247 · Scoped by: #1195 → a2a-coverage-scope.md (row T6) ·
Depends on: #1240 (LANGFLOW_A2A_ENABLED=true on every lane), #1242 / PR #1243
(requireA2aEnabled(), postA2AJsonRpc, messageSendEnvelope) ·
Jira: epic LE-1588
message/send is only half of the task surface. A caller that lost the connection
has to read the task back; a caller that changed its mind has to cancel it;
and a caller asking about a task that is not theirs must be told nothing. This
spec covers those three, plus the error contract they answer with.
The error codes are the point. The product ships a shim —
_SpecErrorAdapter in langflow/api/v1/a2a.py — whose entire job is to stop the
SDK's catch-all from wrapping every failure in InternalError (-32603), which
would make "no such task" indistinguishable from "the agent broke". It maps
TaskNotFoundError → -32001, TaskNotCancelableError → -32002,
UnsupportedOperationError → -32004, InvalidParamsError → -32602. That
shim is the regression surface this spec exists to guard: a test that accepted
"an error" would stay green the day it breaks and a conforming client starts
receiving -32603 for everything.
Two behaviours are safety properties rather than conveniences:
- Cancelling a finished task is refused (-32002), not absorbed. The handler
explicitly declines to clobber a real
COMPLETEDwithCANCELED— so the spec asserts both the refusal and that the stored state is unchanged afterwards. - Task ids do not leak across flows. The in-memory registry is keyed by task id alone, so the handler gates on a flow-scoped store first and returns "not found" for a task belonging to another flow — the code comments that it must "never reveal that it exists under another flow". Asserting -32001 (and not -32002) through a second flow's endpoint is what pins that.
@stable @api @regression @a2a
@api— drives/api/v1/a2a/{id}/jsonrpcthroughrequest; no UI.@regression— the spec-code mapping is a fix that can regress to -32603, and the cross-flow gate is a leak that was closed deliberately.@a2a— functional area; requiresLANGFLOW_A2A_ENABLED=true(CLAUDE.md).@stable— validated by the team and promoted in #1349: the batch ran 51/51 green (17 tests × 3,--retries=0) on nightly1.12.0.dev18, with no leaked flow and no backend error logged.
All four over HTTP 200 — JSON-RPC errors are not HTTP errors on this endpoint (the contract #1243 pinned for -32601/-32600):
- Read-back.
tasks/geton the idmessage/sendreturned gives the same task: sameid, samecontextId, sameartifacts[0].artifactId, the samestatus.timestamp, statecompleted, and the sentinel still atartifacts[0].parts[0].text. Identity of the artifact id and timestamp is what distinguishes a read-back from a silent re-run. - Unknown id.
tasks/getfor a random UUID →error.code === -32001,error.message === "Task not found". - Terminal cancel is refused, and harmless.
tasks/cancelon the completed task →error.code === -32002("Task cannot be canceled"), and a followingtasks/getstill reportscompletedwith the samestatus.timestamp— the refusal did not touch stored state. - Cross-flow isolation. The same task id, cancelled through a second
published flow's endpoint →
error.code === -32001, never -32002 or -32004: flow B must not be able to tell that the task exists at all.
And the live path:
- Cancelling a running task terminates it. A
message/streamrun started with a large payload is cancelled the moment itssubmittedframe yields the task id: thetasks/cancelresponse carriesresult.status.state === "canceled", and a subsequenttasks/getreads backcanceled.
LANGFLOW_A2A_ENABLED=trueon the instance under test — set byscripts/start-langflow-docker.shand every CI lane since #1240; asserted at runtime byrequireA2aEnabled().- No LLM, no provider key, no external network. Both flows are the Chat Input →
Chat Output passthrough (
createRunnableChatFlowViaApi()). - Auto-login superuser (
getAuthToken()). - A ~2 MB text payload on the cancel test only — see below. It costs the run about 2.7 s of CPU and no network.
- Langflow reachable at
PLAYWRIGHT_BASE_URLwith A2A enabled. - The cross-flow test needs two published flows; both are created by the test
and deleted by id in
finally. No pre-test wipe.
Test 1 — a task can be read back and refuses a cancel it cannot honour
requireA2aEnabled; create + publish flow A.message/sendwith a per-run sentinel → capturetaskId,contextId,artifacts[0].artifactId,status.timestamp.tasks/get→ assert every field of criterion 1.tasks/getwith a random UUID →-32001.tasks/cancelontaskId→-32002; thentasks/getagain → stillcompleted, samestatus.timestamp.finally: delete the flow by id.
Test 2 — a task id is invisible to another flow
requireA2aEnabled; create + publish flows A and B.message/sendon A →taskId.tasks/cancel{ id: taskId }posted to B's endpoint →-32001.- Positive control in the same test:
tasks/geton A still returns the taskcompleted— so a blanket "everything is -32001" bug cannot pass this. finally: delete both flows by id.
Test 3 — cancelling a running task moves it to canceled
requireA2aEnabled; create + publish flow A.POST message/streamwith a sentinel prefixed to ~2 MB of filler (see Measured behaviour — this is what makes the run long enough to cancel deterministically). Read the SSE stream only until the first frame carryingresult.id.- Immediately
tasks/cancelthat id → assertresult.status.state === "canceled". - Stop reading the stream;
tasks/get→canceled. finally: delete the flow by id.
| # | Test | Observable |
|---|---|---|
| 1 | read back + refused cancel | identical id/contextId/artifactId/timestamp + sentinel; unknown id -32001; terminal cancel -32002; state and timestamp unchanged after |
| 2 | cross-flow isolation | -32001 through flow B (not -32002/-32004), while flow A still reads the task completed |
| 3 | live cancel | tasks/cancel returns state: "canceled" and tasks/get confirms it |
- The exact wire values, measured, not inferred:
{"code":-32001,"message":"Task not found","data":null}and{"code":-32002,"message":"Task cannot be canceled","data":null}, both underHTTP 200. - The 2 MB payload is margin, not a requirement — and the force-fail proved it.
Measured: a 1 KB run completes in ~121 ms while the task id reaches the client at
52–182 ms, so a cancel issued the instant the id appears wins by only 20–36 ms.
It wins anyway, 3/3 by hand and again when a force-fail attempt deliberately
shrank the payload back to 1 KB — that mutation was rejected for not making
the test fail, which is the evidence that the narrow window still passes locally.
Run time scales with the payload (1 KB → 121 ms, 200 KB → 423 ms, 2 MB →
2687 ms), so the 2 MB message buys a ~2.4 s margin (cancel landing at
121–259 ms) instead of 20 ms. It is kept because a 20 ms margin is not something
a shared CI lane should be asked to reproduce on a loaded runner — not because
the assertion needs it here. Either way it is the deliberate alternative to a
waitForTimeoutracing the run. - Not-reading the SSE stream does not park the task. Measured: leaving the
stream unconsumed for 3 s still ends in
completed, then-32002— so backpressure is not a way to widen the window, and the payload is. - Streaming errors still collapse to -32603, stated in the product's own
docstring ("fixing that means reimplementing that generator, tracked
separately"). So no spec code is asserted on a
message/stream/tasks/resubscribeerror frame — only on the non-streamingtasks/*responses. Test 3 asserts the cancel response, which is non-streaming. tasks/resubscriberequires aWORKINGdurable state and a live registry entry, otherwise-32004by design, withtasks/getas the documented way to read a terminal task. Not covered here: proving the negative would assert a design decision, and proving the positive re-races the same window Test 3 already covers from the cancel side.