diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx index 54f518b3..2fc0aedb 100644 --- a/canvas/src/components/tabs/ChatTab.tsx +++ b/canvas/src/components/tabs/ChatTab.tsx @@ -32,15 +32,11 @@ interface A2AFileRef { bytes?: string; size?: number; } -// A2A Part — outbound matches the v0 Pydantic discriminated-union -// shape that a2a-sdk's JSON-RPC layer validates against (TextPart | -// FilePart | DataPart). The v1 flat-protobuf shape `{url, filename, -// mediaType}` is internal SDK serialization only; sending it on the -// wire fails Pydantic validation with `TextPart.text required, -// FilePart.file required, DataPart.data required` and never reaches -// the executor. Inbound also tolerates the v1 shape via -// message-parser.ts since the agent itself may serialize as v1 in -// some downstream tools. +// Outbound shape matches a2a-sdk's JSON-RPC `SendMessageRequest` +// Pydantic union (TextPart | FilePart | DataPart). The flat +// protobuf shape `{url, filename, mediaType}` is rejected at the +// request boundary with `Field required` errors — keep this +// outbound shape unless a2a-sdk migrates the JSON-RPC schema. interface A2APart { kind: string; text?: string; @@ -511,18 +507,7 @@ function MyChatPanel({ workspaceId, data }: Props) { // A2A parts: text part (if any) + file parts (per attachment). The // agent sees both in a single turn, matching the A2A spec shape. - // - // File parts use the v0 discriminated-union shape `{kind:"file", - // file:{...}}` because that's what a2a-sdk's JSON-RPC layer - // validates against (`SendMessageRequest.params.message.parts[]` - // → `TextPart | FilePart | DataPart` Pydantic union). Sending the - // v1 flat shape `{url, filename, mediaType}` returns - // `Invalid Request — TextPart.text required, FilePart.file - // required, DataPart.data required` and the message never - // reaches the executor. v1 protobuf is internal serialization - // only; the wire shape stays v0 until the SDK migrates the - // JSON-RPC schema. Text parts keep `{kind:"text", text}` for the - // same reason. + // Wire shape is v0 — see A2APart definition above. const parts: A2APart[] = []; if (text) parts.push({ kind: "text", text }); for (const att of uploaded) { diff --git a/canvas/src/components/tabs/chat/message-parser.ts b/canvas/src/components/tabs/chat/message-parser.ts index 5c8cc6b6..d21842d0 100644 --- a/canvas/src/components/tabs/chat/message-parser.ts +++ b/canvas/src/components/tabs/chat/message-parser.ts @@ -41,15 +41,15 @@ export interface ParsedFilePart { /** Extract file parts from an A2A response. Walks parts[] + artifacts[]. * - * Tolerates both A2A protocol generations: - * - v0 (Pydantic): `{ kind: "file", file: { name, mimeType, uri } }` - * - v1 (protobuf): `{ url, filename, mediaType }` — flat, no `kind` - * and no nested `file` object (the v1 Part's content oneof is - * `{text, raw, url, data}`; file metadata sits at top level). + * Hot path: v0 Pydantic shape `{ kind: "file", file: { name, mimeType, + * uri } }` — what every current workspace runtime emits. * - * Without v1 tolerance, agents that emit the v1 shape (every workspace - * runtime since the SDK migration) silently drop file parts in chat — - * the agent says "I sent the file" but the user never sees the chip. + * Defensive secondary path: v1 protobuf shape `{ url, filename, + * mediaType }` — flat, no `kind`, no nested `file`. Not currently + * observed on the wire (a2a-sdk's JSON-RPC layer still validates + * against v0), but kept so a future SDK release that flips the wire + * shape, or a third-party agent that round-trips through protobuf + * serialization, doesn't silently lose file chips. * * We only surface parts that carry a URL — inline bytes would require * a different renderer (data URL) and are out of scope for MVP. Names diff --git a/workspace/executor_helpers.py b/workspace/executor_helpers.py index d3f9d00a..95ac65fc 100644 --- a/workspace/executor_helpers.py +++ b/workspace/executor_helpers.py @@ -844,23 +844,27 @@ def resolve_attachment_uri(uri: str) -> str | None: def extract_attached_files(message: Any) -> list[dict[str, str]]: """Pull ``{name, mime_type, path}`` dicts out of an A2A message. - Tolerates three Part shapes seen in the wild: + Tolerates three Part shapes: 1. a2a-sdk v0 Pydantic RootModel — ``part.root.kind == 'file'`` with - ``part.root.file.{uri,name,mimeType}``. - 2. a2a-sdk v0 flatter shape — ``part.kind == 'file'`` with - ``part.file.{uri,name,mimeType}`` (some hand-built callers). - 3. a2a-sdk v1 protobuf — ``part.url`` non-empty with - ``part.filename`` + ``part.media_type``. The v1 ``Part`` proto - has no ``kind`` field at all (the discriminator is now a oneof - ``content`` of {text, raw, url, data}). Without this branch a v1 - file part — which is what a v1 server constructs from any caller - that JSON-encodes the v1 shape — silently parses to an empty - Part on the v0→v1 transition because protobuf json_format with - ``ignore_unknown_fields=True`` drops the legacy ``kind`` and - ``file`` keys, surfacing as the user-visible - "Error: message contained no text content" on image-only chats - (2026-05-01 hongming incident). + ``part.root.file.{uri,name,mimeType}``. The hot path; this is + what every current caller produces (canvas chat, A2A peer + delegations, agent self-attached files). + 2. v0 flatter shape — ``part.kind == 'file'`` with + ``part.file.{uri,name,mimeType}``. Some hand-built callers + (older test fixtures, third-party clients) emit this. + 3. v1 protobuf — ``part.url`` non-empty with ``part.filename`` + + ``part.media_type``. **Defensive future-proofing only.** The + v1 ``Part`` proto exists in a2a-sdk's ``a2a.types.a2a_pb2`` but + a2a-sdk's JSON-RPC layer still validates inbound requests + against the v0 Pydantic discriminated union (TextPart | + FilePart | DataPart), so a v1 wire shape is rejected at the + request boundary today — this branch is unreachable on the + JSON-RPC ingress path. Kept so a future SDK release that + flips the JSON-RPC schema doesn't silently regress this + helper, and so non-conformant in-process callers (e.g. a + template that constructs a Part directly from protobuf) get + handled correctly. Non-file parts and files with unresolvable URIs are skipped — the caller sees an empty list rather than a mix of valid and broken @@ -884,10 +888,11 @@ def extract_attached_files(message: Any) -> list[dict[str, str]]: name = getattr(f, "name", "") or "" mime = getattr(f, "mimeType", None) or getattr(f, "mime_type", None) or "" else: - # v1 protobuf Part has no `kind`; detect by a non-empty - # `url` (the file/url-of-bytes oneof slot). Fall back to - # `media_type` then `mimeType` for the camelCase Pydantic - # variant some adapters still hand us. + # Defensive v1 path (see docstring): v1 Part has no `kind`, + # detect by a non-empty `url` (the file/url-of-bytes oneof + # slot). Fall back from snake_case `media_type` to + # camelCase `mediaType` for callers that hand us the + # Pydantic-style attribute name. v1_url = getattr(part, "url", "") or "" if not v1_url: continue