Drafts stream into the editor as the assistant writes them 119fd17d213f
A draft waited inside one request, which Cloudflare cuts after 100 s, so the hub gave up at 90 s: Claude on a large proposal never answered in time (#248). With JavaScript, Draft with NAME now posts to …/draft/stream, which answers at once with server-sent events (part, then done or error; a keep-alive every 15 s) while the draft is written in a task of its own, and the description fills as the parts come. Without JavaScript the form posts as before. Spec 13.4; the proposal test reads the stream and an unknown assistant's error. Closes #248
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-17 13:05 UTC
- Signature
- ✔ signed by Claude Code
- Hash
119fd17d213f23e85f97bae161e490a8bcd9ed22b75c1374c58063a24fa6f670- Depends on
- 3ea0b8af2d46 67be8ac00c24 7d2eb590051f 93be9ce4653e c08cec372c81 cc1cd197df1d d23464c0ac60 de505df5cf8d e71846aa07b9 ffb67a81188c
- Depended on by
- 0bc95135954d a22e4aec2e28 c43e4f631d99 8f0efa0729b0 d366306dae7d 166f6e9bf4f1 c7067c7bda56 e97500b70f9a a7aa76ac52d7
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- 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
- 20 atoms, 0 tree operations, 8 files
Browse the tree at this change
modified assets/hub.js +59 −0
| … | 760 unchanged lines |
||
| 761 | 761 | Array.prototype.forEach.call(marked, refresh); |
|
| 762 | 762 | }); |
|
| 763 | 763 | })(); |
|
| 764 | + | ||
| 765 | + | /* Drafting with an assistant (oxygenhub#248): the draft is streamed into |
|
| 766 | + | the description as it is written, so a long one is neither cut off by a |
|
| 767 | + | proxy nor waited for in silence. Without this file the button posts the |
|
| 768 | + | form and the page comes back with the draft. */ |
|
| 769 | + | (function () { |
|
| 770 | + | "use strict"; |
|
| 771 | + | if (!window.fetch || !window.TextDecoder || !window.URLSearchParams) return; |
|
| 772 | + | document.querySelectorAll("button.agent-btn[data-stream]").forEach(function (btn) { |
|
| 773 | + | btn.addEventListener("click", function (e) { |
|
| 774 | + | var form = btn.form, area = form && form.querySelector('textarea[name="body"]'); |
|
| 775 | + | if (!form || !area) return; |
|
| 776 | + | e.preventDefault(); |
|
| 777 | + | function get() { return area._mde ? area._mde.value() : area.value; } |
|
| 778 | + | function show(t) { if (area._mde) area._mde.value(t); else area.value = t; } |
|
| 779 | + | var before = get(), text = ""; |
|
| 780 | + | var box = (area._mde && area.parentNode.querySelector(".EasyMDEContainer")) || area; |
|
| 781 | + | var note = form.querySelector(".draft-note"); |
|
| 782 | + | if (!note) { note = document.createElement("p"); note.className = "draft-note small mute"; box.parentNode.insertBefore(note, box.nextSibling); } |
|
| 783 | + | note.textContent = btn.value + " is drafting the description…"; |
|
| 784 | + | var buttons = form.querySelectorAll("button.agent-btn"); |
|
| 785 | + | buttons.forEach(function (b) { b.disabled = true; }); |
|
| 786 | + | var data = new URLSearchParams(new FormData(form)); |
|
| 787 | + | data.set("assistant", btn.value); |
|
| 788 | + | var finished = false; |
|
| 789 | + | function end(message, keep) { finished = true; if (!keep) show(before); note.textContent = message; } |
|
| 790 | + | fetch(btn.dataset.stream, { method: "POST", body: data, credentials: "same-origin" }) |
|
| 791 | + | .then(function (r) { |
|
| 792 | + | if (!r.ok || !r.body) throw new Error("the hub answered " + r.status); |
|
| 793 | + | var reader = r.body.getReader(), dec = new TextDecoder(), buf = ""; |
|
| 794 | + | function pump() { |
|
| 795 | + | return reader.read().then(function (res) { |
|
| 796 | + | if (res.done) { if (!finished) end(btn.value + " stopped before finishing.", text !== ""); return; } |
|
| 797 | + | buf += dec.decode(res.value, { stream: true }); |
|
| 798 | + | var i; |
|
| 799 | + | while ((i = buf.indexOf("\n\n")) >= 0) { |
|
| 800 | + | var block = buf.slice(0, i), kind = "message", payload = ""; |
|
| 801 | + | buf = buf.slice(i + 2); |
|
| 802 | + | block.split("\n").forEach(function (l) { |
|
| 803 | + | if (l.indexOf("event:") === 0) kind = l.slice(6).trim(); |
|
| 804 | + | else if (l.indexOf("data:") === 0) payload += l.slice(5).trim(); |
|
| 805 | + | }); |
|
| 806 | + | if (!payload) continue; |
|
| 807 | + | var v; |
|
| 808 | + | try { v = JSON.parse(payload); } catch (_) { continue; } |
|
| 809 | + | if (kind === "part") { text += v; show(text); } |
|
| 810 | + | else if (kind === "done") { show(v); end("Drafted by " + btn.value + "; nothing is saved until you save the proposal.", true); return; } |
|
| 811 | + | else if (kind === "error") { end(v, false); return; } |
|
| 812 | + | } |
|
| 813 | + | return pump(); |
|
| 814 | + | }); |
|
| 815 | + | } |
|
| 816 | + | return pump(); |
|
| 817 | + | }) |
|
| 818 | + | .catch(function (err) { end(btn.value + " did not answer: " + err.message + ".", false); }) |
|
| 819 | + | .then(function () { buttons.forEach(function (b) { b.disabled = false; }); }); |
|
| 820 | + | }); |
|
| 821 | + | }); |
|
| 822 | + | })(); |
modified crates/hub/src/assistants.rs +21 −5
| … | 947 unchanged lines |
||
| 948 | 948 | let budget = usize::try_from(a.budget).unwrap_or(DEFAULT_BUDGET as usize).max(1_000); |
|
| 949 | 949 | let prompts = prompt_for_repo(&app.db, row).await.map_err(|e| e.to_string())?; |
|
| 950 | 950 | let system = review_system_prompt(a, &app.config.site.name, &prompts); |
|
| 951 | - | ask_within(app, a, &system, budget, BACKGROUND_TIMEOUT, |room| review_context(row, p, &loaded, room)).await |
|
| 951 | + | ask_within(app, a, &system, budget, BACKGROUND_TIMEOUT, |room| review_context(row, p, &loaded, room), |_| {}).await |
|
| 952 | 952 | } |
|
| 953 | 953 | ||
| 954 | 954 | /// Ask with the context cut to fit `budget` characters, system prompt |
|
| … | 7 unchanged lines |
||
| 962 | 962 | budget: usize, |
|
| 963 | 963 | wait: Duration, |
|
| 964 | 964 | context: impl Fn(usize) -> String, |
|
| 965 | + | mut part: impl FnMut(&str), |
|
| 965 | 966 | ) -> Result<String, String> { |
|
| 966 | 967 | let room = |b: usize| b.saturating_sub(system.chars().count()); |
|
| 967 | - | match ask(app, a, system, &context(room(budget)), wait).await { |
|
| 968 | + | // A refusal for length comes before any part, so asking again starts |
|
| 969 | + | // the answer afresh. |
|
| 970 | + | match ask_streaming(app, a, system, &context(room(budget)), wait, &mut part).await { |
|
| 968 | 971 | Err(e) if too_long(&e) => { |
|
| 969 | 972 | let smaller = (budget / 3).max(1_000); |
|
| 970 | 973 | tracing::info!("assistant {}: too long at {budget} characters ({e}); asking again at {smaller}", a.name); |
|
| 971 | - | ask(app, a, system, &context(room(smaller)), wait).await.map_err(|e2| { |
|
| 974 | + | ask_streaming(app, a, system, &context(room(smaller)), wait, &mut part).await.map_err(|e2| { |
|
| 972 | 975 | format!( |
|
| 973 | 976 | "the proposal was too large for {}'s model at {budget} characters and again at {smaller} ({e2}): lower this assistant's context budget", |
|
| 974 | 977 | a.name |
|
| … | 93 unchanged lines |
||
| 1068 | 1071 | let budget = usize::try_from(a.budget).unwrap_or(DEFAULT_BUDGET as usize).max(1_000); |
|
| 1069 | 1072 | let prompts = prompt_for_repo(&app.db, row).await.map_err(|e| e.to_string())?; |
|
| 1070 | 1073 | let system = summary_system_prompt(a, &app.config.site.name, &prompts); |
|
| 1071 | - | ask_within(app, a, &system, budget, BACKGROUND_TIMEOUT, |room| review_context(row, p, &loaded, room)).await |
|
| 1074 | + | ask_within(app, a, &system, budget, BACKGROUND_TIMEOUT, |room| review_context(row, p, &loaded, room), |_| {}).await |
|
| 1072 | 1075 | } |
|
| 1073 | 1076 | ||
| 1074 | 1077 | // -------------------------------------------------------- drafting |
|
| … | 29 unchanged lines |
||
| 1104 | 1107 | /// Ask `a` for a description, now; the answer goes into the editor, |
|
| 1105 | 1108 | /// nothing is saved. `Err` is for the person. |
|
| 1106 | 1109 | pub async fn draft_description( |
|
| 1110 | + | app: &Shared, |
|
| 1111 | + | row: &crate::db::repos::RepoRow, |
|
| 1112 | + | by: &crate::db::accounts::Account, |
|
| 1113 | + | a: &Assistant, |
|
| 1114 | + | d: Draft<'_>, |
|
| 1115 | + | ) -> Result<String, String> { |
|
| 1116 | + | draft_description_streaming(app, row, by, a, d, |_| {}).await |
|
| 1117 | + | } |
|
| 1118 | + | ||
| 1119 | + | /// [`draft_description`], handing each part of the draft to `part` as it |
|
| 1120 | + | /// comes, for a page that shows it being written (oxygenhub#248). |
|
| 1121 | + | pub async fn draft_description_streaming( |
|
| 1107 | 1122 | app: &Shared, |
|
| 1108 | 1123 | row: &crate::db::repos::RepoRow, |
|
| 1109 | 1124 | by: &crate::db::accounts::Account, |
|
| 1110 | 1125 | a: &Assistant, |
|
| 1111 | 1126 | d: Draft<'_>, |
|
| 1127 | + | part: impl FnMut(&str), |
|
| 1112 | 1128 | ) -> Result<String, String> { |
|
| 1113 | 1129 | let Draft { slot, title, target, changes, diffs } = d; |
|
| 1114 | 1130 | app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).take(row.id, slot, &a.name)?; |
|
| … | 3 unchanged lines |
||
| 1118 | 1134 | let outcome = match prompts { |
|
| 1119 | 1135 | Ok(prompts) => { |
|
| 1120 | 1136 | let system = draft_system_prompt(a, &app.config.site.name, &prompts); |
|
| 1121 | - | ask_within(app, a, &system, budget, CALL_TIMEOUT, |room| draft_context(row, title, target, changes, diffs, room)).await |
|
| 1137 | + | ask_within(app, a, &system, budget, CALL_TIMEOUT, |room| draft_context(row, title, target, changes, diffs, room), part).await |
|
| 1122 | 1138 | } |
|
| 1123 | 1139 | Err(e) => Err(e), |
|
| 1124 | 1140 | }; |
|
| … | 3 unchanged lines |
modified crates/hub/src/web/mod.rs +2 −0
| … | 219 unchanged lines |
||
| 220 | 220 | .route("/{owner}/{name}/proposals", get(proposals::list)) |
|
| 221 | 221 | .route("/{owner}/{name}/proposals/new", get(proposals::new_form).post(proposals::create)) |
|
| 222 | 222 | .route("/{owner}/{name}/proposals/new/draft", post(proposals::new_draft)) |
|
| 223 | + | .route("/{owner}/{name}/proposals/new/draft/stream", post(proposals::new_draft_stream)) |
|
| 223 | 224 | .route("/{owner}/{name}/proposal/{number}", get(proposals::show)) |
|
| 224 | 225 | .route("/{owner}/{name}/proposal/{number}/comment", post(proposals::comment)) |
|
| 225 | 226 | .route("/{owner}/{name}/proposal/{number}/comment/{id}/resolve", post(proposals::resolve_comment)) |
|
| … | 1 unchanged lines |
||
| 227 | 228 | .route("/{owner}/{name}/proposal/{number}/reviewers", post(proposals::reviewers)) |
|
| 228 | 229 | .route("/{owner}/{name}/proposal/{number}/assistant", post(proposals::assistant)) |
|
| 229 | 230 | .route("/{owner}/{name}/proposal/{number}/draft", post(proposals::draft)) |
|
| 231 | + | .route("/{owner}/{name}/proposal/{number}/draft/stream", post(proposals::draft_stream)) |
|
| 230 | 232 | .route("/{owner}/{name}/proposal/{number}/edit", post(proposals::edit)) |
|
| 231 | 233 | .route("/{owner}/{name}/proposal/{number}/state", post(proposals::state)) |
|
| 232 | 234 | .route("/{owner}/{name}/proposal/{number}/land", post(proposals::land)) |
|
| … | 556 unchanged lines |
modified crates/hub/src/web/proposals.rs +135 −0
| … | 1759 unchanged lines |
||
| 1760 | 1760 | Err(why) => Ok(back(&owner, &name, number, "conversation", None, Some(format!("{} did not answer: {why}.", a.name)))), |
|
| 1761 | 1761 | } |
|
| 1762 | 1762 | } |
|
| 1763 | + | ||
| 1764 | + | /// A draft being written, as server-sent events for the page: `part` with |
|
| 1765 | + | /// each piece of text, then `done` with the whole draft or `error` with |
|
| 1766 | + | /// why there is none (each a JSON string). The response starts at once, so |
|
| 1767 | + | /// a draft that takes minutes is not cut off by a proxy waiting for it |
|
| 1768 | + | /// (oxygenhub#248). |
|
| 1769 | + | type DraftEvents = axum::response::Sse< |
|
| 1770 | + | axum::response::sse::KeepAliveStream< |
|
| 1771 | + | tokio_stream::wrappers::ReceiverStream<Result<axum::response::sse::Event, std::convert::Infallible>>, |
|
| 1772 | + | >, |
|
| 1773 | + | >; |
|
| 1774 | + | ||
| 1775 | + | fn draft_events(rx: tokio::sync::mpsc::Receiver<Result<axum::response::sse::Event, std::convert::Infallible>>) -> DraftEvents { |
|
| 1776 | + | axum::response::Sse::new(tokio_stream::wrappers::ReceiverStream::new(rx)) |
|
| 1777 | + | .keep_alive(axum::response::sse::KeepAlive::new().interval(std::time::Duration::from_secs(15))) |
|
| 1778 | + | } |
|
| 1779 | + | ||
| 1780 | + | fn draft_event(kind: &str, text: &str) -> Result<axum::response::sse::Event, std::convert::Infallible> { |
|
| 1781 | + | Ok(axum::response::sse::Event::default().event(kind).data(serde_json::to_string(text).unwrap_or_default())) |
|
| 1782 | + | } |
|
| 1783 | + | ||
| 1784 | + | /// Only an error, for a draft that cannot start. |
|
| 1785 | + | fn draft_refused(why: String) -> DraftEvents { |
|
| 1786 | + | let (tx, rx) = tokio::sync::mpsc::channel(1); |
|
| 1787 | + | let _ = tx.try_send(draft_event("error", &why)); |
|
| 1788 | + | draft_events(rx) |
|
| 1789 | + | } |
|
| 1790 | + | ||
| 1791 | + | /// What a draft is written from, owned so a task of its own can have it. |
|
| 1792 | + | struct DraftJob { |
|
| 1793 | + | row: db::repos::RepoRow, |
|
| 1794 | + | by: accounts::Account, |
|
| 1795 | + | a: crate::assistants::Assistant, |
|
| 1796 | + | slot: i64, |
|
| 1797 | + | title: String, |
|
| 1798 | + | target: String, |
|
| 1799 | + | changes: Vec<ChangeView>, |
|
| 1800 | + | diffs: Vec<oxy_core::repo::FileDiff>, |
|
| 1801 | + | } |
|
| 1802 | + | ||
| 1803 | + | /// Write the draft in a task of its own, its parts to the page. |
|
| 1804 | + | fn draft_in_background(app: Shared, job: DraftJob) -> DraftEvents { |
|
| 1805 | + | let (tx, rx) = tokio::sync::mpsc::channel(64); |
|
| 1806 | + | tokio::spawn(async move { |
|
| 1807 | + | let DraftJob { row, by, a, slot, title, target, changes, diffs } = job; |
|
| 1808 | + | let d = crate::assistants::Draft { slot, title: &title, target: &target, changes: &changes, diffs: &diffs }; |
|
| 1809 | + | let parts = tx.clone(); |
|
| 1810 | + | let result = crate::assistants::draft_description_streaming(&app, &row, &by, &a, d, move |p: &str| { |
|
| 1811 | + | // A page that went away stops nothing: the draft finishes. |
|
| 1812 | + | let _ = parts.try_send(draft_event("part", p)); |
|
| 1813 | + | }) |
|
| 1814 | + | .await; |
|
| 1815 | + | let last = match result { |
|
| 1816 | + | Ok(text) => draft_event("done", text.trim()), |
|
| 1817 | + | Err(why) => draft_event("error", &format!("{} did not answer: {why}.", a.name)), |
|
| 1818 | + | }; |
|
| 1819 | + | let _ = tx.send(last).await; |
|
| 1820 | + | }); |
|
| 1821 | + | draft_events(rx) |
|
| 1822 | + | } |
|
| 1823 | + | ||
| 1824 | + | /// `POST /{owner}/{name}/proposal/{number}/draft/stream`: [`draft`], with |
|
| 1825 | + | /// the draft streamed to the page as it is written. |
|
| 1826 | + | pub async fn draft_stream( |
|
| 1827 | + | State(app): State<Shared>, |
|
| 1828 | + | viewer: Viewer, |
|
| 1829 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 1830 | + | Form(f): Form<DraftForm>, |
|
| 1831 | + | ) -> Result<DraftEvents, Error> { |
|
| 1832 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); |
|
| 1833 | + | let me = require(&viewer, &shell)?; |
|
| 1834 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 1835 | + | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 1836 | + | super::writable(&row, &shell)?; |
|
| 1837 | + | let p = pdb::get(&app.db, row.id, number) |
|
| 1838 | + | .await |
|
| 1839 | + | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 1840 | + | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 1841 | + | if Some(me.account.id) != p.author_id && role < Role::Maintain { |
|
| 1842 | + | return Err(Error::not_found(shell)); |
|
| 1843 | + | } |
|
| 1844 | + | let Some(a) = crate::assistants::enabled_for_repo(&app.db, &row) |
|
| 1845 | + | .await |
|
| 1846 | + | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 1847 | + | .into_iter() |
|
| 1848 | + | .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim())) |
|
| 1849 | + | else { |
|
| 1850 | + | return Ok(draft_refused(format!("No assistant called {} here.", f.assistant.trim()))); |
|
| 1851 | + | }; |
|
| 1852 | + | let loaded = load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 1853 | + | let title = if f.title.trim().is_empty() { p.title.clone() } else { f.title.clone() }; |
|
| 1854 | + | let job = DraftJob { |
|
| 1855 | + | row, |
|
| 1856 | + | by: me.account.clone(), |
|
| 1857 | + | a, |
|
| 1858 | + | slot: p.id, |
|
| 1859 | + | title, |
|
| 1860 | + | target: p.target.clone(), |
|
| 1861 | + | changes: loaded.changes, |
|
| 1862 | + | diffs: loaded.diffs, |
|
| 1863 | + | }; |
|
| 1864 | + | Ok(draft_in_background(app.clone(), job)) |
|
| 1865 | + | } |
|
| 1866 | + | ||
| 1867 | + | /// `POST /{owner}/{name}/proposals/new/draft/stream`: [`new_draft`], with |
|
| 1868 | + | /// the draft streamed to the page as it is written. |
|
| 1869 | + | pub async fn new_draft_stream( |
|
| 1870 | + | State(app): State<Shared>, |
|
| 1871 | + | viewer: Viewer, |
|
| 1872 | + | Path((owner, name)): Path<(String, String)>, |
|
| 1873 | + | Form(f): Form<NewForm>, |
|
| 1874 | + | ) -> Result<DraftEvents, Error> { |
|
| 1875 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer); |
|
| 1876 | + | let me = require(&viewer, &shell)?; |
|
| 1877 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 1878 | + | let (repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 1879 | + | super::writable(&row, &shell)?; |
|
| 1880 | + | let Some(a) = crate::assistants::enabled_for_repo(&app.db, &row) |
|
| 1881 | + | .await |
|
| 1882 | + | .unwrap_or_default() |
|
| 1883 | + | .into_iter() |
|
| 1884 | + | .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim())) |
|
| 1885 | + | else { |
|
| 1886 | + | return Ok(draft_refused(format!("No assistant called {} here.", f.assistant.trim()))); |
|
| 1887 | + | }; |
|
| 1888 | + | let (changes, diffs) = match preview_diff(&app, &repo, &f.target, &f.source).await { |
|
| 1889 | + | Ok(x) => x, |
|
| 1890 | + | Err(e) => return Ok(draft_refused(e.to_string())), |
|
| 1891 | + | }; |
|
| 1892 | + | if changes.is_empty() { |
|
| 1893 | + | return Ok(draft_refused("Nothing to describe: the source adds nothing to the target.".into())); |
|
| 1894 | + | } |
|
| 1895 | + | let job = DraftJob { slot: -row.id, row, by: me.account.clone(), a, title: f.title.clone(), target: f.target.clone(), changes, diffs }; |
|
| 1896 | + | Ok(draft_in_background(app.clone(), job)) |
|
| 1897 | + | } |
modified crates/hub/tests/proposals.rs +14 −0
| … | 430 unchanged lines |
||
| 431 | 431 | assert_eq!(seen.lock().unwrap().len(), 2); |
|
| 432 | 432 | seen.lock().unwrap().clear(); |
|
| 433 | 433 | ||
| 434 | + | // The same as a stream for the page to show as it comes (#248): the |
|
| 435 | + | // button says where, and the draft arrives in parts, then whole. |
|
| 436 | + | let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body; |
|
| 437 | + | assert!(page.contains("data-stream=\"/andy/demo/proposal/1/draft/stream\""), "{page}"); |
|
| 438 | + | let form = format!("_csrf={}&assistant=Claude&title=Add+three", csrf_of(&page)); |
|
| 439 | + | let r = post(app, "/andy/demo/proposal/1/draft/stream", Some(&bob), &form).await; |
|
| 440 | + | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| 441 | + | assert!(r.body.contains("event: part") && r.body.contains("event: done"), "{}", r.body); |
|
| 442 | + | assert!(r.body.contains("Adds a third line."), "{}", r.body); |
|
| 443 | + | assert_eq!(db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap().body, "Please", "still nothing saved"); |
|
| 444 | + | let r = post(app, "/andy/demo/proposal/1/draft/stream", Some(&bob), &form.replace("Claude", "Nobody")).await; |
|
| 445 | + | assert!(r.body.contains("event: error") && r.body.contains("No assistant called Nobody"), "{}", r.body); |
|
| 446 | + | seen.lock().unwrap().clear(); |
|
| 447 | + | ||
| 434 | 448 | // Andy approves; then Bob, the author, asks Claude. |
|
| 435 | 449 | let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await.body; |
|
| 436 | 450 | let r = post(app, "/andy/demo/proposal/1/review", Some(&andy), &format!("_csrf={}&verdict=approve&body=good", csrf_of(&page))).await; |
|
| … | 377 unchanged lines |
modified docs/spec.md +1 −1
| … | 569 unchanged lines |
||
| 570 | 570 | - **Adding one** (oxygenhub#109). The form offers providers (Anthropic, OpenAI, Google Gemini, DeepSeek, Mistral, Perplexity, Ollama) that fill the name, endpoint and API shape, and *Other* for an endpoint typed by hand. **List models** asks the endpoint's own list (`…/models` beside `…/chat/completions` or `…/messages`, with the token typed or, when editing, the one stored) and offers the chat models in the Model field; on save, a model the endpoint lists none of is refused with some it does offer. An endpoint without a list takes any name. |
|
| 571 | 571 | - **Secrets.** Tokens are sealed at rest with ChaCha20-Poly1305 under `keys/assistants.key` (mode 0600, made at first start beside the cookie key; each token carries its own random nonce), shown only as set or none, and never returned by the API. An empty token suits endpoints that need none. Each settings panel has TEST, a one-line exchange whose answer or error is shown. Each level's changes are in the audit log (`assistant.added`, `.updated`, `.enabled`, `.disabled`, `.deleted`, `.prompt`). |
|
| 572 | 572 | - **Request review.** Under Reviewers on the proposal page, the author or a writer sees an **ask NAME** button per assistant the repository may use. Asking records the event (`asked NAME to review`) and the audit entry (`assistant.asked`), then a background task builds the context (title, description, the list of changes, the diff of each changed file as `-`/`+` lines per hunk, binary files noted, the whole cut to the assistant's context budget less the prompt), prefixes a fixed system prompt (who it is, that it answers for the person who asked, that it cannot approve, the shape of a review) with the levels' prompts, calls the endpoint, and records the answer as a review of verdict `comment` whose author is the person who asked and whose **via** (migration 0036) is the assistant, shown as "bob ↳ agent Claude reviewed" in the timeline, exactly as agent-signed changes are shown (§6). An assistant never approves: approvals count only people, and an assistant's review never replaces the asker's own latest verdict. The proposal's author is told as for any review; the `review` webhook carries `via`. No answer leaves an event (`NAME did not answer: why`) and no review. |
|
| 573 | - | - **Draft the description.** The new-proposal form (anyone who may open one) and an open proposal's editor (the author or a maintainer) have a **draft with NAME** button per assistant. It asks the assistant, now, for a description from the title so far, the changes (titles and the first lines of their messages) and the diff, under a fixed system prompt (description field only, what and why, what to look at, how to verify) plus the levels' prompts, and puts the answer in the editor with a note; nothing is saved until the person updates or opens the proposal. The audit log records `assistant.drafted`; the same per-repository hourly limit applies. |
|
| 573 | + | - **Draft the description.** The new-proposal form (anyone who may open one) and an open proposal's editor (the author or a maintainer) have a **draft with NAME** button per assistant. It asks the assistant, now, for a description from the title so far, the changes (titles and the first lines of their messages) and the diff, under a fixed system prompt (description field only, what and why, what to look at, how to verify) plus the levels' prompts, and puts the answer in the editor with a note; nothing is saved until the person updates or opens the proposal. The audit log records `assistant.drafted`; the same per-repository hourly limit applies. With JavaScript the button posts to `…/draft/stream` instead, which answers at once with server-sent events (`part` with each piece of text as the assistant writes it, then `done` with the whole draft or `error` with why there is none, each a JSON string, and a keep-alive every 15 s), and the editor fills as the draft comes; without it the form posts and the page returns with the draft. Every assistant call is made as a stream, and its time limit is for silence (90 s for a draft, 300 s for a review) under a 15-minute cap, so a long answer that keeps coming is not cut off (oxygenhub#248). |
|
| 574 | 574 | - **Limits.** One request per assistant per proposal at a time, 30 assistant calls per repository per hour (in memory), a 60-second call timeout, answers kept to 64 KiB, and a context budget (characters) per assistant. |
|
| 575 | 575 | - **Too much for the model** (oxygenhub#248). The context lists every changed file with its +/− counts before the diff, then gives whole files in order while they fit: one that doesn't is cut where the room ends (marked) when a fair amount is left, else left out, and every file cut or left out is named, so the model knows what it did not see. A provider that refuses the prompt as too long for its model (`400`/`413`/`422` saying so) is asked once more with a third of the budget; if that fails too, the event says the proposal was too large at both sizes and to lower the assistant's budget. A timeout says the same about slow or local models. A local Ollama cuts a prompt longer than its context window without saying so: set its context (`OLLAMA_CONTEXT_LENGTH`, or the model's `num_ctx`) to fit the assistant's budget, about four characters a token. |
|
| 576 | 576 | - **API.** `POST /repos/{o}/{n}/proposals/{n}/review-requests {"assistant": NAME}` (the author or a writer; 202 when started, 409 when that assistant is already answering or the repository is over its hourly limit, 422 for a name the repository cannot use); reviews carry `via`. Settings: `GET`/`POST /repos/{o}/{n}/assistants` (repo:admin; `POST` adds or changes by name, with `preset`, an optional write-only `token` that an empty value keeps, and the model checked against the endpoint's list), `DELETE …/assistants/{name}`, and `GET`/`PUT …/assistants/prompt` (the level's text and what it inherits); the same under `/orgs/{org}/assistants` for its owners and `/admin/assistants` for site administrators. `oh assistant list|add|remove|prompt` uses them, with `--org` or `--site`, and reads a token only from an environment variable (`--token-env`). Hub actions by an assistant are attributed "ASSISTANT via WHO" everywhere, so a project can require that a human has approved regardless of what an assistant said. |
|
| … | 184 unchanged lines |
modified templates/proposal.html +1 −1
| … | 94 unchanged lines |
||
| 95 | 95 | <div class="edit-row"><label for="etarget">Into bookmark</label><input type="text" id="etarget" name="target" value="{{ p.target }}"></div> |
|
| 96 | 96 | <textarea id="ebody" name="body" class="md" rows="10" aria-label="Description (Markdown)">{% if let Some(d) = draft %}{{ d }}{% else %}{{ p.body }}{% endif %}</textarea> |
|
| 97 | 97 | {% if draft.is_some() %}<p class="small mute">Drafted by an assistant; nothing is saved until you update the proposal.</p>{% endif %} |
|
| 98 | - | <div class="tl-actions"><a class="go" href="#description">Cancel</a> {% for a in assistants %}<button type="submit" class="agent-btn" formaction="{{ repo.url }}/proposal/{{ p.number }}/draft" formnovalidate name="assistant" value="{{ a }}" title="{{ a }}, an AI assistant, drafts the description from the changes; you edit it before saving"><img src="/avatar/agent/{{ a }}" alt="" width="16" height="16"> Draft with {{ a }}</button> {% endfor %}<button type="submit" class="primary-btn">Update proposal</button></div> |
|
| 98 | + | <div class="tl-actions"><a class="go" href="#description">Cancel</a> {% for a in assistants %}<button type="submit" class="agent-btn" formaction="{{ repo.url }}/proposal/{{ p.number }}/draft" data-stream="{{ repo.url }}/proposal/{{ p.number }}/draft/stream" formnovalidate name="assistant" value="{{ a }}" title="{{ a }}, an AI assistant, drafts the description from the changes; you edit it before saving"><img src="/avatar/agent/{{ a }}" alt="" width="16" height="16"> Draft with {{ a }}</button> {% endfor %}<button type="submit" class="primary-btn">Update proposal</button></div> |
|
| 99 | 99 | </form> |
|
| 100 | 100 | {% endif %} |
|
| 101 | 101 | </div> |
|
| … | 152 unchanged lines |
modified templates/proposal_new.html +1 −1
| … | 24 unchanged lines |
||
| 25 | 25 | <input type="hidden" name="target" value="{{ target }}"> |
|
| 26 | 26 | <label for="title">Title</label><input type="text" id="title" name="title" required value="{% if !title.is_empty() %}{{ title }}{% else if preview.len() == 1 %}{{ preview[0].title }}{% endif %}"> |
|
| 27 | 27 | <label for="body">Description (Markdown)</label><textarea id="body" name="body" class="md" rows="8">{{ body }}</textarea> |
|
| 28 | - | {% if !assistants.is_empty() %}<div class="side-assist">{% for a in assistants %}<button type="submit" class="agent-btn" formaction="{{ repo.url }}/proposals/new/draft" formnovalidate name="assistant" value="{{ a }}" title="{{ a }}, an AI assistant, drafts the description from the changes; you edit it before opening"><img src="/avatar/agent/{{ a }}" alt="" width="16" height="16"> Draft with {{ a }}</button>{% endfor %}</div>{% endif %} |
|
| 28 | + | {% if !assistants.is_empty() %}<div class="side-assist">{% for a in assistants %}<button type="submit" class="agent-btn" formaction="{{ repo.url }}/proposals/new/draft" data-stream="{{ repo.url }}/proposals/new/draft/stream" formnovalidate name="assistant" value="{{ a }}" title="{{ a }}, an AI assistant, drafts the description from the changes; you edit it before opening"><img src="/avatar/agent/{{ a }}" alt="" width="16" height="16"> Draft with {{ a }}</button>{% endfor %}</div>{% endif %} |
|
| 29 | 29 | <div class="choice"><label><input type="checkbox" name="draft" value="1"> draft: not ready for review</label></div> |
|
| 30 | 30 | <button type="submit">OPEN PROPOSAL</button> |
|
| 31 | 31 | </form> |
|
| … | 4 unchanged lines |