Files
multica/e2e/chat-attachments.spec.ts
Naiyuan Qing 86aa5199fc feat(chat): support attachments & images in chat input (#2445)
* docs(plans): chat attachment & image support implementation plan

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(db): add chat_session_id/chat_message_id to attachment

Co-authored-by: multica-agent <github@multica.ai>

* feat(db): sqlc — chat_session_id on CreateAttachment + LinkAttachmentsToChatMessage

Co-authored-by: multica-agent <github@multica.ai>

* feat(file): upload-file accepts chat_session_id form field

Co-authored-by: multica-agent <github@multica.ai>

* feat(chat): SendChatMessage links uploaded attachments to the new message

Co-authored-by: multica-agent <github@multica.ai>

* feat(api): uploadFile accepts chatSessionId; sendChatMessage accepts attachmentIds

Co-authored-by: multica-agent <github@multica.ai>

* feat(core): useFileUpload supports chatSessionId context

Co-authored-by: multica-agent <github@multica.ai>

* feat(chat): support paste/drag/upload attachments in chat input

Co-authored-by: multica-agent <github@multica.ai>

* test(e2e): chat input attachment upload + send round-trip

Co-authored-by: multica-agent <github@multica.ai>

* chore(chat): keep lazy-created session title empty so untitled fallback localizes

Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): address review — dedupe ensureSession + parse upload response

- chat-window: cache in-flight createSession promise in a ref so a file drop
  followed by a quick send no longer spawns two sessions (and orphans the
  attachment on the losing one).
- Attachment type + EMPTY_ATTACHMENT + AttachmentResponseSchema: include the
  new chat_session_id / chat_message_id fields the server now returns.
- uploadFile: route the response through parseWithFallback so a malformed
  body returns EMPTY_ATTACHMENT instead of an undefined-keyed Attachment,
  matching the API boundary rule.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): address PR #2445 review — test ctx, send gating, attachment surface

1. Backend test was 400ing because the handler reads workspace from
   middleware-injected ctx, and `newRequest` only sets the header. Helper
   `withChatTestWorkspaceCtx` mirrors the agent-access-test pattern and
   loads the member row + SetMemberContext before invoking the handler.

2. Attachment metadata now flows end-to-end:
   - new sqlc `ListAttachmentsByChatMessageIDs` (batch lookup, mirrors the
     comment-side query)
   - `chatMessageToResponse` takes `attachments` and `ChatMessageResponse`
     surfaces them — same shape as CommentResponse
   - `ListChatMessages` loads them via a new `groupChatMessageAttachments`
     helper so the chat bubble can render file cards
   - daemon claim path pulls `ListAttachmentsByChatMessage` for the latest
     user message and ships `ChatMessageAttachments` to the daemon
   - `buildChatPrompt` lists id+filename+content_type and instructs the
     agent to `multica attachment download <id>` — fixes the private-CDN
     expiring-URL problem where the markdown URL would have expired by
     the time the agent acts
   - TS `ChatMessage` gains an optional `attachments` field

3. Chat composer now blocks send while uploads are in flight:
   - `pendingUploads` counter increments in handleUpload, SubmitButton
     uses it to disable
   - handleSend also gates on `editorRef.current.hasActiveUploads()` to
     catch the Mod+Enter path that bypasses the button
   - new vitest covers the "drop large file → immediate send" scenario
     where attachment id would otherwise be silently dropped

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* chore: drop implementation plan doc

Process artefact, not something the repo needs to keep.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 10:57:54 +08:00

171 lines
6.3 KiB
TypeScript

/**
* E2E: chat attachment upload + send back-fills the message link.
*
* Stays at the HTTP layer (auth → upload-file → send-chat-message → DB
* check) so the test doesn't depend on a real agent runtime being online.
* The UI wiring is covered by `chat-input.test.tsx` in @multica/views; this
* spec is the end-to-end contract proof: the backend really does persist
* chat_session_id at upload and back-fill chat_message_id at send.
*/
import "./env";
import { test, expect } from "@playwright/test";
import pg from "pg";
import { createTestApi } from "./helpers";
import type { TestApiClient } from "./fixtures";
const API_BASE =
process.env.NEXT_PUBLIC_API_URL || `http://localhost:${process.env.PORT || "8080"}`;
const DATABASE_URL =
process.env.DATABASE_URL ?? "postgres://multica:multica@localhost:5432/multica?sslmode=disable";
interface UploadRow {
id: string;
url: string;
chat_session_id: string | null;
chat_message_id: string | null;
}
async function authedFetch(api: TestApiClient, path: string, init?: RequestInit) {
const token = api.getToken();
if (!token) throw new Error("test api client not logged in");
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
...((init?.headers as Record<string, string>) ?? {}),
};
return fetch(`${API_BASE}${path}`, { ...init, headers });
}
test.describe("Chat attachments", () => {
let api: TestApiClient;
let pgClient: pg.Client | null = null;
let createdSessionId: string | null = null;
let createdAgentId: string | null = null;
let createdRuntimeId: string | null = null;
test.beforeEach(async () => {
api = await createTestApi();
pgClient = new pg.Client(DATABASE_URL);
await pgClient.connect();
});
test.afterEach(async () => {
try {
if (pgClient) {
if (createdSessionId) {
await pgClient.query(`DELETE FROM chat_session WHERE id = $1`, [createdSessionId]);
}
if (createdAgentId) {
await pgClient.query(`DELETE FROM agent WHERE id = $1`, [createdAgentId]);
}
if (createdRuntimeId) {
await pgClient.query(`DELETE FROM agent_runtime WHERE id = $1`, [createdRuntimeId]);
}
}
} finally {
if (pgClient) await pgClient.end();
pgClient = null;
createdSessionId = null;
createdAgentId = null;
createdRuntimeId = null;
await api.cleanup();
}
});
test("upload-file binds attachment to the chat_session; send back-fills chat_message_id", async () => {
expect(pgClient).not.toBeNull();
const pgc = pgClient!;
// Resolve the workspace + caller so we can seed an agent/runtime/session
// directly via SQL. Going through the HTTP API would require modelling
// local-daemon ownership which isn't needed for this contract test.
const workspaces = await api.getWorkspaces();
const ws = workspaces[0]!;
api.setWorkspaceSlug(ws.slug);
api.setWorkspaceId(ws.id);
const userRow = await pgc.query(
`SELECT id FROM "user" WHERE email = $1 LIMIT 1`,
["e2e@multica.ai"],
);
if (userRow.rows.length === 0) throw new Error("e2e user missing");
const userId = userRow.rows[0].id as string;
// Seed runtime + agent + chat_session.
const runtimeIns = await pgc.query(
`INSERT INTO agent_runtime (
workspace_id, daemon_id, name, runtime_mode, provider, status,
device_info, metadata, last_seen_at
)
VALUES ($1, NULL, $2, 'cloud', $3, 'online', $4, '{}'::jsonb, now())
RETURNING id`,
[ws.id, `e2e chat runtime ${Date.now()}`, "e2e_chat_runtime", "E2E chat runtime"],
);
createdRuntimeId = runtimeIns.rows[0].id as string;
const agentIns = await pgc.query(
`INSERT INTO agent (
workspace_id, name, description, runtime_mode, runtime_config,
runtime_id, visibility, max_concurrent_tasks, owner_id
)
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1, $4)
RETURNING id`,
[ws.id, `E2E Chat Agent ${Date.now()}`, createdRuntimeId, userId],
);
createdAgentId = agentIns.rows[0].id as string;
const sessionIns = await pgc.query(
`INSERT INTO chat_session (workspace_id, agent_id, creator_id, title, status)
VALUES ($1, $2, $3, 'E2E Chat Attachment Session', 'active')
RETURNING id`,
[ws.id, createdAgentId, userId],
);
createdSessionId = sessionIns.rows[0].id as string;
// 1. Upload a small PNG against the chat session.
const pngBytes = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR
]);
const form = new FormData();
form.append("file", new Blob([new Uint8Array(pngBytes)], { type: "image/png" }), "e2e.png");
form.append("chat_session_id", createdSessionId);
const uploadRes = await authedFetch(api, "/api/upload-file", {
method: "POST",
body: form,
headers: { "X-Workspace-Slug": ws.slug },
});
expect(uploadRes.status).toBe(200);
const uploaded = (await uploadRes.json()) as UploadRow;
expect(uploaded.chat_session_id).toBe(createdSessionId);
expect(uploaded.chat_message_id).toBeNull();
expect(uploaded.url).toBeTruthy();
// 2. Send a chat message that references the attachment.
const sendRes = await authedFetch(api, `/api/chat/sessions/${createdSessionId}/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Workspace-Slug": ws.slug,
},
body: JSON.stringify({
content: `look at this ![](${uploaded.url})`,
attachment_ids: [uploaded.id],
}),
});
expect(sendRes.status).toBe(201);
const sendBody = (await sendRes.json()) as { message_id: string; task_id: string };
expect(sendBody.message_id).toBeTruthy();
// 3. DB check: the attachment row's chat_message_id matches the new message.
const after = await pgc.query<{ chat_message_id: string | null }>(
`SELECT chat_message_id::text FROM attachment WHERE id = $1`,
[uploaded.id],
);
expect(after.rows[0]?.chat_message_id).toBe(sendBody.message_id);
// 4. Clean up the attachment we created (chat_session cascade handles the
// rest in afterEach via chat_session row deletion).
await pgc.query(`DELETE FROM attachment WHERE id = $1`, [uploaded.id]);
});
});