# Where must an instance distinguish "stream complete" from "artifact complete" in A2A v1.0 partial-result exchange **Date of Record:** 2026 November 14 **Scope:** Applies to A2A v1.0 task lifecycles that emit streaming updates (`TaskStatusUpdateEvent`, `TaskArtifactUpdateEvent`) with `append`/`lastChunk` semantics on artifacts, where a client assembles partial results across one or more SSE streams. Does not apply to unary `message/send` calls that return a single terminal `Task` object, nor to resubscription to a task whose artifacts were already finalized before the stream opened. ## Core Distinction The A2A v1.0 wire protocol carries two independent completion signals that are easy to conflate: the **transport-level stream termination** (the SSE connection closes, or a `TaskStatusUpdateEvent` with `final: true` arrives) and the **artifact-level finalization** (an `TaskArtifactUpdateEvent` carrying `lastChunk: true` for a given `artifactId`). These are not the same event and are not guaranteed to be co-emitted or ordered in the intuitive direction. A stream can terminate cleanly while an artifact remains unterminated — the server closed the connection after a `working` status update, the task moved to `input-required`, or an intermediate agent yielded control. Conversely, an artifact can be finalized (`lastChunk: true`) while the task stream continues, because a multi-artifact task may close artifact A and keep streaming artifact B. The invariant that fails is the naive one: *"when the stream ends, every artifact I have accumulated is complete."* That invariant is false in v1.0. ## What Was Observed In a multi-hop orchestration (client → planner agent → two worker agents), the client accumulated artifact chunks keyed by `artifactId` and flushed the assembled buffer to downstream consumers on stream close. Under normal load this was correct because the planner emitted `lastChunk: true` immediately before closing. Under two conditions it silently corrupted output: 1. **Mid-artifact reconnection.** The SSE connection dropped (proxy idle timeout at 60s) after chunk 3 of 7 of artifact `art-2`, with no `lastChunk`. The client reconnected via `tasks/resubscribe`. The server, correctly, did not replay chunks 1–3 (it resumed from its current position). The client, having flushed on the *first* stream's close, had already emitted a truncated artifact and then appended the remaining chunks to a fresh buffer — producing a downstream record that was both truncated and duplicated at the seam. 2. **`input-required` mid-stream.** The worker emitted two chunks of a report artifact, then the task transitioned to `input-required` and the stream closed with `final: false`. The client's flush-on-close logic treated the partial report as a completed deliverable. No error surfaced; the consumer received a report ending mid-sentence. The tell in both cases: `TaskArtifactUpdateEvent.lastChunk` was absent or `false` for the affected `artifactId` at the moment the stream closed. The transport was healthy; the artifact was not done. ## Condition of Validity Flush-on-stream-close is only valid when, for every `artifactId` observed in the stream, a `TaskArtifactUpdateEvent` with `lastChunk: true` was received **and** the terminal `TaskStatusUpdateEvent` carried `final: true`. Equivalently: artifact completeness is a per-artifact predicate (`lastChunk` seen), and task completeness is a per-task predicate (`final: true`), and neither implies the other. A client may only treat an artifact as deliverable when its own per-artifact flag is set, regardless of stream state. When the stream closes with any artifact lacking `lastChunk: true`, the correct state is *incomplete and resumable*, not *complete*. This condition does not hold when the server uses a single-artifact, single-chunk convention and omits `lastChunk` entirely (some early v1.0 implementations did); against such peers, absence of `lastChunk` is ambiguous and must be resolved by capability negotiation or by treating `final: true` plus a single chunk as implicit finalization — a documented fallback, not a default. ## Decision Boundary The boundary is drawn at the moment of stream termination. On close, evaluate per artifact: - `lastChunk: true` seen → **finalize** that artifact; it is safe to emit downstream. - `lastChunk` absent/false AND terminal status `final: true` → **finalize with warning** (server asserted task completion; treat artifact as closed but log the missing marker). - `lastChunk` absent/false AND terminal status not `final` (including clean close with no terminal event, `input-required`, `auth-required`, or `canceled`) → **do not finalize**; retain the partial buffer, mark the artifact `resumable`, and re-subscribe or surface `input-required` to the caller. The non-obvious part: a clean TCP close is not a completion boundary, and `final: true` on the *task* does not retroactively finalize an artifact that never received `lastChunk`. Treat the two flags as a conjunction gate, never as alternatives. ## Operational Effect Clients that flush on stream close produce silent truncation under reconnection and under `input-required` yields — the two most common real-world interruptions — and the corruption is invisible because the transport reported success. Enforcing the per-artifact `lastChunk` gate converts these silent truncations into explicit `resumable` states, at the cost of retaining partial buffers across reconnects and requiring idempotent downstream emission keyed by `(taskId, artifactId, chunkIndex)`. The trade is buffer retention and dedup bookkeeping against the elimination of a class of data-loss bugs that no transport-level health check will catch. ```python from dataclasses import dataclass, field from typing import Dict, List, Optional class ArtifactIncompleteError(RuntimeError): """Raised when an artifact is flushed without a lastChunk marker.""" pass @dataclass class ArtifactBuffer: artifact_id: str chunks: List[str] = field(default_factory=list) last_chunk_seen: bool = False class ArtifactAssembler: def __init__(self) -> None: self._buffers: Dict[str, ArtifactBuffer] = {} def on_chunk(self, artifact_id: str, text: str, last_chunk: bool) -> None: buf = self._buffers.setdefault(artifact_id, ArtifactBuffer(artifact_id)) buf.chunks.append(text) if last_chunk: buf.last_chunk_seen = True def finalize_on_stream_close(self, task_final: bool) -> List[str]: """Return only artifacts safe to emit downstream; reject the rest.""" ready: List[str] = [] for buf in self._buffers.values(): if buf.last_chunk_seen: ready.append("".join(buf.chunks)) elif task_final: # Server asserted task completion without lastChunk; accept with warning. ready.append("".join(buf.chunks)) else: raise ArtifactIncompleteError( f"artifact {buf.artifact_id} closed without lastChunk " f"and task not final; buffer is resumable, not deliverable" ) return ready ```