Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/moody-pianos-attach.md
Original file line number Diff line number Diff line change
@@ -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/<id>/<filename>`;
`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 `<attached-files>` delimiters that agent-client strips from the rendered user
message, showing the attached filenames as chips instead of the internal note.
47 changes: 47 additions & 0 deletions packages/agent-client/src/core/__tests__/attachments.test.ts
Original file line number Diff line number Diff line change
@@ -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<attached-files>\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</attached-files>`
);
}

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<attached-files>\nnope\n- x\n</attached-files>\n\nafter";
expect(stripAttachmentNote(text)).toEqual({ text, files: [] });
});
});
27 changes: 27 additions & 0 deletions packages/agent-client/src/core/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }),
Expand Down
45 changes: 45 additions & 0 deletions packages/agent-client/src/core/__tests__/reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<attached-files>\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</attached-files>";

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, {
Expand Down
36 changes: 36 additions & 0 deletions packages/agent-client/src/core/attachments.ts
Original file line number Diff line number Diff line change
@@ -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*<attached-files>\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 };
}
48 changes: 46 additions & 2 deletions packages/agent-client/src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -70,6 +71,28 @@ export interface AgentClientConfig {
/** The shape every `openapi-fetch` operation resolves to. */
type ApiResult<TData> = { 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<string> {
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;
Expand Down Expand Up @@ -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<AgentAttachment> {
const contentBase64 = await toBase64(data);
return this.unwrap(
await this.http.POST("/v1/projects/{projectId}/attachments", {
params: { path: { projectId } },
body: { filename, contentBase64 },
}),
);
}
Expand Down
34 changes: 30 additions & 4 deletions packages/agent-client/src/core/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
? {
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-client/src/core/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
async sendPrompt(projectId: string, sessionId: string, text: string, attachments?: string[]): Promise<void> {
const entryKey = SessionStore.key(projectId, sessionId);
this.attach(projectId, sessionId);
const entry = this.entries.get(entryKey);
Expand All @@ -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, {
Expand Down
6 changes: 6 additions & 0 deletions packages/agent-client/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
} from "@appx-org/agent-protocol";

export type {
AgentAttachment,
AgentAuthProvider,
AgentCustomProvider,
AgentCustomProviderApi,
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading