Agents get their marks; contributors one row each; an assistant is tested when added 206c1fc1b06a
A repository's contributors show one row per account with each agent's
share beneath, as oxy log shows via; agents' names on change pages,
reviews and the ask buttons carry their brand mark, served at
/avatar/agent/{name} (Claude, ChatGPT, Perplexity, DeepSeek, Mistral,
Antigravity with the Gemini mark; others an identicon). Adding an
assistant tests the connection at once and says what it answered, and
each one has a TEST CONNECTION button.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-04 18:01 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
206c1fc1b06a90b3388b36ad52ef832b9ba9e24ab182773e44022baf4c7c27bb- Depends on
- 101740454617 145306044896 19bb31ff1f5c 749d9a5fcd9f 7d2eb590051f 83d5052bf76c 8933faff2043 8e96499f2705 a160b98fb922 a1b9869565aa b5668108487f d23464c0ac60 de505df5cf8d e71846aa07b9 efd29a0033e8
- Depended on by
- ac689cd21adf c7067c7bda56 a7aa76ac52d7 ffb67a81188c c9aacc0e7dc1 155cab142b63 3ea0b8af2d46 c21ef37dd4a0
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 34 atoms, 8 tree operations, 20 files
Browse the tree at this change
added assets/agents/README.md +1 −0
| 1 | + | Brand marks for AI agents' avatars (hub spec §6): the paths are from simple-icons (CC0), each set white on the brand's colour. `antigravity` uses the Gemini mark, Antigravity having none of its own there; Grok has no mark and falls back to the identicon. |
added assets/agents/antigravity.svg +1 −0
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Google Gemini"><title>Google Gemini</title><circle cx="12" cy="12" r="12" fill="#4285F4"/><g transform="translate(5 5) scale(0.5833)" fill="#fff"><path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81"/></g></svg> |
added assets/agents/chatgpt.svg +1 −0
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="OpenAI"><title>OpenAI</title><circle cx="12" cy="12" r="12" fill="#10A37F"/><g transform="translate(5 5) scale(0.5833)" fill="#fff"><path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/></g></svg> |
added assets/agents/claude.svg +1 −0
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Claude"><title>Claude</title><circle cx="12" cy="12" r="12" fill="#D97757"/><g transform="translate(5 5) scale(0.5833)" fill="#fff"><path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z"/></g></svg> |
added assets/agents/deepseek.svg +1 −0
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="DeepSeek"><title>DeepSeek</title><circle cx="12" cy="12" r="12" fill="#4D6BFE"/><g transform="translate(5 5) scale(0.5833)" fill="#fff"><path d="M23.748 4.651c-.254-.124-.364.113-.512.233-.051.04-.094.09-.137.137-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.155-.708-.311-.955-.65-.172-.24-.219-.509-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.094.172.187.129.323-.082.28-.18.553-.266.833-.055.179-.137.218-.328.14a5.5 5.5 0 0 1-1.737-1.179c-.857-.828-1.631-1.743-2.597-2.46a12 12 0 0 0-.689-.47c-.985-.957.13-1.743.387-1.836.27-.098.094-.433-.778-.428-.872.003-1.67.295-2.687.685a3 3 0 0 1-.465.136 9.6 9.6 0 0 0-2.883-.101c-1.885.21-3.39 1.1-4.497 2.622C.082 8.776-.231 10.854.152 13.02c.403 2.284 1.568 4.175 3.36 5.653 1.857 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.132-.284 4.994-1.86.47.234.962.328 1.78.398.629.058 1.235-.031 1.705-.129.735-.155.684-.836.418-.961-2.155-1.004-1.682-.595-2.112-.926 1.095-1.295 2.768-3.598 3.284-6.733.05-.346.115-.834.108-1.114-.004-.171.035-.238.23-.257a4.2 4.2 0 0 0 1.545-.475c1.397-.763 1.96-2.016 2.093-3.517.02-.23-.004-.467-.247-.588M11.58 18.168c-2.088-1.642-3.101-2.183-3.52-2.16-.39.024-.32.472-.234.763.09.288.207.487.371.74.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.168-1.361-.801-2.5-1.86-3.301-3.306-.775-1.393-1.225-2.888-1.299-4.482-.02-.385.094-.522.477-.592a4.7 4.7 0 0 1 1.53-.038c2.131.311 3.946 1.264 5.467 2.774.868.86 1.525 1.887 2.202 2.89.72 1.066 1.494 2.082 2.48 2.915.348.291.626.513.892.677-.802.09-2.14.109-3.055-.615zm1.001-6.44a.306.306 0 0 1 .415-.287.3.3 0 0 1 .113.074.3.3 0 0 1 .086.214c0 .17-.136.307-.308.307a.303.303 0 0 1-.306-.307m3.11 1.596c-.2.081-.4.151-.591.16a1.25 1.25 0 0 1-.798-.254c-.274-.23-.47-.358-.551-.758a1.7 1.7 0 0 1 .015-.588c.07-.327-.007-.537-.238-.727-.188-.156-.426-.199-.689-.199a.6.6 0 0 1-.254-.078.253.253 0 0 1-.114-.358 1 1 0 0 1 .192-.21c.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.392.451.462.576.685.915.176.264.336.536.446.848.066.194-.02.353-.25.45"/></g></svg> |
added assets/agents/mistral.svg +1 −0
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Mistral AI"><title>Mistral AI</title><circle cx="12" cy="12" r="12" fill="#FA520F"/><g transform="translate(5 5) scale(0.5833)" fill="#fff"><path d="M17.143 3.429v3.428h-3.429v3.429h-3.428V6.857H6.857V3.43H3.43v13.714H0v3.428h10.286v-3.428H6.857v-3.429h3.429v3.429h3.429v-3.429h3.428v3.429h-3.428v3.428H24v-3.428h-3.43V3.429z"/></g></svg> |
added assets/agents/perplexity.svg +1 −0
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Perplexity"><title>Perplexity</title><circle cx="12" cy="12" r="12" fill="#20808D"/><g transform="translate(5 5) scale(0.5833)" fill="#fff"><path d="M22.3977 7.0896h-2.3106V.0676l-7.5094 6.3542V.1577h-1.1554v6.1966L4.4904 0v7.0896H1.6023v10.3976h2.8882V24l6.932-6.3591v6.2005h1.1554v-6.0469l6.9318 6.1807v-6.4879h2.8882V7.0896zm-3.4657-4.531v4.531h-5.355l5.355-4.531zm-13.2862.0676 4.8691 4.4634H5.6458V2.6262zM2.7576 16.332V8.245h7.8476l-6.1149 6.1147v1.9723H2.7576zm2.8882 5.0404v-3.8852h.0001v-2.6488l5.7763-5.7764v7.0111l-5.7764 5.2993zm12.7086.0248-5.7766-5.1509V9.0618l5.7766 5.7766v6.5588zm2.8882-5.0652h-1.733v-1.9723L13.3948 8.245h7.8478v8.087z"/></g></svg> |
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Agents get their marks, contributors one row each.** A repository's contributors show one row per account with each agent's share beneath (`↳ via Claude Code · 1`), as `oxy log` does; agents' names on change pages, reviews and the ask buttons carry their brand mark (Claude, ChatGPT, Perplexity, DeepSeek, Mistral; Antigravity with the Gemini mark), served at `/avatar/agent/{name}`. |
|
| 8 | + | ||
| 7 | 9 | - **Jobs go to runners of the right architecture (oxygenflow#36).** Runners report each image's architecture; the hub matches a job's `arch` or its image's against the runner's, and the queue says what a waiting job asks for. |
|
| 8 | 10 | ||
| 9 | 11 | - **Sign in with GitHub or Google (#106).** Set up per provider under Administration (client id, sealed secret, on or off); the login page offers them. A first sign-in links the account with the same verified email, or makes an account when registration is open; connect or disconnect under Settings › Security. |
|
| … | 111 unchanged lines |
modified crates/hub/src/web/assistants.rs +8 −2
| … | 120 unchanged lines |
||
| 121 | 121 | match assistants::create(&app.db, at.scope, &fields, &token).await? { |
|
| 122 | 122 | Some(a) => { |
|
| 123 | 123 | accounts::audit(&app.db, actor, "assistant.added", &at.label, &a.name).await.ok(); |
|
| 124 | - | format!("{} added.", a.name) |
|
| 124 | + | // Try it at once, so a wrong endpoint or token shows here. |
|
| 125 | + | match assistants::test(app, &a).await { |
|
| 126 | + | Ok(answer) => format!("{} added and answered: {answer}", a.name), |
|
| 127 | + | Err(why) => { |
|
| 128 | + | format!("{} added, but did not answer: {why}. Check the endpoint, API and token, then TEST CONNECTION.", a.name) |
|
| 129 | + | } |
|
| 130 | + | } |
|
| 125 | 131 | } |
|
| 126 | 132 | None => return Ok(Err(format!("There is already an assistant called {} here.", fields.name))), |
|
| 127 | 133 | } |
|
| … | 30 unchanged lines |
||
| 158 | 164 | let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) }; |
|
| 159 | 165 | match assistants::test(app, &a).await { |
|
| 160 | 166 | Ok(answer) => format!("{} answered: {answer}", a.name), |
|
| 161 | - | Err(why) => return Ok(Err(format!("{} did not answer: {why}", a.name))), |
|
| 167 | + | Err(why) => return Ok(Err(format!("{} did not answer: {why}. Check the endpoint, API and token.", a.name))), |
|
| 162 | 168 | } |
|
| 163 | 169 | } |
|
| 164 | 170 | ("prompt", _) => { |
|
| … | 14 unchanged lines |
modified crates/hub/src/web/avatar.rs +27 −0
| … | 140 unchanged lines |
||
| 141 | 141 | Ok("Picture saved.".into()) |
|
| 142 | 142 | } |
|
| 143 | 143 | ||
| 144 | + | /// The brand mark for an AI agent, by the name its identity carries |
|
| 145 | + | /// (hub spec §6): `Claude Code` → Claude's, `ChatGPT` → OpenAI's, and so |
|
| 146 | + | /// on; `None` for a name the hub has no mark for. |
|
| 147 | + | pub fn agent_mark(name: &str) -> Option<&'static str> { |
|
| 148 | + | const MARKS: &[(&str, &str)] = &[ |
|
| 149 | + | ("claude", include_str!("../../../../assets/agents/claude.svg")), |
|
| 150 | + | ("anthropic", include_str!("../../../../assets/agents/claude.svg")), |
|
| 151 | + | ("chatgpt", include_str!("../../../../assets/agents/chatgpt.svg")), |
|
| 152 | + | ("openai", include_str!("../../../../assets/agents/chatgpt.svg")), |
|
| 153 | + | ("codex", include_str!("../../../../assets/agents/chatgpt.svg")), |
|
| 154 | + | ("perplexity", include_str!("../../../../assets/agents/perplexity.svg")), |
|
| 155 | + | ("deepseek", include_str!("../../../../assets/agents/deepseek.svg")), |
|
| 156 | + | ("mistral", include_str!("../../../../assets/agents/mistral.svg")), |
|
| 157 | + | ("antigravity", include_str!("../../../../assets/agents/antigravity.svg")), |
|
| 158 | + | ("gemini", include_str!("../../../../assets/agents/antigravity.svg")), |
|
| 159 | + | ]; |
|
| 160 | + | let lower = name.to_lowercase(); |
|
| 161 | + | MARKS.iter().find(|(k, _)| lower.contains(k)).map(|(_, svg)| *svg) |
|
| 162 | + | } |
|
| 163 | + | ||
| 164 | + | /// `GET /avatar/agent/{name}`: an AI agent's brand mark, or its identicon. |
|
| 165 | + | pub async fn agent(Path(name): Path<String>) -> Response { |
|
| 166 | + | let name = name.trim_end_matches(".svg"); |
|
| 167 | + | let svg = agent_mark(name).map(String::from).unwrap_or_else(|| identicon(name)); |
|
| 168 | + | ([(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "public, max-age=86400")], svg).into_response() |
|
| 169 | + | } |
|
| 170 | + | ||
| 144 | 171 | /// `GET /avatar/{name}`: the uploaded picture, or the identicon. |
|
| 145 | 172 | pub async fn avatar(State(app): State<Shared>, Path(name): Path<String>) -> Response { |
|
| 146 | 173 | let name = name.trim_end_matches(".svg"); |
|
| … | 85 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 53 unchanged lines |
||
| 54 | 54 | Router::new() |
|
| 55 | 55 | .route("/", get(home::index)) |
|
| 56 | 56 | .route("/explore", get(home::explore)) |
|
| 57 | + | .route("/avatar/agent/{name}", get(avatar::agent)) |
|
| 57 | 58 | .route("/avatar/{name}", get(avatar::avatar)) |
|
| 58 | 59 | .route("/health", get(home::health)) |
|
| 59 | 60 | .route("/about", get(home::about)) |
|
| … | 639 unchanged lines |
modified crates/hub/src/web/repo.rs +20 −0
| … | 107 unchanged lines |
||
| 108 | 108 | for c in &mut o.contributors { |
|
| 109 | 109 | c.account = linked.get(&c.key).cloned(); |
|
| 110 | 110 | } |
|
| 111 | + | // One row per account: a person's keys over time are one contributor, |
|
| 112 | + | // their agents' shares summed beneath (as `oxy log` shows via). |
|
| 113 | + | let mut merged: Vec<view::Contributor> = Vec::new(); |
|
| 114 | + | for c in std::mem::take(&mut o.contributors) { |
|
| 115 | + | match c.account.as_ref().and_then(|a| merged.iter_mut().find(|m| m.account.as_deref() == Some(a))) { |
|
| 116 | + | Some(m) => { |
|
| 117 | + | m.changes += c.changes; |
|
| 118 | + | for (who, n) in c.via { |
|
| 119 | + | match m.via.iter_mut().find(|(w, _)| *w == who) { |
|
| 120 | + | Some((_, k)) => *k += n, |
|
| 121 | + | None => m.via.push((who, n)), |
|
| 122 | + | } |
|
| 123 | + | } |
|
| 124 | + | m.via.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); |
|
| 125 | + | } |
|
| 126 | + | None => merged.push(c), |
|
| 127 | + | } |
|
| 128 | + | } |
|
| 129 | + | merged.sort_by(|a, b| b.changes.cmp(&a.changes).then(a.name.cmp(&b.name))); |
|
| 130 | + | o.contributors = merged; |
|
| 111 | 131 | let rs = RepoShell::new(&app, &row, "code").with_role(role); |
|
| 112 | 132 | let readme_html = o.readme_html(&rs, &o.tree.target.label); |
|
| 113 | 133 | let watching = match viewer.account() { |
|
| … | 392 unchanged lines |
modified crates/hub/tests/accounts.rs +4 −4
| … | 678 unchanged lines |
||
| 679 | 679 | ) |
|
| 680 | 680 | }; |
|
| 681 | 681 | let r = post(app, "/admin/assistants", Some(&andy), &add("Claude", "")).await; |
|
| 682 | - | assert!(r.body.contains("Claude added.") && r.body.contains("token set"), "{}", r.body); |
|
| 682 | + | assert!(r.body.contains("Claude added and answered: OK from mock") && r.body.contains("token set"), "{}", r.body); |
|
| 683 | 683 | assert!(!r.body.contains("sk-test-secret"), "the token never comes back"); |
|
| 684 | 684 | let site = assistants::list(&app.db, Scope::Site).await.unwrap(); |
|
| 685 | 685 | assert_eq!(site.len(), 1); |
|
| … | 17 unchanged lines |
||
| 703 | 703 | assert!(r.body.contains("Claude answered: OK from mock"), "{}", r.body); |
|
| 704 | 704 | { |
|
| 705 | 705 | let calls = seen.lock().unwrap(); |
|
| 706 | - | assert_eq!(calls.len(), 1); |
|
| 706 | + | assert_eq!(calls.len(), 2, "once on add, once on test"); |
|
| 707 | 707 | assert!(calls[0].starts_with("Bearer sk-test-secret "), "{}", calls[0]); |
|
| 708 | 708 | assert!(calls[0].contains("\"model\":\"claude-opus-5-5\"") && calls[0].contains("Reply with the single word OK"), "{}", calls[0]); |
|
| 709 | 709 | } |
|
| … | 39 unchanged lines |
||
| 749 | 749 | .await; |
|
| 750 | 750 | assert!(r.body.contains("Prompt saved."), "{}", r.body); |
|
| 751 | 751 | let r = post(app, "/orgs/acme/settings/assistants", Some(&andy), &add("DeepSeek", "")).await; |
|
| 752 | - | assert!(r.body.contains("DeepSeek added."), "{}", r.body); |
|
| 752 | + | assert!(r.body.contains("DeepSeek added and answered"), "{}", r.body); |
|
| 753 | 753 | let prompt = assistants::prompt_for_repo(&app.db, &repo).await.unwrap(); |
|
| 754 | 754 | assert!(prompt.starts_with("## Instructions from the site\n\nReview for correctness first.\n\n## Instructions from the acme organisation\n\nWe write 6502 assembly."), "{prompt}"); |
|
| 755 | 755 | let repo_page = get(app, "/acme/plans/settings", Some(&andy)).await.body; |
|
| … | 7 unchanged lines |
||
| 763 | 763 | let r = post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf}&action=toggle&id={}", claude.id)).await; |
|
| 764 | 764 | assert!(r.body.contains("Claude is off."), "{}", r.body); |
|
| 765 | 765 | let r = post(app, "/acme/plans/settings/assistants", Some(&andy), &add("Claude", "")).await; |
|
| 766 | - | assert!(r.body.contains("Claude added."), "{}", r.body); |
|
| 766 | + | assert!(r.body.contains("Claude added and answered"), "{}", r.body); |
|
| 767 | 767 | let on = assistants::enabled_for_repo(&app.db, &repo).await.unwrap(); |
|
| 768 | 768 | assert_eq!(on.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["DeepSeek"]); |
|
| 769 | 769 | let repo_page = get(app, "/acme/plans/settings", Some(&andy)).await.body; |
|
| … | 215 unchanged lines |
modified crates/hub/tests/edits.rs +5 −1
| … | 563 unchanged lines |
||
| 564 | 564 | assert_eq!(agent_change["author"]["account"], "andy"); |
|
| 565 | 565 | // Contributors: the agent's change counts for Andy, named under him. |
|
| 566 | 566 | let overview = send(app, "GET", "/andy/demo", None, None).await.body; |
|
| 567 | - | assert!(overview.contains("via Antigravity 1"), "{overview}"); |
|
| 567 | + | assert!(overview.contains("/avatar/agent/Antigravity") && overview.contains("Antigravity <span class=\"mute small\" title=\"changes signed by this agent for them\">· 1</span>"), "{overview}"); |
|
| 568 | + | assert_eq!(overview.matches("class=\"avatar sm\" src=\"/avatar/andy\"").count(), 1, "one row for Andy, whatever his keys"); |
|
| 569 | + | let mark = send(app, "GET", "/avatar/agent/Claude%20Code", None, None).await; |
|
| 570 | + | assert!(mark.body.contains("<title>Claude</title>"), "a known agent gets its brand mark"); |
|
| 571 | + | assert!(send(app, "GET", "/avatar/agent/HAL", None, None).await.body.contains("<svg"), "an unknown one its identicon"); |
|
| 568 | 572 | // The badge: no runs yet. |
|
| 569 | 573 | let badge = send(app, "GET", "/andy/demo/flow/badge.svg", None, None).await; |
|
| 570 | 574 | assert_eq!(badge.status, StatusCode::OK); |
|
| … | 220 unchanged lines |
modified crates/hub/tests/proposals.rs +2 −1
| … | 385 unchanged lines |
||
| 386 | 386 | &format!("_csrf={csrf_a}&action=add&name=Claude&endpoint={ep}&api=chat-completions&model=claude-opus-5-5&token=sk-test&enabled=1"), |
|
| 387 | 387 | ) |
|
| 388 | 388 | .await; |
|
| 389 | - | assert!(r.body.contains("Claude added."), "{}", r.body); |
|
| 389 | + | assert!(r.body.contains("Claude added and answered"), "{}", r.body); |
|
| 390 | + | seen.lock().unwrap().clear(); |
|
| 390 | 391 | let r = |
|
| 391 | 392 | post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf_a}&action=prompt&prompt=Review+for+correctness+first.")).await; |
|
| 392 | 393 | assert!(r.body.contains("Prompt saved."), "{}", r.body); |
|
| … | 90 unchanged lines |
modified templates/assistants_panel.html +2 −2
| … | 22 unchanged lines |
||
| 23 | 23 | {% if o.on() %}<span class="tag head">on</span>{% else if o.off_above %}<span class="tag dim" title="An assistant of this name is turned off at a level above">off above</span>{% else %}<span class="tag dim">off</span>{% endif %} |
|
| 24 | 24 | </div> |
|
| 25 | 25 | <p class="small mute"><code>{{ r.endpoint }}</code> · token {% if r.has_token() %}set{% else %}none{% endif %} · context {{ r.budget }} characters</p> |
|
| 26 | - | <form class="tiny" method="post" action="{{ base }}/assistants"><input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="id" value="{{ r.id }}"><button name="action" value="toggle">{% if r.enabled %}turn off{% else %}turn on{% endif %}</button> <button name="action" value="test">test</button> <button name="action" value="delete" class="danger-btn" onclick="return confirm('Delete {{ r.name }}?')">delete</button></form> |
|
| 26 | + | <form class="tiny" method="post" action="{{ base }}/assistants"><input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="id" value="{{ r.id }}"><button name="action" value="test" title="Send a one-line message and show the answer or the error">TEST CONNECTION</button> <button name="action" value="toggle">{% if r.enabled %}turn off{% else %}turn on{% endif %}</button> <button name="action" value="delete" class="danger-btn" onclick="return confirm('Delete {{ r.name }}?')">delete</button></form> |
|
| 27 | 27 | <details class="hook-edit"><summary class="small">edit</summary> |
|
| 28 | 28 | <form class="card" method="post" action="{{ base }}/assistants"> |
|
| 29 | 29 | <input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="id" value="{{ r.id }}"><input type="hidden" name="action" value="update"> |
|
| … | 25 unchanged lines |
||
| 55 | 55 | <label for="aheaders">Extra headers</label><textarea id="aheaders" name="headers" rows="2" placeholder="Name: value"></textarea> |
|
| 56 | 56 | <label for="abudget">Context budget (characters)</label><input type="text" id="abudget" name="budget" value="{{ a.default_budget }}"> |
|
| 57 | 57 | <div class="choice"><label><input type="checkbox" name="enabled" value="1" checked> on</label></div> |
|
| 58 | - | <button type="submit">ADD</button> |
|
| 58 | + | <button type="submit">ADD AND TEST</button> |
|
| 59 | 59 | </form> |
|
| 60 | 60 | <form class="card" method="post" action="{{ base }}/assistants"> |
|
| 61 | 61 | <input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="action" value="prompt"> |
|
| … | 8 unchanged lines |
modified templates/change.html +1 −1
| … | 9 unchanged lines |
||
| 10 | 10 | {{ body_html|safe }} |
|
| 11 | 11 | <dl class="facts"> |
|
| 12 | 12 | <dt>Author</dt><dd>{% if let Some(a) = c.view.author.account %}<a href="/{{ a }}">{{ c.view.author.name }}</a> <span class="tag">@{{ a }}</span>{% else %}{{ c.view.author.name }}{% endif %}{% if !c.view.author.email.is_empty() %} <span class="mute"><{{ c.view.author.email }}></span>{% endif %}{% if !c.view.author.key.is_empty() && c.view.author.account.is_none() %} <span class="tag" title="The key that signed this change is not linked to any account.">key not linked</span>{% if shell.user.is_some() %} <a class="small" href="/settings/keys">link your key</a>{% endif %}{% endif %}</dd>{% if let Some(v) = c.view.via %} |
|
| 13 | - | <dt>via</dt><dd><span class="mute" aria-hidden="true">↳</span> <span class="tag{% if v.revoked %} bad{% endif %}" title="{{ v.title() }}">agent</span> {{ v.name }}{% if !v.email.is_empty() %} <span class="mute"><{{ v.email }}></span>{% endif %} <span class="mute small">· {{ v.delegation() }} · key {{ v.short }}</span></dd>{% endif %} |
|
| 13 | + | <dt>via</dt><dd><span class="mute" aria-hidden="true">↳</span> <span class="tag{% if v.revoked %} bad{% endif %}" title="{{ v.title() }}">agent</span> <img class="avatar xs" src="/avatar/agent/{{ v.name }}" alt="" width="16" height="16"> {{ v.name }}{% if !v.email.is_empty() %} <span class="mute"><{{ v.email }}></span>{% endif %} <span class="mute small">· {{ v.delegation() }} · key {{ v.short }}</span></dd>{% endif %} |
|
| 14 | 14 | <dt>When</dt><dd><time title="{{ c.view.when_ms|when }}">{{ c.view.when_ms|ago }}</time> · {{ c.view.when_ms|when }}</dd> |
|
| 15 | 15 | <dt>Signature</dt><dd>{% call m::sig(c.view.signature) %} {{ c.view.signature.title() }}</dd> |
|
| 16 | 16 | <dt>Hash</dt><dd><code class="full">{{ c.view.hash }}</code> <button class="small" data-copy="{{ c.view.hash }}">copy</button></dd> |
|
| … | 12 unchanged lines |
modified templates/overview.html +1 −1
| … | 47 unchanged lines |
||
| 48 | 48 | {% if !o.contributors.is_empty() %} |
|
| 49 | 49 | <h3>Contributors <span class="count-chip">{{ o.contributors.len() }}</span></h3> |
|
| 50 | 50 | <ul class="plain contributors">{% for c in o.contributors.iter().take(10) %} |
|
| 51 | - | <li>{% if let Some(u) = c.account %}<a href="/{{ u }}"><img class="avatar sm" src="/avatar/{{ u }}" alt="" width="20" height="20"> {{ u }}</a> <span class="mute small">{{ c.name }}</span>{% else %}<span class="avatar-none" aria-hidden="true">○</span> {{ c.name }}{% endif %} <span class="mute small" title="changes">· {{ c.changes }}</span>{% if !c.via.is_empty() %}<br><span class="mute small">via {% for v in c.via %}{{ v.0 }} {{ v.1 }}{% if !loop.last %}, {% endif %}{% endfor %}</span>{% endif %}</li> |
|
| 51 | + | <li>{% if let Some(u) = c.account %}<a href="/{{ u }}"><img class="avatar sm" src="/avatar/{{ u }}" alt="" width="20" height="20"> {{ u }}</a> <span class="mute small">{{ c.name }}</span>{% else %}<span class="avatar-none" aria-hidden="true">○</span> {{ c.name }}{% endif %} <span class="mute small" title="changes">· {{ c.changes }}</span>{% if !c.via.is_empty() %}<ul class="plain via-list">{% for v in c.via %}<li><span class="mute" aria-hidden="true">↳</span> <span class="mute small">via</span> <img class="avatar sm" src="/avatar/agent/{{ v.0 }}" alt="" width="16" height="16"> {{ v.0 }} <span class="mute small" title="changes signed by this agent for them">· {{ v.1 }}</span></li>{% endfor %}</ul>{% endif %}</li> |
|
| 52 | 52 | {% endfor %}</ul> |
|
| 53 | 53 | {% if o.contributors.len() > 10 %}<p class="small mute">and {{ o.contributors.len() - 10 }} more</p>{% endif %} |
|
| 54 | 54 | {% endif %} |
|
| … | 20 unchanged lines |
modified templates/proposal.html +2 −2
| … | 110 unchanged lines |
||
| 111 | 111 | <div class="tl-item review-{{ r.row.verdict }}"> |
|
| 112 | 112 | <img class="tl-avatar" src="/avatar/{{ r.row.author }}" alt="" width="36" height="36"> |
|
| 113 | 113 | <div class="tl-card"> |
|
| 114 | - | <div class="tl-head"><a href="/{{ r.row.author }}"><strong>{{ r.row.author }}</strong></a>{% if !r.row.via.is_empty() %} <span class="mute" aria-hidden="true">↳</span> <span class="tag" title="An AI assistant answered for {{ r.row.author }}, who asked it">agent</span> <strong>{{ r.row.via }}</strong>{% endif %} {% if r.row.verdict == "approve" %}{% if r.sealed %}<span class="sig sig-valid">◈</span> approved and sealed{% else %}<span class="sig sig-unsigned">○</span> approved, seal pending{% endif %}{% else if r.row.verdict == "changes" %}<span class="sig sig-invalid">✘</span> requested changes{% else %}reviewed{% endif %} <time title="{{ r.row.created_ms|when_i }}">{{ r.row.created_ms|ago_i }}</time></div> |
|
| 114 | + | <div class="tl-head"><a href="/{{ r.row.author }}"><strong>{{ r.row.author }}</strong></a>{% if !r.row.via.is_empty() %} <span class="mute" aria-hidden="true">↳</span> <span class="tag" title="An AI assistant answered for {{ r.row.author }}, who asked it">agent</span> <img class="avatar xs" src="/avatar/agent/{{ r.row.via }}" alt="" width="16" height="16"> <strong>{{ r.row.via }}</strong>{% endif %} {% if r.row.verdict == "approve" %}{% if r.sealed %}<span class="sig sig-valid">◈</span> approved and sealed{% else %}<span class="sig sig-unsigned">○</span> approved, seal pending{% endif %}{% else if r.row.verdict == "changes" %}<span class="sig sig-invalid">✘</span> requested changes{% else %}reviewed{% endif %} <time title="{{ r.row.created_ms|when_i }}">{{ r.row.created_ms|ago_i }}</time></div> |
|
| 115 | 115 | {% if !r.row.body.is_empty() %}<div class="tl-text">{{ r.body_html|safe }}</div>{% endif %} |
|
| 116 | 116 | </div> |
|
| 117 | 117 | </div> |
|
| … | 86 unchanged lines |
||
| 204 | 204 | <div class="menu-foot"><button type="submit" class="small primary-btn">Apply</button></div> |
|
| 205 | 205 | </form></details>{% endif %}</div> |
|
| 206 | 206 | {% if requested.is_empty() %}<p class="side-empty">No reviews requested</p>{% else %}<ul class="side-people">{% for r in requested %}<li><a href="/{{ r }}"><img class="avatar xs" src="/avatar/{{ r }}" alt="" loading="lazy"> {{ r }}</a> <span class="side-note-inline">awaiting review</span></li>{% endfor %}</ul>{% endif %} |
|
| 207 | - | {% if !assistants.is_empty() %}<div class="side-assist">{% for a in assistants %}<form class="tiny inline" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/assistant"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="assistant" value="{{ a }}"><button type="submit" title="Ask {{ a }}, an AI assistant, to review; its answer is recorded as your review via {{ a }}"><span class="tag">agent</span> ask {{ a }}</button></form>{% endfor %}</div>{% endif %} |
|
| 207 | + | {% if !assistants.is_empty() %}<div class="side-assist">{% for a in assistants %}<form class="tiny inline" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/assistant"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="assistant" value="{{ a }}"><button type="submit" title="Ask {{ a }}, an AI assistant, to review; its answer is recorded as your review via {{ a }}"><img class="avatar xs" src="/avatar/agent/{{ a }}" alt="" width="14" height="14"> ask {{ a }}</button></form>{% endfor %}</div>{% endif %} |
|
| 208 | 208 | </section> |
|
| 209 | 209 | <section class="side-sec"> |
|
| 210 | 210 | <div class="side-head"><h3>Labels</h3> |
|
| … | 40 unchanged lines |
modified templates/style.css +3 −0
| … | 565 unchanged lines |
||
| 566 | 566 | .bar .inline-find { flex: 1 1 200px; } |
|
| 567 | 567 | ul.contributors li { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin: 4px 0; } |
|
| 568 | 568 | ul.contributors img.avatar.sm { width: 20px; height: 20px; vertical-align: middle; } |
|
| 569 | + | ul.contributors ul.via-list { flex-basis: 100%; margin: 0 0 0 26px; padding: 0; } |
|
| 570 | + | ul.contributors ul.via-list li { margin: 2px 0; } |
|
| 571 | + | ul.contributors ul.via-list img.avatar.sm { width: 16px; height: 16px; } |
|
| 569 | 572 | .avatar-none { display: inline-block; width: 20px; text-align: center; color: var(--mute); } |
|
| 570 | 573 | .lang-bar { display: flex; height: 8px; overflow: hidden; background: var(--soft); margin: 6px 0 8px; } |
|
| 571 | 574 | .lang-bar > span { display: block; height: 100%; } |
|
| … | 227 unchanged lines |