#!/usr/bin/env bash # E2E test: agent → user notify with attachments (PR #2130). # # Exercises the wire contract that workspace/a2a_tools.tool_send_message_to_user # uses to push files into the canvas chat. Pure platform test — no workspace # container or LLM key required, so it runs against a bare workspace-server # in <2s and pins the contract that real runtimes depend on. # # What this proves: # 1. POST /notify (no attachments) persists a chat row that survives reload. # 2. POST /notify with attachments persists parts[].kind=file in the SAME # shape extractFilesFromTask reads → chips re-render after reload. # 3. Per-element attachment validation rejects empty uri/name (regression # for the bug where gin's binding:"required" on slice elements was a # no-op without `dive` — see activity.go:299-306). # 4. Empty `attachments: []` does NOT inject a stray `parts` key (would # otherwise render a "0 files" header in the canvas). # 5. Real /chat/uploads → /notify chain round-trips the URI verbatim. # # Usage: tests/e2e/test_notify_attachments_e2e.sh # Prereqs: workspace-server on http://localhost:8080, MOLECULE_ENV != production set -euo pipefail source "$(dirname "$0")/_lib.sh" PASS=0 FAIL=0 WSID="" cleanup() { # Workspace teardown — best-effort, ignore errors so an unrelated CP # outage doesn't shadow a real test failure. if [ -n "$WSID" ]; then curl -s -X DELETE "$BASE/workspaces/$WSID?confirm=true" > /dev/null || true fi # /tmp scratch — pre-fix only ran on success path (the unconditional # rm at the bottom of the script). Trap-based path lets the file leak # whenever the script exits non-zero before reaching the rm. RFC #2873 # cleanup-hygiene PR. if [ -n "${TMPF:-}" ]; then rm -f "$TMPF" fi } trap cleanup EXIT INT TERM assert() { local label="$1" local actual="$2" local expected="$3" if [ "$actual" = "$expected" ]; then echo " PASS — $label" PASS=$((PASS+1)) else echo " FAIL — $label" echo " expected: $expected" echo " actual: $actual" FAIL=$((FAIL+1)) fi } assert_contains() { local label="$1" local haystack="$2" local needle="$3" if echo "$haystack" | grep -qF "$needle"; then echo " PASS — $label" PASS=$((PASS+1)) else echo " FAIL — $label" echo " haystack: $haystack" echo " needle: $needle" FAIL=$((FAIL+1)) fi } echo "=== Setup ===" # Idempotent pre-sweep: a prior run that got SIGPIPE'd or kill -9'd before # the EXIT trap fired would leave a "Notify E2E" workspace sitting on the # canvas. Find and delete any with this exact name so the test is safe to # re-run from any state. Match by name (not tag) so this also catches # leftovers created by older script versions. PRIOR=$(curl -s "$BASE/workspaces" | python3 -c ' import json, sys try: print(" ".join(w["id"] for w in json.load(sys.stdin) if w.get("name") == "Notify E2E")) except Exception: pass ') for _wid in $PRIOR; do echo "Sweeping leftover Notify E2E workspace: $_wid" curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true done R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ -d '{"name":"Notify E2E","tier":1}') WSID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true) [ -n "$WSID" ] || { echo "Failed to create workspace: $R"; exit 1; } echo "Created workspace $WSID" # Mint a bearer token so the wsAuth-grouped endpoints (notify, activity, # chat/uploads) accept us. Local dev mode skips auth, but CI enforces it # — so we always send the header to keep the test portable. The # admin/test-token endpoint is only enabled when MOLECULE_ENV != production. TOKEN=$(e2e_mint_test_token "$WSID") [ -n "$TOKEN" ] || { echo "Failed to mint test token"; exit 1; } AUTH="Authorization: Bearer $TOKEN" echo "" echo "=== Test 1: notify without attachments persists row ===" CODE=$(curl -s -o /tmp/notify1.json -w "%{http_code}" -X POST "$BASE/workspaces/$WSID/notify" \ -H "Content-Type: application/json" -H "$AUTH" \ -d '{"message":"Working on it"}') assert "POST /notify (text only) returns 200" "$CODE" "200" # Read it back via /activity. The notify handler writes a2a_receive, # method=notify, response_body.result=. # Notify writes source_id=NULL → use source=canvas (matches the chat # panel's history loader filter). source=agent would correctly hide it. ACT=$(curl -s -H "$AUTH" "$BASE/workspaces/$WSID/activity?source=canvas&limit=10") ROW=$(echo "$ACT" | python3 -c ' import json, sys rows = json.load(sys.stdin) or [] for r in rows: if r.get("method") == "notify": print(json.dumps(r)) break ') [ -n "$ROW" ] || { echo " FAIL — could not find notify row in activity"; FAIL=$((FAIL+1)); } if [ -n "$ROW" ]; then RESULT=$(echo "$ROW" | python3 -c 'import json,sys;print(json.load(sys.stdin)["response_body"]["result"])') assert "persisted response_body.result matches message" "$RESULT" "Working on it" PARTS=$(echo "$ROW" | python3 -c 'import json,sys;b=json.load(sys.stdin)["response_body"];print("parts" in b)') assert "no stray parts[] when message has no attachments" "$PARTS" "False" fi echo "" echo "=== Test 2: notify with attachments persists parts[].kind=file ===" CODE=$(curl -s -o /tmp/notify2.json -w "%{http_code}" -X POST "$BASE/workspaces/$WSID/notify" \ -H "Content-Type: application/json" -H "$AUTH" \ -d '{ "message": "Done — see attached.", "attachments": [ {"uri":"workspace:/tmp/build-output.zip","name":"build-output.zip","mimeType":"application/zip","size":12345} ] }') assert "POST /notify (with attachment) returns 200" "$CODE" "200" ACT=$(curl -s -H "$AUTH" "$BASE/workspaces/$WSID/activity?source=canvas&limit=10") ROW=$(echo "$ACT" | python3 -c ' import json, sys rows = json.load(sys.stdin) or [] # Most recent matching notify row for r in rows: rb = r.get("response_body") or {} if r.get("method") == "notify" and "parts" in rb: print(json.dumps(r)) break ') [ -n "$ROW" ] || { echo " FAIL — could not find notify-with-attachments row"; FAIL=$((FAIL+1)); } if [ -n "$ROW" ]; then KIND=$(echo "$ROW" | python3 -c 'import json,sys;print(json.load(sys.stdin)["response_body"]["parts"][0]["kind"])') URI=$(echo "$ROW" | python3 -c 'import json,sys;print(json.load(sys.stdin)["response_body"]["parts"][0]["file"]["uri"])') NAME=$(echo "$ROW" | python3 -c 'import json,sys;print(json.load(sys.stdin)["response_body"]["parts"][0]["file"]["name"])') MIME=$(echo "$ROW" | python3 -c 'import json,sys;print(json.load(sys.stdin)["response_body"]["parts"][0]["file"]["mimeType"])') SIZE=$(echo "$ROW" | python3 -c 'import json,sys;print(json.load(sys.stdin)["response_body"]["parts"][0]["file"]["size"])') assert "parts[0].kind == file" "$KIND" "file" assert "parts[0].file.uri preserved" "$URI" "workspace:/tmp/build-output.zip" assert "parts[0].file.name preserved" "$NAME" "build-output.zip" assert "parts[0].file.mimeType preserved" "$MIME" "application/zip" assert "parts[0].file.size preserved" "$SIZE" "12345" fi echo "" echo "=== Test 3: per-element validation rejects empty uri/name ===" # Critical regression: gin's binding:"required" on slice elements is a no-op # without `dive`. activity.go:299 explicitly loops and rejects. Keep in lock- # step here so a future refactor that drops the loop fails this test. CODE=$(curl -s -o /tmp/notify3.json -w "%{http_code}" -X POST "$BASE/workspaces/$WSID/notify" \ -H "Content-Type: application/json" -H "$AUTH" \ -d '{"message":"x","attachments":[{"uri":"","name":""}]}') assert "empty uri/name attachment is 400" "$CODE" "400" ERR=$(cat /tmp/notify3.json | python3 -c 'import json,sys;print(json.load(sys.stdin).get("error",""))') assert_contains "error mentions attachment[0]" "$ERR" "attachment[0]" CODE=$(curl -s -o /tmp/notify3b.json -w "%{http_code}" -X POST "$BASE/workspaces/$WSID/notify" \ -H "Content-Type: application/json" -H "$AUTH" \ -d '{"message":"x","attachments":[{"uri":"workspace:/ok.txt","name":"ok.txt"},{"uri":"","name":"bad"}]}') assert "second-element empty uri rejects whole call" "$CODE" "400" ERR=$(cat /tmp/notify3b.json | python3 -c 'import json,sys;print(json.load(sys.stdin).get("error",""))') assert_contains "error mentions attachment[1]" "$ERR" "attachment[1]" echo "" echo "=== Test 4: missing message field rejected ===" CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$WSID/notify" \ -H "Content-Type: application/json" -H "$AUTH" -d '{}') assert "POST /notify with no message returns 400" "$CODE" "400" echo "" echo "=== Test 5: real /chat/uploads → /notify round-trip ===" TMPF=$(mktemp -t notify-e2e-XXXX.txt) echo "round-trip-marker-$(date +%s)" > "$TMPF" UP=$(curl -s -X POST "$BASE/workspaces/$WSID/chat/uploads" -H "$AUTH" -F "files=@$TMPF") URI=$(echo "$UP" | python3 -c ' import json,sys try: print(json.load(sys.stdin)["files"][0]["uri"]) except Exception: pass ') NAME=$(basename "$TMPF") if [ -z "$URI" ]; then # /chat/uploads requires a running container in some configs. Skip the # round-trip if the platform refused — the synthetic-URI tests above # already pin the wire contract. echo " SKIP — /chat/uploads not available in this env (response: $UP)" else CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$WSID/notify" \ -H "Content-Type: application/json" -H "$AUTH" \ -d "{\"message\":\"see file\",\"attachments\":[{\"uri\":\"$URI\",\"name\":\"$NAME\"}]}") assert "uploaded URI round-trips through notify" "$CODE" "200" ACT=$(curl -s -H "$AUTH" "$BASE/workspaces/$WSID/activity?source=canvas&limit=10") STORED_URI=$(echo "$ACT" | python3 -c " import json, sys rows = json.load(sys.stdin) or [] for r in rows: rb = r.get('response_body') or {} parts = rb.get('parts') or [] for p in parts: f = p.get('file') or {} if f.get('name') == '$NAME': print(f.get('uri','')) sys.exit(0) ") assert "stored URI matches uploaded URI" "$STORED_URI" "$URI" fi # $TMPF cleanup happens via the trap-cleanup function above — covers # both the success path and any early exit / SIGINT. echo "" echo "=== Results: $PASS passed, $FAIL failed ===" [ "$FAIL" -eq 0 ]