diff --git a/.changeset/moody-pianos-attach.md b/.changeset/moody-pianos-attach.md new file mode 100644 index 0000000..15a9311 --- /dev/null +++ b/.changeset/moody-pianos-attach.md @@ -0,0 +1,21 @@ +--- +"@appx-org/agent-server": minor +"@appx-org/agent-protocol": minor +"@appx-org/agent-client": minor +--- + +Add chat attachments. `POST /v1/projects/{id}/attachments` uploads an opaque +file (JSON base64) into the project workspace at `attachments//`; +`POST …/sessions/{id}/prompt` accepts an optional `attachments` id array and +appends the stored workspace paths to the prompt so the agent can read the +files with its own tools when needed. agent-client gains +`AgentClient.uploadAttachment`, an `attachments` parameter on `sendPrompt`, and +an attach button + pending-attachment chips in `ChatPanel`. + +Upload limits are enforced as an HTTP body limit (~25 MB per file) plus a 200 MB +per-project quota, both returning `413`; base64 is validated strictly so a +truncated payload can't be stored as a silently corrupt file; zero-byte files +are accepted; and long multibyte filenames are truncated by UTF-8 byte length +instead of failing with `ENAMETOOLONG`. The prompt's attachment note is wrapped +in `` delimiters that agent-client strips from the rendered user +message, showing the attached filenames as chips instead of the internal note. diff --git a/packages/agent-client/src/core/__tests__/attachments.test.ts b/packages/agent-client/src/core/__tests__/attachments.test.ts new file mode 100644 index 0000000..4579e93 --- /dev/null +++ b/packages/agent-client/src/core/__tests__/attachments.test.ts @@ -0,0 +1,47 @@ +/** + * The attachment note agent-server appends to a prompt must never reach the + * screen as text the user wrote. These tests pin the exact wire format the + * server produces — the fixtures below are copied from + * agent-server/src/runtime/attachments.ts `composePromptWithAttachments`, and + * that module has a matching test asserting the same delimiters. + */ +import { describe, expect, it } from "vitest"; +import { stripAttachmentNote } from "../attachments.js"; + +/** Byte-for-byte what agent-server's composePromptWithAttachments emits. */ +function composed(text: string, paths: string[]): string { + return ( + `${text}\n\n\n` + + "The user attached the following files. They are stored in the project workspace; " + + "read them with your file tools if the task needs their contents.\n" + + `${paths.map((p) => `- ${p}`).join("\n")}\n` + ); +} + +describe("stripAttachmentNote", () => { + it("leaves a message with no note untouched", () => { + expect(stripAttachmentNote("just a prompt")).toEqual({ text: "just a prompt", files: [] }); + }); + + it("removes the note and returns the listed files", () => { + const result = stripAttachmentNote( + composed("summarize these", ["attachments/6f1e/report.pdf", "attachments/a2b3/notes.txt"]), + ); + expect(result.text).toBe("summarize these"); + expect(result.files).toEqual([ + { path: "attachments/6f1e/report.pdf", filename: "report.pdf" }, + { path: "attachments/a2b3/notes.txt", filename: "notes.txt" }, + ]); + }); + + it("keeps list items the user typed themselves", () => { + const result = stripAttachmentNote(composed("- one\n- two", ["attachments/x/a.csv"])); + expect(result.text).toBe("- one\n- two"); + expect(result.files).toHaveLength(1); + }); + + it("does not strip a lookalike block in the middle of the text", () => { + const text = "before\n\n\nnope\n- x\n\n\nafter"; + expect(stripAttachmentNote(text)).toEqual({ text, files: [] }); + }); +}); diff --git a/packages/agent-client/src/core/__tests__/client.test.ts b/packages/agent-client/src/core/__tests__/client.test.ts index 25bd3a8..d48a12f 100644 --- a/packages/agent-client/src/core/__tests__/client.test.ts +++ b/packages/agent-client/src/core/__tests__/client.test.ts @@ -46,6 +46,33 @@ describe("AgentClient — REST requests", () => { expect((post.body as { text?: string }).text).toBe("hallo"); }); + it("sends attachment ids with the prompt when provided", async () => { + const { instance, requests } = client({ + "POST /v1/projects/p1/sessions/s1/prompt": () => ({ body: { ok: true } }), + }); + + await instance.sendPrompt("p1", "s1", "read it", ["att-1", "att-2"]); + + const body = requests[0]!.body as { text?: string; attachments?: string[] }; + expect(body.text).toBe("read it"); + expect(body.attachments).toEqual(["att-1", "att-2"]); + }); + + it("uploads an attachment as base64 JSON", async () => { + const { instance, requests } = client({ + "POST /v1/projects/p1/attachments": () => ({ + body: { id: "a1", filename: "notes.txt", path: "attachments/a1/notes.txt", size: 5, createdAt: "now" }, + }), + }); + + const result = await instance.uploadAttachment("p1", "notes.txt", new TextEncoder().encode("hello")); + + expect(result.id).toBe("a1"); + const body = requests[0]!.body as { filename?: string; contentBase64?: string }; + expect(body.filename).toBe("notes.txt"); + expect(body.contentBase64).toBe(Buffer.from("hello").toString("base64")); + }); + it("sends a DELETE for deleteSession", async () => { const { instance, requests } = client({ "DELETE /v1/projects/p1/sessions/s1": () => ({ body: { ok: true } }), diff --git a/packages/agent-client/src/core/__tests__/reducer.test.ts b/packages/agent-client/src/core/__tests__/reducer.test.ts index 1eb5ec9..58c75dc 100644 --- a/packages/agent-client/src/core/__tests__/reducer.test.ts +++ b/packages/agent-client/src/core/__tests__/reducer.test.ts @@ -334,6 +334,51 @@ describe("sessionReducer — history load", () => { }); }); +describe("sessionReducer — attachment note", () => { + /** What agent-server persists for a prompt that referenced two attachments. */ + const withNote = + "read these\n\n\n" + + "The user attached the following files. They are stored in the project workspace; " + + "read them with your file tools if the task needs their contents.\n" + + "- attachments/6f1e/report.pdf\n- attachments/a2b3/notes.txt\n"; + + it("hides the note from the optimistic bubble when the server echo adopts it", () => { + let state = dispatch(initialSessionState, { type: "user_prompt_submitted", text: "read these", promptId: "p1" }); + state = emit(state, { + type: "message_start", + message: { role: "user", content: withNote, timestamp: "t1" }, + }); + + expect(state.messages).toHaveLength(1); + const parts = state.messages[0]!.parts; + expect(textPart(parts[0]).text).toBe("read these"); + expect(parts[1]).toEqual({ + type: "attachments", + contentIndex: 0, + files: [ + { path: "attachments/6f1e/report.pdf", filename: "report.pdf" }, + { path: "attachments/a2b3/notes.txt", filename: "notes.txt" }, + ], + }); + }); + + it("hides the note when the message comes back from a history reload", () => { + const history = [{ role: "user", content: withNote, timestamp: "t0" }] as unknown as AgentMessage[]; + const state = dispatch(initialSessionState, { type: "load_history", messages: history }); + + expect(textPart(state.messages[0]!.parts[0]).text).toBe("read these"); + expect(state.messages[0]!.parts[1]!.type).toBe("attachments"); + }); + + it("leaves assistant text alone", () => { + const state = emit(initialSessionState, { + type: "message_start", + message: { role: "assistant", content: [{ type: "text", text: withNote }], timestamp: "t1" }, + }); + expect(textPart(state.messages[0]!.parts[0]).text).toBe(withNote); + }); +}); + describe("sessionReducer — extension UI", () => { it("queues a blocking request and clears it on response", () => { let state = emit(initialSessionState, { diff --git a/packages/agent-client/src/core/attachments.ts b/packages/agent-client/src/core/attachments.ts new file mode 100644 index 0000000..762ca09 --- /dev/null +++ b/packages/agent-client/src/core/attachments.ts @@ -0,0 +1,36 @@ +/** + * Client-side handling of the attachment note agent-server appends to a prompt. + * + * When a prompt references uploaded attachments, agent-server rewrites the + * prompt text to include their workspace paths so the agent can read them. That + * *composed* text is what gets persisted and echoed back as the user's message, + * so rendering it verbatim would show an internal instruction as words the user + * typed. We strip the note and surface the files as chips instead. + * + * The delimiters mirror `ATTACHMENT_NOTE_OPEN` / `ATTACHMENT_NOTE_CLOSE` in + * agent-server's `runtime/attachments.ts` — change one and you must change the + * other (both sides have tests pinning the exact wire format). + */ + +/** A file referenced by the attachment note. */ +export type AttachmentRef = { path: string; filename: string }; + +/** The note is always the trailing block of the composed prompt. */ +const ATTACHMENT_NOTE_RE = /\n*\n[\s\S]*?\n<\/attached-files>\s*$/; + +/** + * Split the trailing attachment note off a user message's text. Returns the + * text as the user wrote it plus the files the note listed (empty when the + * message carries no note). + */ +export function stripAttachmentNote(text: string): { text: string; files: AttachmentRef[] } { + const match = ATTACHMENT_NOTE_RE.exec(text); + if (!match) return { text, files: [] }; + const files: AttachmentRef[] = []; + for (const line of match[0].split("\n")) { + if (!line.startsWith("- ")) continue; + const path = line.slice(2).trim(); + if (path) files.push({ path, filename: path.split("/").pop() || path }); + } + return { text: text.slice(0, match.index).trimEnd(), files }; +} diff --git a/packages/agent-client/src/core/client.ts b/packages/agent-client/src/core/client.ts index ecf0aff..ae316c6 100644 --- a/packages/agent-client/src/core/client.ts +++ b/packages/agent-client/src/core/client.ts @@ -21,6 +21,7 @@ import type { paths } from "@appx-org/agent-protocol"; import createClient, { type Client } from "openapi-fetch"; import type { + AgentAttachment, AgentAuthProvider, AgentCustomProvider, AgentMessage, @@ -70,6 +71,28 @@ export interface AgentClientConfig { /** The shape every `openapi-fetch` operation resolves to. */ type ApiResult = { data?: TData; error?: unknown; response: Response }; +/** + * Encode attachment bytes as standard base64 for the JSON upload body. + * Chunked `btoa` in the browser; `Buffer` where available (Node, tests). + */ +async function toBase64(data: Blob | ArrayBuffer | Uint8Array): Promise { + const bytes = + data instanceof Uint8Array + ? data + : data instanceof ArrayBuffer + ? new Uint8Array(data) + : new Uint8Array(await data.arrayBuffer()); + if (typeof Buffer !== "undefined") { + return Buffer.from(bytes).toString("base64"); + } + let binary = ""; + const chunkSize = 0x8000; + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); + } + return btoa(binary); +} + function extractErrorMessage(body: unknown, fallback: string): string { if (!body) return fallback; if (typeof body === "string") return body || fallback; @@ -325,11 +348,32 @@ export class AgentClient { ); } - async sendPrompt(projectId: string, sessionId: string, text: string): Promise<{ ok: true }> { + async sendPrompt(projectId: string, sessionId: string, text: string, attachments?: string[]): Promise<{ ok: true }> { return this.unwrap( await this.http.POST("/v1/projects/{projectId}/sessions/{id}/prompt", { params: { path: { projectId, id: sessionId } }, - body: { text }, + body: attachments && attachments.length > 0 ? { text, attachments } : { text }, + }), + ); + } + + // --- Attachments ---------------------------------------------------------- + + /** + * Upload an opaque attachment into the project workspace. The client never + * interprets the bytes; agent-server stores them where the agent can read + * them. Reference the returned `id` in `sendPrompt`'s `attachments`. + */ + async uploadAttachment( + projectId: string, + filename: string, + data: Blob | ArrayBuffer | Uint8Array, + ): Promise { + const contentBase64 = await toBase64(data); + return this.unwrap( + await this.http.POST("/v1/projects/{projectId}/attachments", { + params: { path: { projectId } }, + body: { filename, contentBase64 }, }), ); } diff --git a/packages/agent-client/src/core/reducer.ts b/packages/agent-client/src/core/reducer.ts index 4711da3..84b1a8e 100644 --- a/packages/agent-client/src/core/reducer.ts +++ b/packages/agent-client/src/core/reducer.ts @@ -3,6 +3,7 @@ * renderable `UiMessage[]` * keep it framework-agnostic so it can be unit-tested in isolation. */ +import { stripAttachmentNote } from "./attachments.js"; import type { AgentEvent, AgentMessage, @@ -82,6 +83,25 @@ function partsFromContent(content: unknown): UiMessagePart[] { return parts; } +/** + * Build UI parts for a **user** message: same as `partsFromContent`, but with + * agent-server's trailing attachment note lifted out of the text into an + * `attachments` part so the note is never rendered as words the user typed. + */ +function userPartsFromContent(content: unknown): UiMessagePart[] { + const parts: UiMessagePart[] = []; + for (const part of partsFromContent(content)) { + if (part.type !== "text") { + parts.push(part); + continue; + } + const { text, files } = stripAttachmentNote(part.text); + if (text) parts.push({ ...part, text }); + if (files.length > 0) parts.push({ type: "attachments", files, contentIndex: part.contentIndex }); + } + return parts; +} + const isToolResultMessage = (m: AgentMessage): m is ToolResultMessage => (m as { role?: string }).role === "toolResult"; /** @@ -409,7 +429,7 @@ function loadHistory(state: SessionState, history: AgentMessage[]): SessionState for (const m of history) { if (isToolResultMessage(m)) continue; if (m.role !== "user" && m.role !== "assistant") continue; - const parts = partsFromContent(m.content); + const parts = m.role === "user" ? userPartsFromContent(m.content) : partsFromContent(m.content); if (parts.length === 0) continue; messages.push({ // History is rebuilt deterministically from the server transcript, so a @@ -466,7 +486,7 @@ function reduceEvent(state: SessionState, event: AgentEvent): SessionState { // through and is appended as a new message. if (event.message.role === "user" && state.pendingPromptIds.length > 0) { const [promptId, ...restPending] = state.pendingPromptIds; - const serverParts = partsFromContent(event.message.content); + const serverParts = userPartsFromContent(event.message.content); const messages = state.messages.map((message) => message.promptId === promptId ? { @@ -482,7 +502,10 @@ function reduceEvent(state: SessionState, event: AgentEvent): SessionState { return { ...state, pendingPromptIds: restPending, messages }; } - const initialParts = partsFromContent(event.message.content); + const initialParts = + event.message.role === "user" + ? userPartsFromContent(event.message.content) + : partsFromContent(event.message.content); const newMsg: UiMessage = { id: `m${state.messageSeq}`, role: event.message.role, @@ -535,7 +558,10 @@ function reduceEvent(state: SessionState, event: AgentEvent): SessionState { }; } if (event.message.role !== "user" && event.message.role !== "assistant") return { ...state, rawMessages }; - const finalisedParts = partsFromContent(event.message.content); + const finalisedParts = + event.message.role === "user" + ? userPartsFromContent(event.message.content) + : partsFromContent(event.message.content); let replaced = false; const messages = state.messages.map((m) => { if (replaced) return m; diff --git a/packages/agent-client/src/core/store.ts b/packages/agent-client/src/core/store.ts index aeb42a8..3766dcd 100644 --- a/packages/agent-client/src/core/store.ts +++ b/packages/agent-client/src/core/store.ts @@ -199,7 +199,7 @@ export class SessionStore { return this.entries.get(SessionStore.key(projectId, sessionId))?.state ?? initialSessionState; } - async sendPrompt(projectId: string, sessionId: string, text: string): Promise { + async sendPrompt(projectId: string, sessionId: string, text: string, attachments?: string[]): Promise { const entryKey = SessionStore.key(projectId, sessionId); this.attach(projectId, sessionId); const entry = this.entries.get(entryKey); @@ -209,7 +209,7 @@ export class SessionStore { const promptId = newCorrelationId(); this.dispatch(entryKey, { type: "user_prompt_submitted", text, promptId }); try { - await this.client.sendPrompt(projectId, sessionId, text); + await this.client.sendPrompt(projectId, sessionId, text, attachments); void this.refreshExtensionRequests(projectId, sessionId, entryKey); } catch (err) { this.dispatch(entryKey, { diff --git a/packages/agent-client/src/core/types.ts b/packages/agent-client/src/core/types.ts index 684f37e..6af8784 100644 --- a/packages/agent-client/src/core/types.ts +++ b/packages/agent-client/src/core/types.ts @@ -21,6 +21,7 @@ import type { } from "@appx-org/agent-protocol"; export type { + AgentAttachment, AgentAuthProvider, AgentCustomProvider, AgentCustomProviderApi, @@ -61,6 +62,11 @@ export type AssistantMessagePartial = { content?: ContentBlock[] }; export type UiMessagePart = | { type: "text"; text: string; contentIndex?: number } + /** + * Files the user attached to this prompt. Rendered as chips instead of the + * raw note agent-server appends to the prompt text (see `stripAttachmentNote`). + */ + | { type: "attachments"; files: { path: string; filename: string }[]; contentIndex?: number } | { type: "tool"; id: string; diff --git a/packages/agent-client/src/react/ChatPanel.tsx b/packages/agent-client/src/react/ChatPanel.tsx index ca26579..5a4b423 100644 --- a/packages/agent-client/src/react/ChatPanel.tsx +++ b/packages/agent-client/src/react/ChatPanel.tsx @@ -28,6 +28,34 @@ function modelLabel(model: AgentModel): string { : `${model.provider}/${model.id}`; } +/** Lucide `paperclip` icon (https://lucide.dev, ISC license), inlined to avoid an icon-library dependency. */ +function PaperclipIcon() { + return ( + + ); +} + +/** + * Mirrors `MAX_PROMPT_ATTACHMENTS` in agent-server's contract schemas: the + * prompt endpoint rejects more than this many ids, so cap the selection here + * rather than letting the user upload files the send will then bounce. + */ +const MAX_ATTACHMENTS_PER_PROMPT = 20; + const thinkingLabels: Record = { off: "Off", minimal: "Minimal", @@ -77,12 +105,20 @@ export function ChatPanel({ headerStart, className, }: ChatPanelProps) { - const { classNames, labels, costRates } = useAgentChatContext(); + const { classNames, labels, costRates, client } = useAgentChatContext(); const { state, sendPrompt, abort, respondExtensionRequest, loadModelSettings, updateModelSettings } = useAgentSession(projectId, sessionId); const [input, setInput] = useState(""); const [sending, setSending] = useState(false); + // Attachments are opaque to the client: files are uploaded as raw bytes and + // only their ids ride along with the next prompt. + const [attachments, setAttachments] = useState<{ id: string; filename: string }[]>([]); + const [uploadCount, setUploadCount] = useState(0); + const [attachError, setAttachError] = useState(null); + const fileInputRef = useRef(null); + const textareaRef = useRef(null); const prevStatusRef = useRef(state.status); + const prevSessionIdRef = useRef(sessionId); // Model/thinking settings are owned by the store (single source of truth), // so the panel just reads the live slice instead of duplicating it locally. @@ -117,6 +153,30 @@ export function ChatPanel({ if (showModelControls || showUsage) void loadModelSettings(); }, [showModelControls, showUsage, loadModelSettings]); + // The panel is not remounted when the active session changes, so pending + // attachments would otherwise follow the user into the next session and be + // silently sent with its first prompt. Reset during render (React's + // "adjusting state when a prop changes" pattern) so the chips never paint + // against the wrong session. + if (prevSessionIdRef.current !== sessionId) { + prevSessionIdRef.current = sessionId; + setAttachments([]); + setAttachError(null); + } + + // Grow the textarea with its content instead of scrolling a one-line window. + // The CSS caps it (`max-height`), after which it scrolls. Keyed on `input` + // rather than done in onChange so programmatic clears (send, session switch) + // shrink it too. + useEffect(() => { + const el = textareaRef.current; + if (!el) return; + // Reset first so the box shrinks as well as grows; with no text, drop the + // inline height entirely and let `min-height` hold it level with the buttons. + el.style.height = "auto"; + if (input) el.style.height = `${el.scrollHeight}px`; + }, [input]); + useEffect(() => { if (prevStatusRef.current !== "idle" && state.status === "idle") { onTurnComplete?.(); @@ -130,18 +190,52 @@ export function ChatPanel({ const handleSend = async () => { const text = input.trim(); - if (!text || sending) return; + if (!text || sending || uploadCount > 0) return; + const pending = attachments; + const attachmentIds = pending.map((a) => a.id); + // Clear optimistically (the textarea is disabled while sending, so nothing + // can be typed over) and restore on failure — otherwise a rejected prompt + // silently discards both the text and the ids of the uploaded files. setInput(""); + setAttachments([]); setSending(true); try { - await sendPrompt(text); + await sendPrompt(text, attachmentIds.length > 0 ? attachmentIds : undefined); } catch (err) { + // The store already surfaces the failure in `state.error`, so only the + // composer needs restoring here. console.error("[agent-client] failed to send prompt:", err); + setInput((current) => current || text); + setAttachments((current) => (current.length > 0 ? current : pending)); } finally { setSending(false); } }; + const handleFilesSelected = async (files: FileList | null) => { + if (!files || files.length === 0) return; + setAttachError(null); + const room = MAX_ATTACHMENTS_PER_PROMPT - attachments.length - uploadCount; + const selected = Array.from(files).slice(0, Math.max(room, 0)); + if (selected.length < files.length) { + setAttachError(`At most ${MAX_ATTACHMENTS_PER_PROMPT} attachments per message.`); + } + if (selected.length === 0) return; + setUploadCount((n) => n + selected.length); + await Promise.all( + selected.map(async (file) => { + try { + const info = await client.uploadAttachment(projectId, file.name, file); + setAttachments((prev) => [...prev, { id: info.id, filename: info.filename }]); + } catch (err) { + setAttachError(err instanceof Error ? err.message : String(err)); + } finally { + setUploadCount((n) => n - 1); + } + }), + ); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -271,31 +365,82 @@ export function ChatPanel({ )} + {attachError &&
{attachError}
} + {(attachments.length > 0 || uploadCount > 0) && ( +
+ {attachments.map((a) => ( + + {a.filename} + + + ))} + {uploadCount > 0 && uploading…} +
+ )} +
-