Compare commits

..
2 Commits
Author SHA1 Message Date
Miguel Palhas 1e30b9ade1 Merge remote-tracking branch 'origin/main' into timbre
ci / rust (push) Successful in 8m43s
ci / web (push) Failing after 7s
ci / flake (push) Successful in 4m21s
2026-09-07 10:24:22 +01:00
Miguel Palhas 4df8fdb7dc feat(maestro): attach files in structured views 2026-09-07 10:20:08 +01:00
10 changed files with 363 additions and 9 deletions
+46 -1
View File
@@ -12,7 +12,8 @@
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::body::Bytes;
use axum::extract::{DefaultBodyLimit, Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{any, delete, get, post};
@@ -70,6 +71,10 @@ pub fn router(state: LocalApiState) -> Router {
.route(routes::patterns::STRUCTURED, any(structured_ws))
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
.route(
routes::patterns::STRUCTURED_ATTACHMENT,
post(structured_attachment).layer(DefaultBodyLimit::max(20 * 1024 * 1024)),
)
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
.route(routes::patterns::STRUCTURED_CONFIG, post(structured_config))
.route(
@@ -432,6 +437,46 @@ async fn structured_replay(
}
}
#[derive(Deserialize)]
struct AttachmentQuery {
name: String,
}
async fn structured_attachment(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Query(query): Query<AttachmentQuery>,
bytes: Bytes,
) -> Response {
let name = query.name;
if name.is_empty()
|| name.len() > 180
|| name == "."
|| name == ".."
|| name
.chars()
.any(|c| c.is_control() || c == '/' || c == '\\')
{
return error_response(
StatusCode::BAD_REQUEST,
"invalid_filename",
"use a filename without directory separators, up to 180 bytes",
);
}
match s.manager.store_acp_attachment(id, &name, &bytes).await {
Ok(Some(path)) => Json(serde_json::json!({ "path": path })).into_response(),
Ok(None) => error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
),
Err(e) => {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
}
}
}
async fn structured_prompt(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
+30
View File
@@ -604,6 +604,36 @@ impl SessionManager {
Ok(Some(replay))
}
/// Store attachment bytes for a live structured session.
pub async fn store_acp_attachment(
&self,
id: Uuid,
name: &str,
bytes: &[u8],
) -> Result<Option<std::path::PathBuf>, SessionError> {
{
let sessions = self.inner.sessions.lock().await;
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
if !record.is_live_structured() {
return Ok(None);
}
}
let dir = self.inner.sidecar.dir().join(format!("{id}.attachments"));
tokio::fs::create_dir_all(&dir).await?;
let dir = tokio::fs::canonicalize(dir).await?;
let path = dir.join(format!("{}-{name}", Uuid::new_v4()));
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)
.await?;
use tokio::io::AsyncWriteExt;
file.write_all(bytes).await?;
file.sync_all().await?;
Ok(Some(path))
}
/// Subscribe to the two projections of a structured session's ACP log.
///
/// The transcript snapshot and whole reduced state are folded once by the
+54
View File
@@ -234,6 +234,60 @@ async fn structured_ws_sends_snapshot_then_live_projection() {
);
}
#[tokio::test]
async fn structured_attachments_preserve_bytes_and_reject_paths() {
let h = spawn("attachments").await;
let fake = FakeAcp::new();
let id = structured_session(&h, &fake).await;
let client = reqwest::Client::new();
let endpoint = url(
h.addr,
&format!("/api/sessions/{id}/structured/attachments"),
);
let bytes = vec![0, 255, 10, 13];
let response = client
.post(&endpoint)
.query(&[("name", "sample.bin")])
.body(bytes.clone())
.send()
.await
.unwrap();
assert_eq!(response.status(), 200);
let body: serde_json::Value = response.json().await.unwrap();
let path = body["path"].as_str().unwrap();
assert!(Path::new(path).is_absolute());
assert_eq!(std::fs::read(path).unwrap(), bytes);
for name in ["../escape", "a/b", "a\\b", "..", ""] {
let response = client
.post(&endpoint)
.query(&[("name", name)])
.body("text")
.send()
.await
.unwrap();
assert_eq!(response.status(), 400, "{name}");
}
let response = client
.post(&endpoint)
.query(&[("name", "large.bin")])
.body(vec![0; 20 * 1024 * 1024 + 1])
.send()
.await
.unwrap();
assert_eq!(response.status(), 413);
let response = client
.post(url(
h.addr,
&format!("/api/sessions/{}/structured/attachments", Uuid::new_v4()),
))
.query(&[("name", "file.txt")])
.body("text")
.send()
.await
.unwrap();
assert_eq!(response.status(), 404);
}
#[tokio::test]
async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() {
let h = spawn("naps-laptop").await;
+6
View File
@@ -86,6 +86,11 @@ pub fn structured_prompt(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/prompt")
}
/// `POST /api/sessions/:id/structured/attachments` — upload a file.
pub fn structured_attachment(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/attachments")
}
/// `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn.
pub fn structured_cancel(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/cancel")
@@ -113,6 +118,7 @@ pub mod patterns {
pub const STRUCTURED: &str = "/api/structured/:id";
pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay";
pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt";
pub const STRUCTURED_ATTACHMENT: &str = "/api/sessions/:id/structured/attachments";
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
+20
View File
@@ -318,6 +318,26 @@ pending decision, entirely on the daemon side:
## Prompt and cancel: bounded liveness
The web composer accepts up to ten files through its attachment picker, file
drop, or clipboard. Pasted text of at least 4,000 characters or 40 lines becomes
a UTF-8 `.txt` attachment. Shorter pastes stay in the composer. Attachments can
be removed before sending; failed sends retain the draft and attachments.
Pastes also stay inline when all ten attachment slots are occupied or the text
exceeds the 20 MiB file limit.
On send, `POST /api/sessions/:id/structured/attachments?name=<filename>` uploads
each file as raw bytes (20 MiB maximum) and returns its absolute `path`.
Only live structured sessions accept uploads. Filenames are limited to 180
bytes and cannot contain directory separators or control characters. Files get unique names and private
file permissions in `<id>.attachments` beside the session sidecar. They persist
across daemon restarts; removing a composer chip does not delete uploaded data.
There is currently no automatic attachment cleanup.
The prompt includes each filename and host path so the adapter can read it
through its own file tools, including images when the provider supports them.
These references are persisted in the ordinary user transcript row. Attachment
bytes are not embedded in ACP messages or transcript history.
`POST /api/sessions/:id/structured/prompt` and `.../cancel` are serialized
against each other through one per-session `operation` lock
(`AcpEventStream::operation`), because ACP permission callbacks carry no
+9
View File
@@ -184,6 +184,15 @@ export async function sendStructuredPrompt(id: Uuid, prompt: string): Promise<vo
);
}
export async function uploadStructuredFile(id: Uuid, file: File): Promise<{ path: string }> {
return jsonOrError(await fetch(`${routes.structuredAttachment(id)}?name=${encodeURIComponent(file.name)}`, {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/octet-stream" },
body: file,
}));
}
/** Cancel a structured session's active ACP turn (#138). */
export async function cancelStructuredTurn(id: Uuid): Promise<void> {
await jsonOrError(
+4
View File
@@ -47,6 +47,10 @@ export function structuredPrompt(id: Uuid): string {
return `${session(id)}/structured/prompt`;
}
export function structuredAttachment(id: Uuid): string {
return `${session(id)}/structured/attachments`;
}
/** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */
export function structuredCancel(id: Uuid): string {
return `${session(id)}/structured/cancel`;
+49
View File
@@ -6,6 +6,7 @@ vi.mock("./api.js", async () => {
return {
...actual,
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
uploadStructuredFile: vi.fn().mockResolvedValue({ path: "/uploads/pasted.txt" }),
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
setStructuredConfig: vi.fn().mockResolvedValue(undefined),
@@ -16,6 +17,7 @@ import {
cancelStructuredTurn,
resolveStructuredApproval,
sendStructuredPrompt,
uploadStructuredFile,
setStructuredConfig,
} from "./api.js";
import { TOGGLE_DRAWER_EVENT } from "./drawer.js";
@@ -95,6 +97,53 @@ function mount(opts: {
describe("structured connection: data flow", () => {
beforeEach(() => vi.clearAllMocks());
it("converts long pasted text into a removable file and sends it without reply text", async () => {
const conn = mount({ sockets: [new FakeSocket()] });
const input = conn.el.querySelector("textarea")!;
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: {
files: [], getData: () => "a".repeat(4000),
} });
input.dispatchEvent(paste);
expect(paste.defaultPrevented).toBe(true);
expect(input.value).toBe("");
expect(conn.el.querySelector(".structured-attachments")!.textContent).toContain("pasted-1.txt");
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
await vi.waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", expect.stringContaining("/uploads/pasted.txt")));
expect(uploadStructuredFile).toHaveBeenCalledWith("s1", expect.objectContaining({ name: "pasted-1.txt", size: 4000 }));
conn.dispose();
});
it("leaves short pastes alone", () => {
const conn = mount({ sockets: [new FakeSocket()] });
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: { files: [], getData: () => "hello" } });
conn.el.querySelector("textarea")!.dispatchEvent(paste);
expect(paste.defaultPrevented).toBe(false);
conn.dispose();
});
it("retains failed uploads for retry and lets the operator remove them", async () => {
vi.mocked(uploadStructuredFile).mockRejectedValueOnce(new Error("Upload failed"));
const conn = mount({ sockets: [new FakeSocket()] });
const input = conn.el.querySelector("textarea")!;
input.value = "Read this";
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: {
files: [new File(["content"], "notes.txt")], getData: () => "",
} });
input.dispatchEvent(paste);
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
await vi.waitFor(() => expect(input.disabled).toBe(false));
expect(input.value).toBe("Read this");
expect(sendStructuredPrompt).not.toHaveBeenCalled();
const remove = conn.el.querySelector<HTMLButtonElement>('[aria-label="Remove notes.txt"]')!;
expect(remove).not.toBeNull();
remove.click();
expect(conn.el.querySelector<HTMLElement>(".structured-attachments")!.hidden).toBe(true);
conn.dispose();
});
it("renders every row from the initial snapshot", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
+106 -8
View File
@@ -10,6 +10,7 @@ import {
cancelStructuredTurn,
resolveStructuredApproval,
sendStructuredPrompt,
uploadStructuredFile,
setStructuredConfig,
structuredWsUrl,
} from "./api.js";
@@ -90,7 +91,12 @@ export function createStructuredConnection(
<span class="structured-activity-text">Thinking</span>
</div>
<form class="structured-composer">
<button type="button" class="btn btn-ghost btn-icon structured-attach" aria-label="Attach files" title="Attach files (up to 20 MiB each)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="m8 12 7-7a3 3 0 0 1 4 4L9 19a5 5 0 0 1-7-7L12 2M5 14l9-9"/></svg>
</button>
<input class="structured-file-input" type="file" multiple hidden>
<div class="structured-input-wrap">
<div class="structured-attachments" aria-label="Attachments" aria-live="polite" hidden></div>
<div id="structured-command-menu-${sessionId}" class="structured-command-menu"
role="listbox" hidden></div>
<textarea class="structured-input" rows="1" placeholder="Reply, or / for commands…"
@@ -128,6 +134,79 @@ export function createStructuredConnection(
const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
const attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement;
const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement;
const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement;
type Attachment = { file: File; path?: string };
let attachments: Attachment[] = [];
let uploading = false;
let pasteCount = 0;
function renderAttachments(): void {
attachmentEl.replaceChildren();
attachmentEl.hidden = attachments.length === 0;
for (const attachment of attachments) {
const chip = document.createElement("span");
chip.className = "structured-attachment";
const label = document.createElement("span");
label.textContent = attachment.file.name;
label.title = `${attachment.file.name} (${attachment.file.size.toLocaleString()} bytes)`;
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "×";
remove.setAttribute("aria-label", `Remove ${attachment.file.name}`);
remove.disabled = input.disabled;
remove.addEventListener("click", () => {
attachments = attachments.filter((item) => item !== attachment);
renderAttachments();
});
chip.append(label, remove);
attachmentEl.append(chip);
}
}
function addFiles(files: File[]): void {
if (input.disabled || disposed) return;
for (const file of files) {
if (file.size > 20 * 1024 * 1024) {
showError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
} else if (attachments.length >= 10) {
showError("Attach up to 10 files per message.");
} else {
attachments.push({ file });
}
}
renderAttachments();
}
attachBtn.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => {
addFiles(Array.from(fileInput.files ?? []));
fileInput.value = "";
});
composerForm.addEventListener("dragover", (event) => {
if (event.dataTransfer?.types.includes("Files")) event.preventDefault();
});
composerForm.addEventListener("drop", (event) => {
if (!event.dataTransfer?.files.length) return;
event.preventDefault();
addFiles(Array.from(event.dataTransfer.files));
});
input.addEventListener("paste", (event) => {
const clipboard = event.clipboardData;
if (!clipboard || input.disabled) return;
if (clipboard.files.length) {
event.preventDefault();
addFiles(Array.from(clipboard.files));
return;
}
const text = clipboard.getData("text/plain");
if (text.length < 4000 && text.split("\n").length < 40) return;
if (attachments.length >= 10 || new Blob([text]).size > 20 * 1024 * 1024) return;
event.preventDefault();
addFiles([new File([text], `pasted-${++pasteCount}.txt`, { type: "text/plain" })]);
input.setRangeText("", input.selectionStart, input.selectionEnd, "end");
autoResize();
});
disconnectBtn.addEventListener("click", () => opts.onRequestClose?.(sessionId));
// Mobile-only (CSS hides it on desktop, where the sidebar is always in
@@ -527,9 +606,12 @@ export function createStructuredConnection(
}
function renderComposerState(): void {
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
const active = uploading || state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
const projectedActive = state.turn.responding || state.turn.thinking;
input.disabled = active;
attachBtn.disabled = active;
fileInput.disabled = active;
renderAttachments();
sendBtn.disabled = active;
sendBtn.hidden = projectedActive;
cancelBtn.hidden = !projectedActive;
@@ -797,7 +879,9 @@ export function createStructuredConnection(
composerForm.addEventListener("submit", (e) => {
e.preventDefault();
const text = input.value;
if (!text.trim() || input.disabled) return;
if ((!text.trim() && attachments.length === 0) || input.disabled) return;
const submittedAttachments = attachments;
uploading = submittedAttachments.length > 0;
const generation = ++submissionGeneration;
const submissionSocketGeneration = socketGeneration;
awaitingBeginGeneration = generation;
@@ -807,13 +891,26 @@ export function createStructuredConnection(
autoResize();
clearError();
renderComposerState();
sendStructuredPrompt(sessionId, text).then(
const send = async () => {
for (const attachment of submittedAttachments) {
if (disposed) return;
if (!attachment.path) attachment.path = (await uploadStructuredFile(sessionId, attachment.file)).path;
}
if (disposed) return;
const references = submittedAttachments.map(({ file, path }) =>
`Attached file ${JSON.stringify(file.name)}: ${JSON.stringify(path)}`);
await sendStructuredPrompt(sessionId, [text, ...references].filter(Boolean).join("\n\n"));
};
void send().then(
() => {
if (
disposed
|| generation !== submissionGeneration
|| awaitingBeginGeneration !== generation
) return;
if (disposed || generation !== submissionGeneration) return;
uploading = false;
if (attachments === submittedAttachments) {
attachments = [];
renderAttachments();
}
renderComposerState();
if (awaitingBeginGeneration !== generation) return;
const acceptanceSocketGeneration = socketGeneration;
acceptedSubmission = { generation, socketGeneration: acceptanceSocketGeneration };
// A replacement socket may have captured idle before the POST claimed
@@ -821,6 +918,7 @@ export function createStructuredConnection(
if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation();
},
(e: unknown) => {
uploading = false;
if (disposed || generation !== submissionGeneration) return;
if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null;
if (input.value === "") {
+39
View File
@@ -3095,6 +3095,45 @@ input.leaf-rename:focus {
min-width: 0;
}
.structured-attachments {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1);
padding: var(--sp-1);
}
.structured-attachments[hidden] { display: none; }
.structured-attachment {
display: inline-flex;
align-items: center;
min-width: 0;
max-width: 100%;
padding-left: var(--sp-2);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-1);
font-size: var(--fs-11);
}
.structured-attachment > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.structured-attachment button {
flex-shrink: 0;
min-width: var(--tap);
min-height: var(--tap);
border: 0;
background: transparent;
color: var(--muted);
}
.structured-attachment button:hover { color: var(--ink); }
.structured-attachment button:disabled { opacity: 0.5; }
.structured-input {
display: block;
width: 100%;