diff --git a/frontend/package.json b/frontend/package.json
index 8941df0..5f7415f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "vite build",
- "preview": "vite preview --host 0.0.0.0"
+ "preview": "vite preview --host 0.0.0.0",
+ "test": "node --test"
},
"dependencies": {
"react": "^18.3.1",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index e4c27b0..750b502 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -10,6 +10,18 @@ const DEFAULT_FORM = {
variant_count: 2,
};
+function downloadSvgAsset(asset) {
+ const blob = new Blob([asset.svg], { type: asset.media_type || "image/svg+xml;charset=utf-8" });
+ const objectUrl = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = objectUrl;
+ link.download = asset.name || "game-asset.svg";
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
+}
+
export function App() {
const [config, setConfig] = useState(null);
const [session, setSession] = useState(null);
@@ -201,7 +213,10 @@ export function App() {
{asset.name}
-
+
+
+
+
))}
diff --git a/frontend/src/a2a.js b/frontend/src/a2a.js
index 0a8d87e..9aed978 100644
--- a/frontend/src/a2a.js
+++ b/frontend/src/a2a.js
@@ -1,4 +1,11 @@
-export const CONFIG_URL = import.meta.env.DEV ? "/config.json" : "./config.json";
+export const CONFIG_URL = import.meta.env?.DEV ? "/config.json" : "./config.json";
+
+export function unwrapSkillResponse(payload) {
+ if (payload && typeof payload === "object" && "result" in payload) {
+ return payload.result;
+ }
+ return payload;
+}
export async function requestJson(url, init = {}) {
const response = await fetch(url, { credentials: "same-origin", ...init });
@@ -43,11 +50,12 @@ export async function callSkill(config, skillName, args) {
if (config.auth?.invokeRequiresSession) {
await requireSession(config);
}
- const data = await requestJson(`${config.endpoints.invoke}/${encodeURIComponent(skillName)}`, {
+ const payload = await requestJson(`${config.endpoints.invoke}/${encodeURIComponent(skillName)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ arguments: args }),
});
+ const data = unwrapSkillResponse(payload);
if (data?.status === "setup_required") {
throw new Error(data.warning || "Backend setup is required before this action can run.");
}
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 993f26f..9ff59b5 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -46,5 +46,7 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monos
.svg-preview svg { max-width: 100%; height: auto; image-rendering: pixelated; }
.asset-meta { padding: 14px; align-items: start; }
.asset-meta strong { overflow-wrap: anywhere; }
+.asset-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
+.asset-meta button.secondary { border: 1px solid rgba(148, 163, 184, .35); background: rgba(15, 23, 42, .88); color: #dcecff; }
@media (max-width: 900px) { .hero, .workspace { grid-template-columns: 1fr; } h1 { font-size: clamp(36px, 13vw, 64px); } }
-@media (max-width: 560px) { .form-grid { grid-template-columns: 1fr; } .app-shell { width: min(100% - 20px, 1180px); padding-top: 16px; } .receipt-header, .result-header, .asset-meta { align-items: flex-start; flex-direction: column; } }
+@media (max-width: 560px) { .form-grid { grid-template-columns: 1fr; } .app-shell { width: min(100% - 20px, 1180px); padding-top: 16px; } .receipt-header, .result-header, .asset-meta { align-items: flex-start; flex-direction: column; } .asset-actions { justify-content: flex-start; } }
diff --git a/frontend/tests/a2a.test.mjs b/frontend/tests/a2a.test.mjs
new file mode 100644
index 0000000..91bc293
--- /dev/null
+++ b/frontend/tests/a2a.test.mjs
@@ -0,0 +1,21 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { unwrapSkillResponse } from "../src/a2a.js";
+
+test("unwraps the production invoke response envelope", () => {
+ const result = {
+ status: "ok",
+ svg_assets: [{ name: "sprite.svg", svg: "" }],
+ };
+
+ assert.deepEqual(
+ unwrapSkillResponse({ result, events: [], artifacts: [], grant_id: null }),
+ result,
+ );
+});
+
+test("keeps direct skill responses compatible", () => {
+ const result = { status: "ok", receipts: [] };
+ assert.equal(unwrapSkillResponse(result), result);
+});
diff --git a/tests/test_full_stack_contract.py b/tests/test_full_stack_contract.py
index 27eb4a0..ef12f4e 100644
--- a/tests/test_full_stack_contract.py
+++ b/tests/test_full_stack_contract.py
@@ -17,6 +17,7 @@ def test_full_stack_product_contract():
manifest = (ROOT / "a2a.yaml").read_text(encoding="utf-8")
source = (ROOT / "agent.py").read_text(encoding="utf-8")
frontend = (ROOT / "frontend" / "src" / "App.jsx").read_text(encoding="utf-8")
+ frontend_client = (ROOT / "frontend" / "src" / "a2a.js").read_text(encoding="utf-8")
assert "frontend:" in manifest and "mount: /app" in manifest
assert "public: false" in manifest
@@ -26,6 +27,8 @@ def test_full_stack_product_contract():
assert "AgentPlatformResources" in source and "AgentDatabase(" in source
assert "Skill runner" not in frontend
assert "generate_svg_asset" in frontend
+ assert "Download SVG" in frontend
+ assert "unwrapSkillResponse(payload)" in frontend_client
assert "DATABASE_URL" not in frontend
migrations = list((ROOT / "db" / "migrations").glob("*.sql"))