Session's accumulated UX work across frontend and platform. Reviewable in four logical sections — diff is large but internally cohesive (each section fixes a gap the next one depends on). ## Chat attachments — user ↔ agent file round trip - New POST /workspaces/:id/chat/uploads (multipart, 50 MB total / 25 MB per file, UUID-prefixed storage under /workspace/.molecule/chat-uploads/). - New GET /workspaces/:id/chat/download with RFC 6266 filename escaping and binary-safe io.CopyN streaming. - Canvas: drag-and-drop onto chat pane, pending-file pills, per-message attachment chips with fetch+blob download (anchor navigation can't carry auth headers). - A2A flow carries FileParts end-to-end; hermes template executor now consumes attachments via platform helpers. ## Platform attachment helpers (workspace/executor_helpers.py) Every runtime's executor routes through the same helpers so future runtimes inherit attachment awareness for free: - extract_attached_files — resolve workspace:/file:///bare URIs, reject traversal, skip non-existent. - build_user_content_with_files — manifest for non-image files, multi-modal list (text + image_url) for images. Respects MOLECULE_DISABLE_IMAGE_INLINING for providers whose vision adapter hangs on base64 payloads (MiniMax M2.7). - collect_outbound_files — scans agent reply for /workspace/... paths, stages each into chat-uploads/ (download endpoint whitelist), emits as FileParts in the A2A response. - ensure_workspace_writable — called at molecule-runtime startup so non-root agents can write /workspace without each template having to chmod in its Dockerfile. Hermes template executor + langgraph (a2a_executor.py) + claude-code (claude_sdk_executor.py) all adopt the helpers. ## Model selection & related platform fixes - PUT /workspaces/:id/model — was 404'ing, so canvas "Save" silently lost the model choice. Stores into workspace_secrets (MODEL_PROVIDER), auto-restarts via RestartByID. - applyRuntimeModelEnv falls back to envVars["MODEL_PROVIDER"] so Restart propagates the stored model to HERMES_DEFAULT_MODEL without needing the caller to rehydrate payload.Model. - ConfigTab Tier dropdown now reads from workspaces row, not the (stale) config.yaml — fixes "badge shows T3, form shows T2". ## ChatTab & WebSocket UX fixes - Send button no longer locks after a dropped TASK_COMPLETE — `sending` no longer initializes from data.currentTask. - A2A POST timeout 15 s → 120 s. LLM turns routinely exceed 15 s; the previous default aborted fetches while the server was still replying, producing "agent may be unreachable" on success. - socket.ts: disposed flag + reconnectTimer cancellation + handler detachment fix zombie-WebSocket in React StrictMode. - Hermes Config tab: RUNTIMES_WITH_OWN_CONFIG drops 'hermes' — the adaptor's purpose IS the form, banner was contradictory. - workspace_provision.go auto-recovery: try <runtime>-default AND bare <runtime> for template path (hermes lives at the bare name). ## Org deploy/delete animation (theme-ready CSS) - styles/theme-tokens.css — design tokens (durations, easings, colors). Light theme overrides by setting only the deltas. - styles/org-deploy.css — animation classes + keyframes, every value references a token. prefers-reduced-motion respected. - Canvas projects node.draggable=false onto locked workspaces (deploying children AND actively-deleting ids) — RF's authoritative drag lock; useDragHandlers retains a belt-and- braces check. - Organ cancel button (red pulse pill on root during deploy) cascades via existing DELETE /workspaces/:id?confirm=true. - Auto fit-view after each arrival, debounced 500 ms so rapid sibling arrivals coalesce into one fit (previous per-event fit made the viewport lurch continuously). - Auto-fit respects user-pan — onMoveEnd stamps a user-pan timestamp only when event !== null (ignores programmatic fitView) so auto-fits don't self-cancel. - deletingIds store slice + useOrgDeployState merge gives the delete flow the same dim + non-draggable treatment as deploy. - Platform-level classNames.ts shared by canvas-events + useCanvasViewport (DRY'd 3 copies of split/filter/join). ## Server payload change - org_import.go WORKSPACE_PROVISIONING broadcast now includes parent_id + parent-RELATIVE x/y (slotX/slotY) so the canvas renders the child at the right parent-nested slot without doing any absolute-position walk. createWorkspaceTree signature gains relX, relY alongside absX, absY; both call sites updated. ## Tests - workspace/tests/test_executor_helpers.py — 11 new cases covering URI resolution (including traversal rejection), attached-file extraction (both Part shapes), manifest-only vs multi-modal content, large-image skip, outbound staging, dedup, and ensure_workspace_writable (chmod 777 + non-root tolerance). - workspace-server chat_files_test.go — upload validation, Content-Disposition escaping, filename sanitisation. - workspace-server secrets_test.go — SetModel upsert, empty clears, invalid UUID rejection. - tests/e2e/test_chat_attachments_e2e.sh — round-trip against a live hermes workspace. - tests/e2e/test_chat_attachments_multiruntime_e2e.sh — static plumbing check + round-trip across hermes/langgraph/claude-code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
94 lines
4.5 KiB
Bash
Executable File
94 lines
4.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# E2E test: chat file attachment round-trip
|
|
#
|
|
# Proves the full drag-drop → agent-reads → agent-returns-file → download
|
|
# path against a live workspace. Runs against the local workspace-server
|
|
# on :8080 with a hermes workspace already online. The test is provider-
|
|
# agnostic as long as the agent has a valid API key — it only asserts
|
|
# that attachments surface on both ends, not a specific reply shape.
|
|
#
|
|
# Usage: WSID=<workspace-id> tests/e2e/test_chat_attachments_e2e.sh
|
|
# (pass WSID for an existing hermes workspace)
|
|
#
|
|
# Prereqs:
|
|
# - workspace-server on http://localhost:8080
|
|
# - the WSID workspace is online, runtime=hermes
|
|
# - a working provider key (MINIMAX_API_KEY / ANTHROPIC_API_KEY / etc.)
|
|
# - /workspace writable by the agent user (some templates ship it
|
|
# root-owned; chmod 777 for the E2E or use a writable template)
|
|
|
|
set -euo pipefail
|
|
|
|
WSID="${WSID:?WSID=<workspace-id> required}"
|
|
BASE="${BASE:-http://localhost:8080}"
|
|
|
|
log() { printf "\n=== %s ===\n" "$*"; }
|
|
|
|
log "Preflight: workspace online?"
|
|
STATUS=$(curl -s "$BASE/workspaces/$WSID" | python3 -c 'import json,sys;print(json.load(sys.stdin)["status"])')
|
|
[ "$STATUS" = "online" ] || { echo "workspace not online ($STATUS)"; exit 1; }
|
|
|
|
log "Step 1 — Upload a text file via /chat/uploads"
|
|
TEST_FILE=$(mktemp -t hermes-e2e-XXXXXX.txt)
|
|
echo "secret code: $(openssl rand -hex 4)-$(openssl rand -hex 4)" > "$TEST_FILE"
|
|
EXPECTED=$(cat "$TEST_FILE" | awk '{print $NF}')
|
|
UPLOAD=$(curl -s -X POST "$BASE/workspaces/$WSID/chat/uploads" -F "files=@$TEST_FILE")
|
|
URI=$(echo "$UPLOAD" | python3 -c 'import json,sys;print(json.load(sys.stdin)["files"][0]["uri"])')
|
|
[ -n "$URI" ] || { echo "upload failed: $UPLOAD"; exit 1; }
|
|
echo "uploaded: $URI"
|
|
|
|
log "Step 2 — A2A message with file part; expect agent to quote the code"
|
|
# Build the JSON via a python helper so the URI value doesn't have to be
|
|
# shell-interpolated through a heredoc (the { } tokens in a JSON body
|
|
# collide with bash brace-expansion when quoted wrong).
|
|
PAYLOAD=$(URI="$URI" python3 -c '
|
|
import json, os
|
|
uri = os.environ["URI"]
|
|
print(json.dumps({
|
|
"jsonrpc":"2.0","id":"e2e-up","method":"message/send",
|
|
"params":{"message":{"role":"user","messageId":"e2e-up","kind":"message","parts":[
|
|
{"kind":"text","text":"Read the attached file and tell me the exact secret code."},
|
|
{"kind":"file","file":{"name":"test.txt","mimeType":"text/plain","uri":uri}},
|
|
]},"configuration":{"acceptedOutputModes":["text/plain"],"blocking":True}}}))
|
|
')
|
|
REPLY=$(curl -s -X POST "$BASE/workspaces/$WSID/a2a" \
|
|
-H 'Content-Type: application/json' \
|
|
--max-time 120 \
|
|
-d "$PAYLOAD")
|
|
REPLY_TEXT=$(echo "$REPLY" | python3 -c 'import json,sys;d=json.load(sys.stdin);[print(p.get("text","")) for p in d["result"]["parts"] if p.get("kind")=="text"]')
|
|
echo "agent reply: $REPLY_TEXT"
|
|
if echo "$REPLY_TEXT" | grep -qF "$EXPECTED"; then
|
|
echo "PASS: agent saw the attached file"
|
|
else
|
|
echo "FAIL: agent reply missing expected code '$EXPECTED'"
|
|
exit 1
|
|
fi
|
|
|
|
log "Step 3 — Seed a file inside /workspace and ask agent to reference it"
|
|
# Relies on /workspace being writable by the platform (we copy as root via
|
|
# docker exec, mimicking the path a real agent would use through its tools).
|
|
CONTAINER=$(docker ps --format '{{.Names}}' | grep -E "^ws-${WSID:0:12}" | head -1)
|
|
[ -n "$CONTAINER" ] || { echo "container not found"; exit 1; }
|
|
docker exec "$CONTAINER" sh -c 'echo "E2E report body $(date -u +%s)" > /workspace/e2e-report.txt'
|
|
|
|
REPLY=$(curl -s -X POST "$BASE/workspaces/$WSID/a2a" \
|
|
-H 'Content-Type: application/json' \
|
|
--max-time 120 \
|
|
-d '{"jsonrpc":"2.0","id":"e2e-down","method":"message/send","params":{"message":{"role":"user","messageId":"e2e-down","kind":"message","parts":[{"kind":"text","text":"There is a file at /workspace/e2e-report.txt. Mention its exact path in your reply so I can download it."}]},"configuration":{"acceptedOutputModes":["text/plain"],"blocking":true}}}')
|
|
FILE_URI=$(echo "$REPLY" | python3 -c 'import json,sys,re;d=json.load(sys.stdin);[print(p["file"]["uri"]) for p in d["result"]["parts"] if p.get("kind")=="file"]' | head -1)
|
|
[ -n "$FILE_URI" ] || { echo "FAIL: agent reply had no file part"; echo "$REPLY"; exit 1; }
|
|
echo "agent attached: $FILE_URI"
|
|
|
|
log "Step 4 — Download via /chat/download"
|
|
DL_PATH=${FILE_URI#workspace:}
|
|
BODY=$(curl -s "$BASE/workspaces/$WSID/chat/download?path=$DL_PATH")
|
|
echo "downloaded: $BODY"
|
|
if echo "$BODY" | grep -q "E2E report body"; then
|
|
echo "PASS: downloaded the agent-returned file"
|
|
else
|
|
echo "FAIL: download did not return expected body"
|
|
exit 1
|
|
fi
|
|
|
|
log "ALL E2E CHECKS PASSED"
|