@@ -447,8 +447,13 @@ export class DefaultRequestHandler implements A2ARequestHandler {
447447 // Publish a synthetic error event so the consumer's event loop
448448 // can settle the first-result promise and so any concurrent
449449 // resubscribers see the failure on the wire.
450+ //
451+ // The synthetic Task id MUST be `requestContext.taskId` — that's
452+ // the id the bus is registered under and the id we hand back to
453+ // the client. Fabricating a fresh `uuidv4()` here would make the
454+ // returned Task unreachable via `getTask` (TaskNotFoundError).
450455 const errorTask : Task = {
451- id : requestContext . task ?. id || uuidv4 ( ) ,
456+ id : requestContext . taskId ,
452457 contextId : finalMessageForAgent . contextId ! ,
453458 status : {
454459 state : TaskState . TASK_STATE_FAILED ,
@@ -540,23 +545,48 @@ export class DefaultRequestHandler implements A2ARequestHandler {
540545 /**
541546 * Streaming variant of {@link _runExecutor}.
542547 *
543- * Differs in error handling: only publishes a synthetic statusUpdate
544- * (not a fresh Task event) when the executor rejects, because the
545- * stream is required to start with the executor's own Task event per
546- * §3.1.2. If the executor failed BEFORE publishing any Task event
547- * (e.g. argument validation), the consumer would otherwise hang
548- * forever — `eventBus.finished()` in the finally block unblocks it.
548+ * Error handling mirrors the blocking path:
549+ *
550+ * * If the executor has already published a Task event before
551+ * throwing, only a synthetic statusUpdate(FAILED) is published —
552+ * publishing a fresh Task event in that state would violate the
553+ * §3.1.2 task-lifecycle ordering enforced by
554+ * {@link _advanceStreamPattern}.
555+ * * If the executor threw BEFORE publishing any Task event (e.g.
556+ * argument validation, auth check), we synthesize BOTH the Task
557+ * event and the statusUpdate(FAILED) so the SSE consumer sees a
558+ * well-formed task-lifecycle stream that terminates in FAILED.
559+ * Previously this path silently returned, leaving the client with
560+ * an empty stream and no signal that the request failed —
561+ * asymmetric with the blocking path which always synthesizes the
562+ * error Task.
563+ *
564+ * The synthetic Task id is `requestContext.taskId` (the bus
565+ * registration key and the id the client will use for subsequent
566+ * `getTask` calls); the executor-published `latestTask` (if any) is
567+ * preferred for the statusUpdate so the failure carries the same id
568+ * the consumer has already seen on the wire.
569+ *
570+ * Note: we read the most-recent published Task and task state off
571+ * the bus via {@link trackLatestTaskAndState} rather than from
572+ * `ResultManager`. The consumer loop that drains the bus into
573+ * `ResultManager` runs in a separate microtask, so
574+ * `ResultManager.getCurrentTask()` would still return `undefined`
575+ * immediately after a synchronous `bus.publish(...)` followed by a
576+ * `throw` — which is exactly the typical executor pattern.
549577 */
550578 private _runStreamExecutor (
551579 taskId : string ,
552580 eventBus : ExecutionEventBus ,
553- requestContext : RequestContext ,
554- resultManager : ResultManager
581+ requestContext : RequestContext
555582 ) : void {
556583 const finalMessageForAgent = requestContext . userMessage ;
557- // See `_runExecutor` for why we snoop the bus directly instead of
558- // re-reading state from `resultManager` in the `.finally` block.
559- const stateTracker = trackLatestTaskState ( eventBus ) ;
584+ // Single per-execution listener captures both the most-recent Task
585+ // event and the most-recent task state — see
586+ // `trackLatestTaskAndState` for why combining them in one listener
587+ // avoids double dispatch and why reading the snapshot from
588+ // ResultManager here is unsafe.
589+ const snapshotTracker = trackLatestTaskAndState ( eventBus ) ;
560590 this . agentExecutor
561591 . execute ( requestContext , eventBus )
562592 . catch ( ( err : unknown ) => {
@@ -568,24 +598,65 @@ export class DefaultRequestHandler implements A2ARequestHandler {
568598 `Agent execution failed for stream message ${ finalMessageForAgent . messageId } :` ,
569599 err
570600 ) ;
571- // Only publish a synthetic error status update if the task already
572- // exists in the store. If the agent failed before creating a task,
573- // the `finished()` signal below unblocks the consumer without
574- // producing a stream-pattern violation.
575- const currentTask = resultManager . getCurrentTask ( ) ;
576- if ( ! currentTask ) {
577- return ;
601+
602+ const latestTask = snapshotTracker ( ) . task ;
603+ const errorTaskId = latestTask ?. id ?? requestContext . taskId ;
604+ const errorContextId = latestTask ?. contextId ?? finalMessageForAgent . contextId ! ;
605+
606+ // If no Task event has been published yet, synthesize one first
607+ // so the SSE consumer's stream pattern transitions into
608+ // TASK_LIFECYCLE (per §3.1.2) before the statusUpdate(FAILED)
609+ // lands. Without this, the executor would silently close an
610+ // empty stream and the client would have no way to learn the
611+ // request failed — the asymmetry called out in PR 2.
612+ if ( ! latestTask ) {
613+ const errorTask : Task = {
614+ id : requestContext . taskId ,
615+ contextId : finalMessageForAgent . contextId ! ,
616+ status : {
617+ state : TaskState . TASK_STATE_FAILED ,
618+ message : {
619+ role : Role . ROLE_AGENT ,
620+ messageId : uuidv4 ( ) ,
621+ taskId : requestContext . taskId ,
622+ contextId : finalMessageForAgent . contextId ! ,
623+ parts : [
624+ {
625+ content : { $case : 'text' , value : `Agent execution error: ${ errorMessage } ` } ,
626+ mediaType : 'text/plain' ,
627+ filename : '' ,
628+ metadata : { } ,
629+ } ,
630+ ] ,
631+ metadata : { } ,
632+ extensions : [ ] ,
633+ referenceTaskIds : [ ] ,
634+ } ,
635+ timestamp : new Date ( ) . toISOString ( ) ,
636+ } ,
637+ artifacts : [ ] ,
638+ history : requestContext . task ?. history ? [ ...requestContext . task . history ] : [ ] ,
639+ metadata : { } ,
640+ } ;
641+ if (
642+ finalMessageForAgent &&
643+ ! errorTask . history ?. find ( ( m ) => m . messageId === finalMessageForAgent . messageId )
644+ ) {
645+ errorTask . history ?. push ( finalMessageForAgent ) ;
646+ }
647+ eventBus . publish ( AgentEvent . task ( errorTask ) ) ;
578648 }
649+
579650 const errorTaskStatus : TaskStatusUpdateEvent = {
580- taskId : requestContext . taskId ,
581- contextId : finalMessageForAgent . contextId ! ,
651+ taskId : errorTaskId ,
652+ contextId : errorContextId ,
582653 status : {
583654 state : TaskState . TASK_STATE_FAILED ,
584655 message : {
585656 role : Role . ROLE_AGENT ,
586657 messageId : uuidv4 ( ) ,
587- taskId : requestContext . taskId ,
588- contextId : finalMessageForAgent . contextId ! ,
658+ taskId : errorTaskId ,
659+ contextId : errorContextId ,
589660 parts : [
590661 {
591662 content : { $case : 'text' , value : `Agent execution error: ${ errorMessage } ` } ,
@@ -605,10 +676,11 @@ export class DefaultRequestHandler implements A2ARequestHandler {
605676 eventBus . publish ( AgentEvent . statusUpdate ( errorTaskStatus ) ) ;
606677 } )
607678 . finally ( ( ) => {
608- // Closes the bus for terminal tasks; kept alive for
679+ // Detach the bus listener and read the final state in one
680+ // call. Closes the bus for terminal tasks; kept alive for
609681 // INPUT_REQUIRED / AUTH_REQUIRED so follow-up sends and
610682 // resubscribers can still attach.
611- this . _settleBus ( taskId , eventBus , stateTracker ( ) ) ;
683+ this . _settleBus ( taskId , eventBus , snapshotTracker ( ) . state ) ;
612684 } ) ;
613685 }
614686
@@ -777,7 +849,7 @@ export class DefaultRequestHandler implements A2ARequestHandler {
777849 // to attach via `tasks/resubscribe` while the executor keeps
778850 // running, so we cannot tear down the bus here when this generator
779851 // settles.
780- this . _runStreamExecutor ( taskId , eventBus , requestContext , resultManager ) ;
852+ this . _runStreamExecutor ( taskId , eventBus , requestContext ) ;
781853
782854 let streamPattern = StreamPattern . UNDETERMINED ;
783855 try {
@@ -1246,3 +1318,75 @@ function trackLatestTaskState(bus: ExecutionEventBus): () => TaskState | undefin
12461318 return lastState ;
12471319 } ;
12481320}
1321+
1322+ /**
1323+ * Snapshot exposed by {@link trackLatestTaskAndState}.
1324+ */
1325+ interface LatestTaskSnapshot {
1326+ /**
1327+ * The most-recent `Task` event published on the bus, or `undefined`
1328+ * if no Task event has been seen.
1329+ */
1330+ task : Task | undefined ;
1331+ /**
1332+ * The most-recent task state observed on the bus — either via a
1333+ * `Task` event or a subsequent `TaskStatusUpdateEvent`. May be more
1334+ * recent than `task.status.state`.
1335+ */
1336+ state : TaskState | undefined ;
1337+ }
1338+
1339+ /**
1340+ * Subscribes a single lightweight listener on `bus` that records both:
1341+ *
1342+ * * the most-recent `Task` event published, and
1343+ * * the most-recent task state (from `Task` or
1344+ * `TaskStatusUpdateEvent`, whichever is newer).
1345+ *
1346+ * Returns a thunk that detaches the listener and yields the snapshot.
1347+ *
1348+ * Combines what used to be two separate per-execution listeners into
1349+ * one to avoid double dispatch on every `bus.publish(...)`. Used by
1350+ * {@link DefaultRequestHandler._runStreamExecutor} to make two
1351+ * decisions in the executor's `.finally` / `.catch` blocks:
1352+ *
1353+ * 1. Whether to settle the bus or keep it alive for follow-ups
1354+ * (driven by `state`).
1355+ * 2. Whether to synthesize a Task event before the terminal
1356+ * statusUpdate on the error path (driven by `task`).
1357+ *
1358+ * Reading state from `ResultManager` would be unsafe at the point we
1359+ * need to make these decisions: the consumer loop that drains the bus
1360+ * into `ResultManager` runs in a separate microtask, so an executor
1361+ * that synchronously `bus.publish(...)`s a Task and then throws would
1362+ * appear (via `ResultManager`) to have published nothing — and we'd
1363+ * incorrectly re-publish a Task, violating the §3.1.2 stream-pattern
1364+ * ordering enforced by `_advanceStreamPattern`.
1365+ *
1366+ * Detach contract: the returned thunk must be invoked exactly once,
1367+ * in a `.finally` block, so the listener is removed regardless of
1368+ * whether the executor succeeded or threw. Otherwise long-lived buses
1369+ * (kept alive for INPUT_REQUIRED / AUTH_REQUIRED) would accumulate
1370+ * one listener per turn. The thunk's `bus.off` is idempotent at the
1371+ * bus level, so an extra read after the listener is already detached
1372+ * just returns the last-known snapshot.
1373+ */
1374+ function trackLatestTaskAndState ( bus : ExecutionEventBus ) : ( ) => LatestTaskSnapshot {
1375+ let lastTask : Task | undefined ;
1376+ let lastState : TaskState | undefined ;
1377+ const listener = ( event : AgentExecutionEvent ) => {
1378+ if ( event . kind === 'task' ) {
1379+ lastTask = event . data ;
1380+ if ( event . data . status ?. state !== undefined ) {
1381+ lastState = event . data . status . state ;
1382+ }
1383+ } else if ( event . kind === 'statusUpdate' && event . data . status ?. state !== undefined ) {
1384+ lastState = event . data . status . state ;
1385+ }
1386+ } ;
1387+ bus . on ( 'event' , listener ) ;
1388+ return ( ) => {
1389+ bus . off ( 'event' , listener ) ;
1390+ return { task : lastTask , state : lastState } ;
1391+ } ;
1392+ }
0 commit comments