feat(orchestrator): S3 generated SVG faces whose mouths move while speaking

Requirement 001 phase 6, S3:

- faces.js derives a face from the agent's stable key (its session id,
  or name:<name> without a session), so a rename keeps the face: shape
  4, colour 7, eyes 5, brows 4, mouth 5, hair or headwear 6, accessory 5,
  84,000 combinations. It emits constant SVG tags and attributes only.
- The emoji avatar is gone: popup, Orbit and Chord nodes, the radio
  view, stream and palette draw the SVG face (the 3D views keep their
  marker).
- While the radio reads an agent's message, its faces carry the class
  speaking and the mouth runs the talk keyframes; a silent face does
  not move, and prefers-reduced-motion stops the mouth.

Evidence: make test 673 OK, ruff and markdownlint clean
(/tmp/s3-make-test-1.log); e2e 20 OK in Chromium and Firefox, none
skipped (/tmp/s3-e2e-3.log); screenshots
/tmp/orchestrator-phase4-shots/*-speaking.png; MEDUSA 13 files, 2
findings ("PLA" in "templates", the UI call openAgent) accepted after
an independent skeptic that found no markup sink or selector injection
in the face code.

🎯 Fix: 94 %
💥 Break: 3 %
💸 Cost: 100 %
⏱️ Time: –
🛡️ Security: 100 %
🏆 Score: 91
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-08 22:06:31 +00:00
1 parent d89fb616b8
commit 4d88296e47
13 files changed
+262 -34

No files matched your search

+2 -2
View File
@@ -603,9 +603,9 @@ flowchart TD
### Phase 6: faces (S3) ### Phase 6: faces (S3)
- [ ] The face generator yields at least 1000 distinct attribute - [x] The face generator yields at least 1000 distinct attribute
combinations and the same face for the same agent id (unit test). combinations and the same face for the same agent id (unit test).
- [ ] A speaking agent's face animates its mouth; a silent one does not - [x] A speaking agent's face animates its mouth; a silent one does not
(DOM test on the class while the radio plays). (DOM test on the class while the radio plays).
### Phase 6: night commands (S4) ### Phase 6: night commands (S4)
+95
View File
@@ -0,0 +1,95 @@
const Faces = (() => {
"use strict";
const SIZE = 40;
const INK = "#2b2622";
const dot = (cx, cy, rx, ry, fill) => ["ellipse", {cx, cy, rx, ry, fill}];
const line = (d, width) => ["path", {d, fill: "none", stroke: INK, "stroke-width": width, "stroke-linecap": "round", "stroke-linejoin": "round"}];
const blob = (d, fill) => ["path", {d, fill}];
const sclera = (cx) => ["ellipse", {cx, cy: 20, rx: 3.2, ry: 3, fill: "#fff", stroke: INK, "stroke-width": 0.8}];
const cheek = (cx) => ["ellipse", {cx, cy: 25, rx: 2.4, ry: 1.5, fill: "#f08a8a", opacity: 0.6}];
const SHAPES = [
["ellipse", {cx: 20, cy: 22, rx: 13, ry: 13}],
["ellipse", {cx: 20, cy: 23, rx: 11, ry: 14}],
["rect", {x: 7, y: 9, width: 26, height: 26, rx: 8}],
["ellipse", {cx: 20, cy: 22, rx: 15, ry: 12}],
];
const COLOURS = ["#f6d5b8", "#e3ac85", "#b97a55", "#7d4f33", "#f4cf5d", "#9fd4c7", "#c2b2f2"];
const EYES = [
[dot(15, 20, 1.8, 1.8, INK), dot(25, 20, 1.8, 1.8, INK)],
[dot(15, 20, 1.4, 2.5, INK), dot(25, 20, 1.4, 2.5, INK)],
[line("M12.5 21q2.5-3 5 0M22.5 21q2.5-3 5 0", 1.6)],
[line("M12.5 20.5h5M22.5 20.5h5", 1.6)],
[sclera(15), sclera(25), dot(15.6, 20.4, 1.4, 1.4, INK), dot(25.6, 20.4, 1.4, 1.4, INK)],
];
const BROWS = [
[],
[line("M12 15.5h6M22 15.5h6", 1.4)],
[line("M12 16q3-2.5 6 0M22 16q3-2.5 6 0", 1.4)],
[line("M12 14.5l6 1.5M28 14.5l-6 1.5", 1.4)],
];
const MOUTHS = [
blob("M14 27q6 6 12 0z", INK),
dot(20, 28, 2.2, 2, INK),
blob("M13 26q7 7 14 0q-7 2-14 0z", INK),
["rect", {x: 15, y: 27.2, width: 10, height: 1.8, rx: 0.9, fill: INK}],
blob("M15 28q5 3 10-2q-5 4.5-10 2z", INK),
];
const HAIR = [
[],
[blob("M7 19q0-12 13-12t13 12q-5-6-13-6t-13 6z", "#3b2a20")],
[blob("M9 15l2-8 3 5 3-7 3 6 3-6 3 7 3-5 2 8q-11-4-22 0z", "#d9a441")],
[blob("M8 14q12-14 24 0z", "#3f6fb5"), ["rect", {x: 17, y: 12.5, width: 19, height: 2.6, rx: 1.3, fill: "#2f568f"}]],
[blob("M8 13q12-17 24 0z", "#c0504d"), ["rect", {x: 7, y: 11.5, width: 26, height: 3, rx: 1.5, fill: "#9c3d3a"}], dot(20, 4.6, 2.6, 2.6, "#f1f1f1")],
[dot(20, 6.5, 4.5, 4, "#6b3f2a"), blob("M8 18q1-11 12-11t12 11q-4-5-12-5t-12 5z", "#6b3f2a")],
];
const EXTRAS = [
[],
[["path", {d: "M11 20a4 3.2 0 1 0 8 0a4 3.2 0 1 0-8 0M21 20a4 3.2 0 1 0 8 0a4 3.2 0 1 0-8 0M19 20h2", fill: "none", stroke: INK, "stroke-width": 1.1}]],
[cheek(12.5), cheek(27.5)],
[["rect", {x: 4.5, y: 18, width: 3.5, height: 8, rx: 1.75, fill: INK}], line("M6.5 25.5q1 5 7.5 4.5", 1.3)],
[12.5, 14.5, 12, 27.5, 25.5, 28].map((cx, index) => dot(cx, 24 + (index % 3) * 1.1, 0.75, 0.75, "#8a5a3c")),
];
const ATTRIBUTES = {shape: SHAPES, colour: COLOURS, eyes: EYES, brows: BROWS, mouth: MOUTHS, hair: HAIR, accessory: EXTRAS};
const COUNTS = Object.fromEntries(Object.entries(ATTRIBUTES).map(([name, list]) => [name, list.length]));
const COMBINATIONS = Object.values(COUNTS).reduce((product, count) => product * count, 1);
function hash(text) {
let value = 2166136261;
for (const char of String(text)) {
value = Math.imul(value ^ char.codePointAt(0), 16777619);
}
value = Math.imul(value ^ (value >>> 16), 0x85ebca6b);
value = Math.imul(value ^ (value >>> 13), 0xc2b2ae35);
return (value ^ (value >>> 16)) >>> 0;
}
/**
* @param id - the agent's stable key: its session id, or name:<name> for an agent without a session.
* @returns {pick, parts}: pick holds the variant index of every attribute in ATTRIBUTES order; parts lists the
* [tag, attributes] SVG children of a 0 0 SIZE SIZE view box from back to front, the mouth carrying class "mouth".
*/
function of(id) {
let rest = hash(id);
const pick = {};
for (const [name, count] of Object.entries(COUNTS)) {
pick[name] = rest % count;
rest = Math.floor(rest / count);
}
const [tag, shape] = SHAPES[pick.shape];
const [mouth, lips] = MOUTHS[pick.mouth];
const parts = [
[tag, {...shape, fill: COLOURS[pick.colour], stroke: INK, "stroke-width": 0.8, "stroke-opacity": 0.4}],
...HAIR[pick.hair],
...EYES[pick.eyes],
...BROWS[pick.brows],
[mouth, {...lips, class: "mouth"}],
...EXTRAS[pick.accessory],
];
return {pick, parts};
}
return {SIZE, COUNTS, COMBINATIONS, of};
})();
if (typeof module === "object" && module.exports) {
module.exports = Faces;
}
+5 -5
View File
@@ -242,9 +242,6 @@ svg .node.k-chair circle { fill: var(--accent-soft); stroke: var(--accent); stro
svg .node.k-subagent circle { fill: var(--violet-soft); stroke: var(--violet); } svg .node.k-subagent circle { fill: var(--violet-soft); stroke: var(--violet); }
svg .node.claimed circle { stroke: var(--ok); stroke-width: 2.2; } svg .node.claimed circle { stroke: var(--ok); stroke-width: 2.2; }
svg .label { fill: var(--text); font-size: var(--fs-sm); font-weight: 560; paint-order: stroke; stroke: var(--panel); stroke-width: 4px; stroke-linejoin: round; } svg .label { fill: var(--text); font-size: var(--fs-sm); font-weight: 560; paint-order: stroke; stroke: var(--panel); stroke-width: 4px; stroke-linejoin: round; }
svg .initial { fill: var(--muted); font-size: var(--fs-sm); font-weight: 700; pointer-events: none; }
svg .k-operator .initial { fill: var(--on-accent); font-size: var(--fs-xl); }
svg .k-chair .initial { fill: var(--accent); font-size: var(--fs-lg); }
svg .group-arc { fill: none; stroke: var(--line-strong); stroke-width: 7; stroke-linecap: round; } svg .group-arc { fill: none; stroke: var(--line-strong); stroke-width: 7; stroke-linecap: round; }
svg .group-arc.k-operator, svg .group-arc.k-chair { stroke: var(--accent); } svg .group-arc.k-operator, svg .group-arc.k-chair { stroke: var(--accent); }
svg .tick { stroke: var(--c); stroke-linecap: round; opacity: .7; } svg .tick { stroke: var(--c); stroke-linecap: round; opacity: .7; }
@@ -281,8 +278,9 @@ svg .lane-mark.tone-info { fill: var(--info); stroke: var(--info); }
svg .lane-mark.tone-neutral { fill: var(--muted); stroke: var(--muted); } svg .lane-mark.tone-neutral { fill: var(--muted); stroke: var(--muted); }
input[type="datetime-local"] { padding: 3px 6px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--panel); font-size: var(--fs-sm); } input[type="datetime-local"] { padding: 3px 6px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--panel); font-size: var(--fs-sm); }
.custom { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; } .custom { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.avatar { display: inline-grid; place-items: center; flex: none; width: 26px; height: 26px; border-radius: 50%; background: var(--sunken); border: 1px solid var(--line); font-size: var(--fs-lg); line-height: 1; } .avatar { display: inline-grid; place-items: center; flex: none; width: 26px; height: 26px; overflow: hidden; border-radius: 50%; background: var(--sunken); border: 1px solid var(--line); }
.avatar.big { width: 52px; height: 52px; font-size: var(--fs-3xl); } .avatar.big { width: 52px; height: 52px; }
.avatar svg { display: block; width: 100%; height: 100%; }
.linkish { padding: 0; border: 0; background: none; color: inherit; font-weight: 650; text-align: left; cursor: pointer; overflow-wrap: anywhere; } .linkish { padding: 0; border: 0; background: none; color: inherit; font-weight: 650; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
.linkish:hover { color: var(--accent); text-decoration: underline; } .linkish:hover { color: var(--accent); text-decoration: underline; }
.popup { position: fixed; inset: 0; z-index: 25; width: auto; height: auto; max-width: none; max-height: none; margin: 0; padding: 0; overflow: hidden; border: 0; background: transparent; color: var(--text); } .popup { position: fixed; inset: 0; z-index: 25; width: auto; height: auto; max-width: none; max-height: none; margin: 0; padding: 0; overflow: hidden; border: 0; background: transparent; color: var(--text); }
@@ -453,6 +451,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "nav.js" %} {% include "nav.js" %}
{% include "popup.js" %} {% include "popup.js" %}
{% include "faces.js" %}
</script> </script>
<script nonce="{{ nonce }}"> <script nonce="{{ nonce }}">
{# Jinja drops each file's last newline and trim_blocks the one after the tag: the blank lines keep consecutive modules on separate lines #} {# Jinja drops each file's last newline and trim_blocks the one after the tag: the blank lines keep consecutive modules on separate lines #}
-1
View File
@@ -4,7 +4,6 @@
const DAY = 86400; const DAY = 86400;
const HUMAN = "Operator"; const HUMAN = "Operator";
const PRESETS = [[3600, "1 h"], [21600, "6 h"], [DAY, "24 h"], [7 * DAY, "7 d"], [0, "all"], [-1, "custom"]]; const PRESETS = [[3600, "1 h"], [21600, "6 h"], [DAY, "24 h"], [7 * DAY, "7 d"], [0, "all"], [-1, "custom"]];
const EMOJI = ["🦊", "🐙", "🦉", "🐝", "🐢", "🦄", "🐳", "🦋", "🐧", "🦁", "🐼", "🐨", "🦜", "🐬", "🦀", "🐸", "🦔", "🦩", "🐞", "🌵", "🍀", "🌻", "🍄", "🌙", "⭐", "🔥", "🌊", "⚡", "🎈", "🎲", "🧭", "🚀", "🪐", "🔭", "🧩", "🎯", "🪁", "🧪", "🔮", "🗿", "🌋", "🍉", "🍋", "🥨", "🐋", "🦚", "🐌", "🌈"];
const PARTS = [["user", "User"], ["assistant", "Assistant"], ["tool_call", "Tool call"], ["tool_result", "Tool result"], ["system", "System"]]; const PARTS = [["user", "User"], ["assistant", "Assistant"], ["tool_call", "Tool call"], ["tool_result", "Tool result"], ["system", "System"]];
const PART_TONES = {user: "info", assistant: "ok", tool_call: "violet", tool_result: "teal", system: "neutral"}; const PART_TONES = {user: "info", assistant: "ok", tool_call: "violet", tool_result: "teal", system: "neutral"};
const KIND_TONES = {radio: "teal", review: "info", ask: "warn", decision: "violet", operator: "bad", register: "neutral", rename: "info", cron: "teal", rule: "ok", approval: "ok", run: "info", transcript: "neutral"}; const KIND_TONES = {radio: "teal", review: "info", ask: "warn", decision: "violet", operator: "bad", register: "neutral", rename: "info", cron: "teal", rule: "ok", approval: "ok", run: "info", transcript: "neutral"};
+7 -8
View File
@@ -68,14 +68,13 @@
updated.textContent = `${ui.offline ? "No connection · " : ""}${live() ? "Live" : "As of"} ${absolute.format(stampOf(live() ? state.now : state.until))}`; updated.textContent = `${ui.offline ? "No connection · " : ""}${live() ? "Live" : "As of"} ${absolute.format(stampOf(live() ? state.now : state.until))}`;
} }
function avatar(name) { const faceParts = (key) => Faces.of(key).parts.map(([tag, attrs]) => s(tag, attrs));
let hash = 2166136261; const talking = (key) => Boolean(speech.current && speech.current.who === key);
for (const char of String(name)) { const face = (who, big) => {
hash = Math.imul(hash ^ char.codePointAt(0), 16777619) >>> 0; const key = agentKey(who);
} return h("span", {class: `avatar${big ? " big" : ""}${talking(key) ? " speaking" : ""}`, "data-face": key, "aria-hidden": "true"},
return EMOJI[hash % EMOJI.length]; s("svg", {class: "face-art", viewBox: `0 0 ${Faces.SIZE} ${Faces.SIZE}`, focusable: "false"}, faceParts(key)));
} };
const face = (name, big) => h("span", {class: big ? "avatar big" : "avatar", "aria-hidden": "true"}, avatar(name));
const person = (name) => (name && name !== "-" && name !== "all" && name !== HUMAN ? h("button", {type: "button", class: "linkish", title: `Open ${name}`, on: {click: () => openAgent(name)}}, name) : dash(name)); const person = (name) => (name && name !== "-" && name !== "all" && name !== HUMAN ? h("button", {type: "button", class: "linkish", title: `Open ${name}`, on: {click: () => openAgent(name)}}, name) : dash(name));
const people = () => state.people || {}; const people = () => state.people || {};
const labelOf = (key) => (people()[key] || {name: String(key).replace(/^name:/, "")}).name; const labelOf = (key) => (people()[key] || {name: String(key).replace(/^name:/, "")}).name;
+1 -1
View File
@@ -137,7 +137,7 @@
}, },
s("title", {}, `${node.label} · ${NODE_KINDS[node.kind]}${node.parent ? ` of ${labelOf(node.parent)}` : ""}${tasks}`), s("title", {}, `${node.label} · ${NODE_KINDS[node.kind]}${node.parent ? ` of ${labelOf(node.parent)}` : ""}${tasks}`),
s("circle", {r}), s("circle", {r}),
node.kind === "subagent" ? null : s("text", {class: "initial", "text-anchor": "middle", dy: "0.35em"}, node.label.slice(0, 1).toUpperCase()), node.kind === "subagent" ? null : s("g", {class: "face-art", transform: `translate(${-r} ${-r}) scale(${(2 * r) / Faces.SIZE})`}, faceParts(node.id)),
caption(node, at, r, gap)); caption(node, at, r, gap));
} }
+2 -2
View File
@@ -4,7 +4,7 @@
...["agents", "artifacts", "tasks"].flatMap((screen) => Nav.viewsOf(screen).map(([id, name]) => ({label: `${Nav.TABS.find(([tab]) => tab === screen)[1]}: ${name}`, hint: Nav.hashOf(screen, id), run: () => go(screen, id)}))), ...["agents", "artifacts", "tasks"].flatMap((screen) => Nav.viewsOf(screen).map(([id, name]) => ({label: `${Nav.TABS.find(([tab]) => tab === screen)[1]}: ${name}`, hint: Nav.hashOf(screen, id), run: () => go(screen, id)}))),
...Object.entries(state.views).map(([id, view]) => ({label: `Stream: ${view.label}`, hint: "Category", run: () => useView(id)})), ...Object.entries(state.views).map(([id, view]) => ({label: `Stream: ${view.label}`, hint: "Category", run: () => useView(id)})),
...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "saved view", run: () => useSaved(view)})), ...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "saved view", run: () => useSaved(view)})),
...agentNames().map((name) => ({label: `${avatar(name)} ${name}`, hint: "Agent", run: () => openAgent(name)})), ...agentNames().map((name) => ({label: name, hint: "Agent", face: name, run: () => openAgent(name)})),
{label: "Radio to all …", hint: "Newsletter, Broadcast, Pan-Pan, Security, Mayday", run: openToAll}, {label: "Radio to all …", hint: "Newsletter, Broadcast, Pan-Pan, Security, Mayday", run: openToAll},
...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Day/Night", run: () => confirmAct(id)})), ...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Day/Night", run: () => confirmAct(id)})),
{label: "Priority …", hint: "agent order in the hierarchy", run: openAgentOrder}, {label: "Priority …", hint: "agent order in the hierarchy", run: openAgentOrder},
@@ -40,7 +40,7 @@
drawPalette(); drawPalette();
} }
}, },
}}, h("span", {}, item.label), h("span", {class: "muted"}, item.hint)))); }}, h("span", {class: item.face ? "who" : null}, item.face ? face(item.face, false) : null, item.label), h("span", {class: "muted"}, item.hint))));
const current = palette.list.children[palette.index]; const current = palette.list.children[palette.index];
if (current) { if (current) {
current.scrollIntoView({block: "nearest"}); current.scrollIntoView({block: "nearest"});
+5 -2
View File
@@ -57,18 +57,21 @@
function speakerChips() { function speakerChips() {
return Object.entries(people()).sort(([, a], [, b]) => a.now.localeCompare(b.now)).map(([key, entry]) => h("li", {class: "speaker", "data-key": key, title: entry.now}, return Object.entries(people()).sort(([, a], [, b]) => a.now.localeCompare(b.now)).map(([key, entry]) => h("li", {class: "speaker", "data-key": key, title: entry.now},
h("span", {class: "speaker-face"}, face(entry.now, false)), h("span", {class: "speaker-name"}, entry.now))); h("span", {class: "speaker-face"}, face(key, false)), h("span", {class: "speaker-name"}, entry.now)));
} }
function drawSpeaker() { function drawSpeaker() {
const now = speech.current && speech.current.who ? speech.current : null; const now = speech.current && speech.current.who ? speech.current : null;
for (const art of document.querySelectorAll(".avatar[data-face]")) {
art.classList.toggle("speaking", Boolean(now && art.dataset.face === now.who));
}
if (radioDom) { if (radioDom) {
for (const chip of radioDom.speakers.querySelectorAll(".speaker")) { for (const chip of radioDom.speakers.querySelectorAll(".speaker")) {
chip.classList.toggle("speaking", Boolean(now && chip.dataset.key === now.who)); chip.classList.toggle("speaking", Boolean(now && chip.dataset.key === now.who));
} }
radioDom.caption.hidden = !now; radioDom.caption.hidden = !now;
radioDom.caption.replaceChildren(...(now ? [ radioDom.caption.replaceChildren(...(now ? [
h("span", {class: "speaker-face"}, face(nameOf(now.who), false)), h("span", {class: "speaker-face"}, face(now.who, false)),
h("strong", {}, `${nameOf(now.who)} speaking`), h("strong", {}, `${nameOf(now.who)} speaking`),
h("span", {class: "caption-line", title: now.line}, now.line), h("span", {class: "caption-line", title: now.line}, now.line),
iconBtn("bi-stop-fill", "Stop speaking", "btn small ghost", cancelSpeech), iconBtn("bi-stop-fill", "Stop speaking", "btn small ghost", cancelSpeech),
+4 -1
View File
@@ -19,8 +19,11 @@
.caption-line { flex: 1; min-width: 0; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; } .caption-line { flex: 1; min-width: 0; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
svg .node.speaking circle { stroke: var(--ok); stroke-width: 4; animation: speaking-node 1.3s ease-in-out infinite; } svg .node.speaking circle { stroke: var(--ok); stroke-width: 4; animation: speaking-node 1.3s ease-in-out infinite; }
@keyframes speaking-node { 50% { stroke-width: 10; stroke-opacity: .45; } } @keyframes speaking-node { 50% { stroke-width: 10; stroke-opacity: .45; } }
.face-art .mouth { transform-box: fill-box; transform-origin: center; }
.speaking .face-art .mouth { animation: talk .3s ease-in-out infinite alternate; }
@keyframes talk { to { transform: scale(1.15, 2.2); } }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.speaker.speaking .speaker-face::after, .radio-caption .speaker-face::after, svg .node.speaking circle { animation: none; } .speaker.speaking .speaker-face::after, .radio-caption .speaker-face::after, svg .node.speaking circle, .speaking .face-art .mouth { animation: none; }
} }
.stations { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; } .stations { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
.station { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) auto auto; align-items: center; gap: var(--sp-2); min-width: 0; padding: 3px 3px 3px 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--sunken); } .station { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) auto auto; align-items: center; gap: var(--sp-2); min-width: 0; padding: 3px 3px 3px 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--sunken); }
+42 -8
View File
@@ -243,7 +243,9 @@ POPUP_ORBIT = """(async () => {@HELPERS@@POPUPS@
const flat = !tall(); const flat = !tall();
click(pick()); click(pick());
await sleep(400); await sleep(400);
const shown = {...opened(), ...look(pick()), sections: all("h3", box).map((head) => head.firstChild.textContent), composer: Boolean(box.querySelector("form textarea")), offices: text(box.querySelector(".popup-body p"))}; const art = box.querySelector(".popup-head .avatar.big");
const face = art ? {key: art.dataset.face, svg: Boolean(art.querySelector("svg.face-art .mouth")), text: art.textContent} : null;
const shown = {...opened(), ...look(pick()), sections: all("h3", box).map((head) => head.firstChild.textContent), composer: Boolean(box.querySelector("form textarea")), offices: text(box.querySelector(".popup-body p")), face};
const gone = await away(pick); const gone = await away(pick);
click(pick()); click(pick());
await sleep(300); await sleep(300);
@@ -491,12 +493,18 @@ SPEAKING = """
return node && !node.hidden ? node : null; return node && !node.hidden ? node : null;
}; };
const ring = (chip) => (chip ? getComputedStyle(chip.querySelector(".speaker-face"), "::after").animationName : null); const ring = (chip) => (chip ? getComputedStyle(chip.querySelector(".speaker-face"), "::after").animationName : null);
const mouth = (art) => (art ? [art.classList.contains("speaking"), getComputedStyle(art.querySelector(".mouth")).animationName] : null);
const faces = () => ({
speaking: mouth(document.querySelector('#main .speakers .avatar[data-face="s2"]')),
silent: mouth(document.querySelector('#main .speakers .avatar[data-face="s1"]')),
keys: [...new Set(all("#main .avatar.speaking").map((art) => art.dataset.face))],
});
const cue = async (word) => { const cue = async (word) => {
await until(() => document.querySelector("#main .speakers .speaker"), 8000); await until(() => document.querySelector("#main .speakers .speaker"), 8000);
await fetch("/api/tts", {method: "POST", headers: {"X-Kvb-Orchestrator": "1", "Content-Type": "application/json"}, body: JSON.stringify({text: word, voice: ""}), cache: "no-store"}); await fetch("/api/tts", {method: "POST", headers: {"X-Kvb-Orchestrator": "1", "Content-Type": "application/json"}, body: JSON.stringify({text: word, voice: ""}), cache: "no-store"});
const found = await until(caption, 25000); const found = await until(caption, 25000);
const chip = document.querySelector("#main .speaker.speaking"); const chip = document.querySelector("#main .speaker.speaking");
return found ? {who: text(found.querySelector("strong")), line: text(found.querySelector(".caption-line")), chip: chip ? chip.dataset.key : null, ring: ring(chip)} : {error: "no caption", toasts: all("#toasts .toast").map(text)}; return found ? {who: text(found.querySelector("strong")), line: text(found.querySelector(".caption-line")), chip: chip ? chip.dataset.key : null, ring: ring(chip), faces: faces()} : {error: "no caption", toasts: all("#toasts .toast").map(text)};
}; };
""" """
SPEAK = """(async () => {@HELPERS@@SPEAKING@ SPEAK = """(async () => {@HELPERS@@SPEAKING@
@@ -505,14 +513,18 @@ SPEAK = """(async () => {@HELPERS@@SPEAKING@
SPEAK_GRAPH = """(async () => {@HELPERS@@SPEAKING@ SPEAK_GRAPH = """(async () => {@HELPERS@@SPEAKING@
location.hash = "#agents/orbit"; location.hash = "#agents/orbit";
const node = await until(() => document.querySelector("#main svg .node.speaking"), 6000); const node = await until(() => document.querySelector("#main svg .node.speaking"), 6000);
return {node: node ? node.getAttribute("aria-label") : null, ring: node ? getComputedStyle(node.querySelector("circle")).animationName : null}; const silent = document.querySelector('#main svg .node[data-pick="s1"] .mouth');
return {
node: node ? node.getAttribute("aria-label") : null, ring: node ? getComputedStyle(node.querySelector("circle")).animationName : null,
mouth: node ? getComputedStyle(node.querySelector(".mouth")).animationName : null, silent: silent ? getComputedStyle(silent).animationName : null,
};
})()""".replace("@HELPERS@", HELPERS).replace("@SPEAKING@", SPEAKING) })()""".replace("@HELPERS@", HELPERS).replace("@SPEAKING@", SPEAKING)
SPEAK_END = """(async () => {@HELPERS@@SPEAKING@ SPEAK_END = """(async () => {@HELPERS@@SPEAKING@
const ended = Boolean(await until(() => !document.querySelector("#main svg .node") || !document.querySelector("#main svg .node.speaking"), 20000)); const ended = Boolean(await until(() => !document.querySelector("#main svg .node") || !document.querySelector("#main svg .node.speaking"), 20000));
location.hash = "#stream/radio"; location.hash = "#stream/radio";
await until(() => document.querySelector("#main .radio-caption"), 6000); await until(() => document.querySelector("#main .radio-caption"), 6000);
await sleep(300); await sleep(300);
return {ended, caption: Boolean(caption()), chips: all("#main .speaker.speaking").length}; return {ended, caption: Boolean(caption()), chips: all("#main .speaker.speaking").length, faces: all("#main .avatar.speaking").length};
})()""".replace("@HELPERS@", HELPERS).replace("@SPEAKING@", SPEAKING) })()""".replace("@HELPERS@", HELPERS).replace("@SPEAKING@", SPEAKING)
COMPOSE = """(async () => {@HELPERS@ COMPOSE = """(async () => {@HELPERS@
const form = await until(() => document.querySelector("#main form.composer"), 8000); const form = await until(() => document.querySelector("#main form.composer"), 8000);
@@ -908,6 +920,7 @@ class TestOrchestratorE2E(HubCase):
self.assertNotIn("error", artifact) self.assertNotIn("error", artifact)
self.assertTrue(agent["flat"], "#agents/orbit scrolls") self.assertTrue(agent["flat"], "#agents/orbit scrolls")
self.popped(agent, "Stage") self.popped(agent, "Stage")
self.assertEqual(agent["shown"]["face"], {"key": "s2", "svg": True, "text": ""})
self.assertEqual(agent["shown"]["sections"], ["Tasks", "Claims", "Subagents", "History"]) self.assertEqual(agent["shown"]["sections"], ["Tasks", "Claims", "Subagents", "History"])
self.assertTrue(agent["shown"]["composer"]) self.assertTrue(agent["shown"]["composer"])
self.assertRegex(agent["shown"]["offices"], r"^Offices: .*identity/oidc") self.assertRegex(agent["shown"]["offices"], r"^Offices: .*identity/oidc")
@@ -1079,11 +1092,32 @@ class TestOrchestratorE2E(HubCase):
(OUT / "chromium-speaking.json").write_text(json.dumps(data, indent=1), encoding="utf-8") (OUT / "chromium-speaking.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
self.assertEqual(data["errors"], []) self.assertEqual(data["errors"], [])
speaking, graph, ended, calm = data["results"] speaking, graph, ended, calm = data["results"]
self.assertEqual((speaking["who"], speaking["line"], speaking["chip"], speaking["ring"]), ("Stage speaking", "Disk almost full (e2e-cue-1)", "s2", "speaking"), speaking) self.talks(speaking, graph)
self.assertEqual((graph["node"] or "").split(",")[0], "Stage", graph) self.assertEqual(ended, {"ended": True, "caption": False, "chips": 0, "faces": 0})
self.assertEqual(graph["ring"], "speaking-node")
self.assertEqual(ended, {"ended": True, "caption": False, "chips": 0})
self.assertEqual((calm["who"], calm["line"], calm["ring"]), ("Stage speaking", "Disk almost full (e2e-cue-2)", "none"), calm) self.assertEqual((calm["who"], calm["line"], calm["ring"]), ("Stage speaking", "Disk almost full (e2e-cue-2)", "none"), calm)
self.assertEqual(calm["faces"]["speaking"], [True, "none"], calm)
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
def test_firefox_shows_who_speaks_and_moves_only_the_speakers_mouth(self):
self.speaks(("e2e-cue-1",))
steps = [
{"width": 1366, "height": 768, "url": f"{self.base}#stream/radio", "wait": 900, "probe": SPEAK.replace("@CUE@", "e2e-cue-1"), "shot": str(OUT / "firefox-1366-radio-speaking.png"), "gesture": True},
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": SPEAK_GRAPH, "shot": str(OUT / "firefox-1366-agents-speaking.png")},
]
data = self.drive(steps, None, "bidi.mjs", os.environ.get("FIREFOX", "firefox"))
(OUT / "firefox-speaking.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
self.assertEqual([error for error in data["errors"] if "/favicon.ico" not in error], [])
self.talks(*data["results"])
def talks(self, speaking: dict, graph: dict) -> None:
"""Args:
speaking: the SPEAK probe while Stage (s2) is read aloud.
graph: the SPEAK_GRAPH probe on #agents/orbit right after it.
"""
self.assertEqual((speaking["who"], speaking["line"], speaking["chip"], speaking["ring"]), ("Stage speaking", "Disk almost full (e2e-cue-1)", "s2", "speaking"), speaking)
self.assertEqual(speaking["faces"], {"speaking": [True, "talk"], "silent": [False, "none"], "keys": ["s2"]}, speaking)
self.assertEqual((graph["node"] or "").split(",")[0], "Stage", graph)
self.assertEqual((graph["ring"], graph["mouth"], graph["silent"]), ("speaking-node", "talk", "none"), graph)
def test_chromium_records_transcribes_and_sends_a_signed_call_with_voice(self): def test_chromium_records_transcribes_and_sends_a_signed_call_with_voice(self):
local = f"http://localhost:{self.port}/" local = f"http://localhost:{self.port}/"
+1
View File
@@ -77,6 +77,7 @@ VOICE = HUB / "templates" / "voice.js"
SPACE = HUB / "templates" / "space.js" SPACE = HUB / "templates" / "space.js"
NAV = HUB / "templates" / "nav.js" NAV = HUB / "templates" / "nav.js"
POPUP = HUB / "templates" / "popup.js" POPUP = HUB / "templates" / "popup.js"
FACES = HUB / "templates" / "faces.js"
class FakeScripts: class FakeScripts:
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
import json
import math
import re
import subprocess
import unittest
from collections import Counter
from hub_support import FACES
FACES_PROBE = """
const F = require(process.argv[1]);
let seed = 7;
const hex = (digits) => Array.from({length: digits}, () => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return (seed >>> 28).toString(16);
}).join("");
const ids = Array.from({length: 5000}, () => `${hex(8)}-${hex(4)}-${hex(4)}-${hex(4)}-${hex(12)}`);
const fixed = ["s1", "s2", "name:Stage", "Operator", "", "名前"];
console.log(JSON.stringify({
size: F.SIZE,
counts: F.COUNTS,
combinations: F.COMBINATIONS,
picks: ids.map((id) => F.of(id).pick),
again: ids.slice(0, 200).every((id) => JSON.stringify(F.of(id)) === JSON.stringify(F.of(id))),
fixed: fixed.map((id) => F.of(id)),
parts: ids.slice(0, 400).map((id) => F.of(id).parts),
}));
"""
ATTRIBUTES = ["shape", "colour", "eyes", "brows", "mouth", "hair", "accessory"]
TAGS = {"ellipse", "rect", "path"}
COLOUR = re.compile(r"^#[0-9a-f]{3}(?:[0-9a-f]{3})?$")
def probe() -> dict:
done = subprocess.run(["node", "-e", FACES_PROBE, str(FACES)], capture_output=True, text=True, timeout=60, check=True)
return json.loads(done.stdout)
class TestFacesModule(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.data = probe()
cls.rerun = probe()
def test_seven_independent_attributes_multiply_to_at_least_1000_combinations(self):
counts = self.data["counts"]
self.assertEqual(list(counts), ATTRIBUTES)
self.assertTrue(all(count >= 2 for count in counts.values()), counts)
self.assertEqual(self.data["combinations"], math.prod(counts.values()))
self.assertGreaterEqual(self.data["combinations"], 1000)
def test_many_ids_reach_at_least_1000_distinct_faces(self):
distinct = {tuple(pick[name] for name in ATTRIBUTES) for pick in self.data["picks"]}
self.assertGreaterEqual(len(distinct), 1000)
self.assertGreater(len(distinct), len(self.data["picks"]) * 0.9)
def test_the_same_id_gets_the_same_face_in_every_call_and_every_run(self):
self.assertTrue(self.data["again"])
self.assertEqual(self.data["fixed"], self.rerun["fixed"])
self.assertEqual(self.data["picks"], self.rerun["picks"])
self.assertEqual(len({json.dumps(face["pick"]) for face in self.data["fixed"]}), len(self.data["fixed"]))
def test_different_ids_spread_evenly_over_every_variant_of_every_attribute(self):
total = len(self.data["picks"])
for name, count in self.data["counts"].items():
seen = Counter(pick[name] for pick in self.data["picks"])
with self.subTest(attribute=name):
self.assertEqual(sorted(seen), list(range(count)))
for variant, hits in seen.items():
self.assertTrue(0.6 < hits * count / total < 1.4, (variant, hits))
def test_the_svg_description_has_a_coloured_head_one_mouth_and_no_missing_values(self):
for parts in [*self.data["parts"], *(face["parts"] for face in self.data["fixed"])]:
with self.subTest(parts=parts[0]):
self.assertEqual({tag for tag, _ in parts} - TAGS, set())
self.assertRegex(parts[0][1]["fill"], COLOUR)
self.assertEqual([attrs.get("class") for _, attrs in parts].count("mouth"), 1)
for _, attrs in parts:
for key, value in attrs.items():
if isinstance(value, str):
self.assertTrue(value and "undefined" not in value and "NaN" not in value, (key, value))
else:
self.assertIsInstance(value, (int, float), (key, value))
self.assertTrue(math.isfinite(value) and -1 <= value <= self.data["size"] + 1, (key, value))
def test_faces_draw_no_circle_because_graph_node_styles_target_every_circle_in_a_node(self):
tags = {tag for parts in self.data["parts"] for tag, _ in parts}
self.assertNotIn("circle", tags)
if __name__ == "__main__":
unittest.main()
+4 -4
View File
@@ -11,7 +11,7 @@ from pathlib import Path
from unittest import mock from unittest import mock
from hub_case import HubCase from hub_case import HubCase
from hub_support import AMBIENCE, NAV, POPUP, SPACE, VOICE from hub_support import AMBIENCE, FACES, NAV, POPUP, SPACE, VOICE
from kvb_orchestrator import exchange from kvb_orchestrator import exchange
@@ -63,9 +63,9 @@ class TestHubPage(HubCase):
self.assertEqual((state["reviews"][0]["title"], state["reviews"][0]["notes"]), ("<script>alert(1)</script>", "</pre><script>alert(2)</script>")) self.assertEqual((state["reviews"][0]["title"], state["reviews"][0]["notes"]), ("<script>alert(1)</script>", "</pre><script>alert(2)</script>"))
self.assertEqual((state["register"][0]["title"], state["radio"][0]["from"]), ("<script>alert(3)</script>", "<img src=x>")) self.assertEqual((state["register"][0]["title"], state["radio"][0]["from"]), ("<script>alert(3)</script>", "<img src=x>"))
def test_the_index_scripts_parse_and_embed_the_ambience_voice_nav_popup_and_space_modules(self): def test_the_index_scripts_parse_and_embed_the_ambience_voice_nav_popup_faces_and_space_modules(self):
html = self.index() html = self.index()
for module in (AMBIENCE, VOICE, NAV, POPUP, SPACE): for module in (AMBIENCE, VOICE, NAV, POPUP, FACES, SPACE):
with self.subTest(module=module.name): with self.subTest(module=module.name):
self.assertIn(module.read_text(encoding="utf-8").strip(), html) self.assertIn(module.read_text(encoding="utf-8").strip(), html)
scripts = re.findall(r'<script nonce="[^"]+">(.*?)</script>', html, re.DOTALL) scripts = re.findall(r'<script nonce="[^"]+">(.*?)</script>', html, re.DOTALL)
@@ -74,7 +74,7 @@ class TestHubPage(HubCase):
page.write_text("\n;\n".join(scripts), encoding="utf-8") page.write_text("\n;\n".join(scripts), encoding="utf-8")
done = subprocess.run(["node", "--check", str(page)], capture_output=True, text=True, timeout=60, check=False) done = subprocess.run(["node", "--check", str(page)], capture_output=True, text=True, timeout=60, check=False)
self.assertEqual(done.returncode, 0, done.stderr) self.assertEqual(done.returncode, 0, done.stderr)
for module in (AMBIENCE, VOICE, NAV, POPUP, SPACE): for module in (AMBIENCE, VOICE, NAV, POPUP, FACES, SPACE):
with self.subTest(module=module.name): with self.subTest(module=module.name):
done = subprocess.run(["node", "--check", str(module)], capture_output=True, text=True, timeout=60, check=False) done = subprocess.run(["node", "--check", str(module)], capture_output=True, text=True, timeout=60, check=False)
self.assertEqual(done.returncode, 0, done.stderr) self.assertEqual(done.returncode, 0, done.stderr)