feat(a2a): implement full A2A task lifecycle state support#1025
feat(a2a): implement full A2A task lifecycle state support#1025agent-of-mkmeral wants to merge 6 commits into
Conversation
Implements the complete A2A task lifecycle on both server and client sides, matching the Python SDK implementation (strands-agents/harness-sdk#2245). ## Server-side (executor.ts) - **failed**: Agent errors now gracefully transition task to 'failed' state instead of re-throwing. Error details stay in server logs (no leaking). - **input-required**: Agent returning stopReason='interrupt' transitions task to 'input-required' with interrupt details in the status message. - **canceled**: Cooperative cancellation via AbortController. cancelTask() signals the agent to stop; task transitions to 'canceled'. - **CancelledError handling**: CancelledError from the agent is caught and transitions to 'canceled' (not 'failed'). ## Client-side (a2a-agent.ts) - **STATE_TO_STOP_REASON mapping**: Single source of truth for mapping A2A task states to Strands StopReason values: - completed/failed/rejected/unknown → 'endTurn' - canceled → 'cancelled' - input-required/auth-required → 'interrupt' - **_isCompleteEvent**: Now recognizes all terminal and input states as complete events (prevents infinite polling on failed/canceled tasks). - **a2aTaskState metadata**: Task state included in invocationState for downstream consumers. ## Tests - 15 executor tests (was 7): +8 tests for failed, canceled, input-required, multi-interrupt, error leak prevention, CancelledError, and cancelTask - 39 a2a-agent tests (was 28): +11 tests for parametrized state mapping, a2aTaskState metadata, terminal state recognition, and input-required Closes: strands-agents/harness-sdk#2245 (TS counterpart)
|
Assessment: Comment Solid implementation of the A2A task lifecycle. The code is well-structured, tests are comprehensive with good edge case coverage, and the approach aligns well with the Python SDK implementation. Review Categories
The test coverage is thorough with clear parametrized tests and good separation of concerns between the server and client sides. |
|
@agent-of-mkmeral bro CI is failing. make sure precommit hooks run beforehand. also check the comments here |
- Fix type errors: use Interrupt class instances instead of plain objects in executor tests (toJSON method required by Interrupt type) - Fix lint: remove unused TERMINAL_STATES/INPUT_STATES from executor.ts (state classification is done via stopReason branching, not sets) - Fix lint: prefix unused eventBus param with underscore in cancelTask - Fix lint: add eslint-disable require-yield for test generators that only return (no yield needed for mock agents) - Fix formatting: run prettier on executor.ts - Fix docs: input_required to input-required (match A2A protocol)
|
Fixed in CI Fixes
Review Comments AddressedAll 5 automated review comments are now resolved:
Verified Locally
|
|
Assessment: Approve All 5 previous review comments have been addressed in |
mkmeral
left a comment
There was a problem hiding this comment.
also update from the main
and once you are done, kick off another agent to do adversarial testing here. i want it to test against python implementation and other sample a2a implementations to make sure these lifecycles work e2e. (ie there's input required, i provide input and it keeps working. and it happens both for a2a agent and executor)
When the A2A client receives 'input-required', it now reconstructs
Interrupt objects and includes them in the AgentResult. Previously,
stopReason was 'interrupt' but result.interrupts was undefined.
## Server-side (executor.ts)
- Add structured DataPart alongside the text part when publishing
input-required status. The DataPart carries the full interrupt JSON
(id, name, reason) for lossless round-tripping through A2A protocol.
## Client-side (a2a-agent.ts)
- Add _extractInterrupts() that reconstructs Interrupt objects from:
1. Structured DataPart (preferred — preserves id, name, reason)
2. Fallback synthetic Interrupt from status text (interop with
other A2A servers that don't send structured data)
- _buildResult() now passes interrupts to AgentResult when
stopReason is 'interrupt'
## Merge from main
- Rebased on latest main (15 commits) to pick up npm-pack CI,
py-check, WIT rewrite, security fixes, and other changes.
## Tests
- +3 a2a-agent tests: structured DataPart reconstruction, synthetic
fallback from text, non-interrupt states don't set interrupts
- Updated 2 executor tests: verify DataPart is included in
interrupt status messages
Addresses: @mkmeral review comment on a2a-agent.ts:349
|
Fixed in What was wrong@mkmeral correctly identified that What changedServer-side (executor.ts)
Client-side (a2a-agent.ts)
Merged from main
Tests
Verified locally
Kicking off adversarial testing agent next per your request. |
agent-of-mkmeral
left a comment
There was a problem hiding this comment.
TL;DR: FAIL — 5 issues found (2 bugs, 2 unhandled edge cases, 1 usability issue). 42 adversarial tests written, all pass — confirming the issues are real. Core lifecycle works well; findings are at the edges.
Adversarial Testing Report
Scope: Full A2A task lifecycle — executor state transitions, client interrupt reconstruction, DataPart round-tripping, cancellation error types, concurrent tasks, Python SDK parity, and edge cases in all 7 lifecycle states.
Baseline: 94 existing tests pass ✅
Adversarial tests written: 42 (21 executor + 21 client)
All 42 pass: confirming 5 findings are real (bugs prove themselves by asserting the wrong behavior)
| # | Category | Severity | Description |
|---|---|---|---|
| 1 | Bug | High | Client creates Interrupt with undefined id/name from malformed DataPart — no validation |
| 2 | Bug | Medium | DOMException(AbortError) falls through to failed instead of canceled in executor |
| 3 | Unhandled Edge Case | Medium | interrupt.toJSON() leaks response field into DataPart serialization |
| 4 | Unhandled Edge Case | Medium | Object reason renders as [object Object] in human-readable text part |
| 5 | Parity Gap | Low | Executor invocationState key is a2aRequestContext (TS) vs a2a_request_context (Python) — cosmetic but breaks cross-SDK consumers |
Finding 1 — Bug (High): Client creates Interrupt with undefined id/name from malformed DataPart
Reproduction:
// A2AAgent receives a status-update with DataPart containing interrupts without id:
const statusUpdate = {
kind: 'status-update',
status: {
state: 'input-required',
message: {
parts: [
{ kind: 'text', text: 'Need input' },
{ kind: 'data', data: { interrupts: [{ name: 'confirm', reason: 'ok' }] } }
// ^^^ missing 'id' field!
]
}
}
};
const result = await agent.invoke('Hello');
console.log(result.interrupts[0].id); // undefined ← violates `id: string` contract
console.log(result.interrupts[0].name); // 'confirm'Observed behavior: Interrupt is created with id: undefined, violating the { id: string, name: string } type contract. Downstream code calling interruptState.getOrCreateInterrupt(interrupt.id, ...) will use undefined as a key.
Expected behavior: Should either validate and reject malformed DataPart (fall through to synthetic interrupt) or generate a default id.
Root cause: _extractInterrupts() in a2a-agent.ts line ~290 blindly trusts raw.id and raw.name from the DataPart without validation:
return rawInterrupts.map((raw) => new Interrupt({ id: raw.id, name: raw.name, ... }))Fix: Add validation before creating Interrupt objects:
if (rawInterrupts.length > 0 && rawInterrupts.every(r => r.id && r.name)) {
return rawInterrupts.map(...);
}
// else fall through to synthetic interruptArtifact: test_adversarial_a2a_agent.test.ts → "malformed DataPart interrupts" (3 tests)
Finding 2 — Bug (Medium): DOMException AbortError treated as failure instead of cancellation
Reproduction:
// Agent throws native AbortError (from AbortController in browser/polyfill environments)
const agent = {
async *stream() {
throw new DOMException('The operation was aborted.', 'AbortError');
}
};
const executor = new A2AExecutor(agent);
await executor.execute(context, eventBus);
// → status.state === 'failed' ← WRONG, should be 'canceled'Observed behavior: DOMException with name AbortError falls through the CancelledError instanceof check and hits the generic catch(error) block, producing state: 'failed'.
Expected behavior: state: 'canceled' — same as CancelledError.
Root cause: executor.ts catch block only checks error instanceof CancelledError. In Node.js and browser environments, AbortController.abort() can propagate DOMException or Error with name: 'AbortError' instead of CancelledError. Python handles this with asyncio.CancelledError (a BaseException).
Fix:
} catch (error) {
if (error instanceof CancelledError ||
(error instanceof DOMException && error.name === 'AbortError') ||
(error instanceof Error && error.name === 'AbortError')) {
// → canceled
} else {
// → failed
}
}Artifact: test_adversarial_executor.test.ts → "cancellation error type handling" (2 BUG tests)
Finding 3 — Unhandled Edge Case (Medium): interrupt.toJSON() leaks response field into DataPart
Reproduction:
// Interrupt with a response field set (e.g., from a previous resume cycle)
const interrupt = new Interrupt({
id: 'int-1', name: 'confirm', reason: 'Are you sure?',
response: 'secret-user-input-data'
});
// Executor serializes it into DataPart for A2A protocol:
interruptParts.push({ kind: 'data', data: { interrupts: [interrupt.toJSON()] } });
// → DataPart contains: { response: 'secret-user-input-data' }
// The response field leaks to the remote client!Observed behavior: toJSON() includes response when set, which gets serialized into the A2A DataPart and sent over the wire.
Expected behavior: Only id, name, and reason should be serialized in the DataPart. The response field is the user's input from a previous cycle and should not be retransmitted.
Fix: In executor.ts, strip response during serialization:
data: { interrupts: result.interrupts.map(i => ({ id: i.id, name: i.name, ...(i.reason !== undefined ? { reason: i.reason } : {}) })) }Artifact: test_adversarial_executor.test.ts → "interrupt serialization security" (1 test)
Finding 4 — Unhandled Edge Case (Medium): Object reason renders as [object Object] in text
Reproduction:
// Interrupt with object reason (valid JSONValue):
const interrupt = new Interrupt({
id: 'int-1', name: 'form-input',
reason: { fields: ['name', 'email'], required: true }
});
// Executor renders text part:
`[${i.name}]: ${i.reason ?? 'Input required'}`
// → '[form-input]: [object Object]'Observed behavior: Template literal coerces the object to [object Object].
Expected behavior: Should use JSON.stringify() for non-string reasons, or at least produce a human-readable representation.
Fix:
const reasonStr = typeof i.reason === 'string' ? i.reason
: i.reason != null ? JSON.stringify(i.reason)
: 'Input required';Artifact: test_adversarial_executor.test.ts → "complex interrupt data" (1 test)
Finding 5 — Parity Gap (Low): invocationState key naming differs from Python SDK
TS executor: invocationState: { a2aRequestContext: context } (camelCase)
Python executor: invocation_state = {"a2a_request_context": context} (snake_case)
Not a bug per se — each SDK follows its language conventions — but if a cross-SDK consumer (e.g., a polyglot hook library) expects a consistent key, this breaks. Worth documenting.
Artifact: test_adversarial_executor.test.ts → "Python SDK parity" (1 test)
What Survived (36 tests passed — correct behavior confirmed)
- ✅ Empty interrupts array (
[]) correctly transitions toinput-requiredwith generic message - ✅
undefinedinterrupts correctly transitions toinput-required - ✅ Interrupt
reason: undefined→ uses fallback "Input required" - ✅ Interrupt
reason: null→ nullish coalescing correctly triggers fallback - ✅ Interrupt
reason: 0andreason: ''(falsy but valid JSONValues) → preserved correctly - ✅
CancelledError→canceledstate - ✅ Concurrent tasks execute independently with separate state
- ✅ Canceling one task doesn't affect another
- ✅ Task cleanup in
_runningTasksafter success, error, and cancellation - ✅ Complex object
reasonround-trips correctly through DataPart - ✅ All 7 lifecycle states map correctly: completed→endTurn, failed→endTurn, canceled→cancelled, rejected→endTurn, input-required→interrupt, auth-required→interrupt, unknown→endTurn
- ✅
auth-requiredcreates synthetic interrupt with correct fallback - ✅ Caller
invocationStateis enriched witha2aTaskStatewithout mutation - ✅ Accumulated artifact text used as fallback when status has no message
- ✅ Status-update text takes priority over accumulated artifact text
- ✅ Input-required uses status text (not artifact text) as fallback interrupt reason
- ✅ Empty stream → endTurn with empty text, no spurious interrupts
- ✅ Unknown future states degrade gracefully to endTurn
- ✅
workingstate as last event → endTurn (premature stream end handled) - ✅ DataPart malformed (non-array, missing key, empty array) → falls through to synthetic interrupt
- ✅ Failed status message doesn't leak error details
- ✅ Python parity: empty interrupts + interrupt stopReason → input-required (matches Python)
🤖 AI agent response. Strands Agents. Feedback welcome!
|
Assessment: Comment The interrupt round-tripping via Review Categories
Previous review items are all cleanly resolved. The core design is sound. |
|
@agent-of-mkmeral check the latest reviews, adversarial testing results and so on. update the pr |
## Review feedback (3 threads) - Use Interrupt.fromJSON() instead of manual construction (idiomatic) - Use full object assertions with toStrictEqual per TESTING.md - Add validation on incoming DataPart structure (skip malformed entries) ## Adversarial testing findings (4 of 5 addressed) 1. HIGH: Malformed DataPart with missing id/name now filtered out — falls through to synthetic interrupt instead of creating invalid Interrupt objects with undefined fields 2. MEDIUM: DOMException(AbortError) now caught alongside CancelledError — transitions to 'canceled' instead of 'failed' in browser/polyfill envs 3. MEDIUM: DataPart serialization now explicitly picks id/name/reason — prevents interrupt.toJSON() from leaking the response field 4. MEDIUM: Object reasons now rendered with JSON.stringify instead of template literal coercion to [object Object] 5. LOW (not fixed): invocationState key naming follows language convention (camelCase in TS, snake_case in Python) — documented, not a bug ## Verified - tsc --noEmit: zero type errors - eslint: zero warnings - prettier --check: all formatted - vitest: all 94 A2A tests pass
|
Updated in What changedReview feedback (3 threads → all resolved)
Adversarial testing findings (4 of 5 fixed)
Verified
|
|
Assessment: Comment The adversarial testing fixes are solid — Review Categories
All previous review items and the core robustness improvements are properly addressed. The code is in good shape. |
…eason tests ## Round 2 — addressing latest automated review (18:44 UTC) ### Review thread 1: per-field assertions still present - Replaced per-field assertions in a2a-agent.test.ts with toStrictEqual(toJSON()) for both structured and synthetic interrupt tests - Previous fix silently failed (string replacement mismatch); now fixed by line-index editing ### Review thread 2: missing test coverage for new behaviors - Added test: DOMException(AbortError) → 'canceled' state (not 'failed') - Added test: object reason → JSON.stringify in text part ## Verified - tsc --noEmit: zero type errors - eslint: zero warnings - prettier --check: all formatted - vitest: 96 A2A tests pass (+2 new)
|
Round 2 update in What happened with the previous pushThe What's fixed now
New tests (+2)
Verified
|
|
Assessment: Approve All feedback from rounds 1–4 is resolved. The test assertions now use |
|
@mkmeral Everything is fully addressed — here's the current status: PR Status ✅Reviews
CI (commit
|
| Check | Status |
|---|---|
| Tests (Node 20/22/24 × ubuntu/windows/macos) | ✅ 9/9 pass |
| Code Quality (tsc + eslint + prettier) | ✅ |
| NPM Security Audit | ✅ |
| Python Check (ruff + pyright) | ✅ |
| Pack Install | ✅ |
| PR Title | ✅ |
| Integration Tests | ⏳ Waiting (needs manual approval) |
What was addressed across 4 rounds
Round 1 — @mkmeral's review + automated feedback:
- Interrupt round-tripping: DataPart on server,
_extractInterrupts()on client - CI fixes: type errors, lint, formatting
Round 2 — Automated review:
Interrupt.fromJSON()instead of manual construction- Validation filter for malformed DataParts
Round 3 — Adversarial testing (4/5 findings fixed):
- Malformed DataPart → filtered (HIGH)
- DOMException(AbortError) →
cancelednotfailed(MEDIUM) - Explicit serialization — no
responsefield leak (MEDIUM) JSON.stringifyfor object reasons (MEDIUM)
Round 4 — Test assertion fixes:
toStrictEqual(toJSON())for both interrupt tests- +2 new tests (AbortError, object reason)
Bottom line
PR is approved, CI green (except integration tests needing manual trigger), all threads resolved. Ready for merge whenever you are 👍
|
This repository has been merged into the strands-agents/harness-sdk monorepo and will be archived shortly. All new development happens there. If this PR is still relevant, please recreate it against the monorepo. The code now lives under Apologies for the disruption, and thank you for contributing! |
|
@mkmeral Status changed since last time — this is no longer a review-iteration problem, it's a repo migration issue. Here's what I found: 🚨 This repo is being archivedAt 19:21 UTC today (right before your ping),
The PR's merge state is now
|
| Feature (this PR) | In monorepo? |
|---|---|
Task lifecycle states (input-required, canceled, failed) |
❌ |
Interrupt round-tripping via DataPart |
❌ |
_extractInterrupts() + Interrupt.fromJSON() |
❌ |
cancelTask() implementation |
❌ (still throws unsupportedOperation) |
AbortError → canceled handling |
❌ |
| 96 A2A tests | ❌ (monorepo has the older test set) |
The monorepo's executor.ts _buildResult still always returns stopReason: 'endTurn'.
🔜 Path forward
The work needs to be recreated as a fresh PR against strands-agents/harness-sdk, rebased on its current strands-ts/src/a2a/ (which has diverged — different imports, logging/logger.js, hooks/events.js paths, etc.). This is a real port, not a cherry-pick.
Want me to go ahead and open the migrated PR in harness-sdk? I'll:
- Re-apply the task-lifecycle + interrupt round-tripping changes onto the monorepo's current A2A files
- Port the 96 tests, adapting to the new module layout
- Run tsc/eslint/prettier/vitest locally before pushing
- Open the PR with a summary linking back to this one (feat(a2a): implement full A2A task lifecycle state support #1025)
Just confirm and I'll start. (I held off auto-creating a cross-repo PR since it's a non-trivial port and I wanted your go-ahead.)
Summary
Implements the complete A2A task lifecycle on both server and client sides, matching the Python SDK implementation (strands-agents/sdk-python#2245).
Server-side (
executor.ts)completedstopReason: 'endTurn'failedinput-requiredstopReason: 'interrupt'canceledcancelTask()called or agent returnsstopReason: 'cancelled'Key changes:
failedstate with a generic message (no internal details leaked).cancelTask()now works — uses AbortController to signal the agent. The agent stops at the next cancellation checkpoint and the task transitions tocanceled.stopReason: 'interrupt'), the task transitions toinput-requiredwith interrupt names/reasons in the status message.canceled(notfailed).Client-side (
a2a-agent.ts)completedendTurnfailedendTurncanceledcancelledrejectedendTurninput-requiredinterruptauth-requiredinterruptunknownendTurnKey changes:
_isCompleteEvent: Now recognizes ALL terminal and input states (was: onlycompleted). This prevents infinite polling when a remote agent fails or requires input.STATE_TO_STOP_REASON: Single source of truth mapping (derived sets prevent drift).a2aTaskStatemetadata: The task state is included ininvocationState.a2aTaskStatefor downstream consumers to inspect.Python part of TS SDK
The
strands-py/directory has no A2A code — A2A is handled entirely in the TypeScript layer. No changes needed there.Tests
All 54 tests pass ✅
Related
cc @mkmeral