feat(ui): add agent chat using the shared task surface

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-11 17:00:17 -05:00
1 parent 64c9edc29f
commit ff2bca0d08
56 files changed
+3634 -108

No files matched your search

File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
// Upstream GitHub simulation only. Paperclip's discovery, secret resolution,
// responsible-user authorization, and project creation all remain real.
if (process.env.NODE_ENV === "test") {
const realFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
const url = new URL(
typeof input === "string" || input instanceof URL ? input : input.url,
);
const headers = new Headers(
init?.headers ?? (input instanceof Request ? input.headers : undefined),
);
if (
url.hostname === "api.github.com" &&
headers.get("authorization") === "Bearer paperclip-e2e-repository-fixture"
) {
if (url.pathname !== "/user/repos")
return Response.json(
{ error: "Unsupported fixture GitHub request" },
{ status: 422 },
);
return Response.json([
{ id: 101, full_name: "chat-fixture/frontend", private: false },
{ id: 102, full_name: "chat-fixture/backend", private: false },
]);
}
return realFetch(input, init);
};
}
+188
View File
@@ -0,0 +1,188 @@
// Deterministic provider: all effects use the real run-authenticated APIs/MCP transport.
// No DB writes, mocked Paperclip responses, provider calls, or outside workspaces.
const base = process.env.PAPERCLIP_API_URL;
const headers = {
Authorization: `Bearer ${process.env.PAPERCLIP_API_KEY}`,
"Content-Type": "application/json",
};
async function api(path, method = "GET", body) {
const response = await fetch(`${base}/api${path}`, {
method,
headers,
...(body ? { body: JSON.stringify(body) } : {}),
});
const data = await response.json();
if (!response.ok)
throw new Error(
`${method} ${path}: ${response.status} ${JSON.stringify(data)}`,
);
return data;
}
const run = await api(`/heartbeat-runs/${process.env.PAPERCLIP_RUN_ID}`);
const ctx = run.contextSnapshot;
const task = await api(`/issues/${ctx.issueId}`);
const comment = async (body) =>
api(`/issues/${task.id}/comments`, "POST", { body });
if (!task.conversationAgentId) {
const plan = await api(`/issues/${task.id}/documents/plan`);
await api(`/issues/${task.id}/documents/output`, "PUT", {
title: "Output",
format: "markdown",
body: `Execution received plan: ${plan.body}`,
});
await api(`/issues/${task.id}`, "PATCH", {
status: "done",
comment: "Execution finished with its initial plan.",
});
process.exit(0);
}
const comments = await api(`/issues/${task.id}/comments?order=asc`);
const current =
comments.find((c) => c.id === ctx.wakeCommentId) ??
comments.filter((c) => c.authorUserId).at(-1);
let command;
try {
command = JSON.parse(
current.body.startsWith("fixture:")
? Buffer.from(current.body.slice(8), "base64url").toString()
: current.body,
);
} catch {
command = { action: "reply", text: current.body };
}
if (
ctx.interactionKind === "request_confirmation" &&
ctx.interactionStatus === "accepted"
) {
const plan = await api(`/issues/${task.id}/documents/plan`);
command = {
action: "handoff",
plan: plan.body,
name: "Approved plan project",
key: ctx.interactionId,
};
}
if (ctx.interactionKind === "ask_user_questions")
command = { action: "reply", text: "Clarification received." };
const writePlan = async (body) => {
const documents = await api(`/issues/${task.id}/documents`);
const previous = documents.find((doc) => doc.key === "plan");
return api(`/issues/${task.id}/documents/plan`, "PUT", {
title: "Plan",
format: "markdown",
body,
baseRevisionId: previous?.latestRevisionId,
});
};
const mcp = async (name, args) => {
const result = await api("/mcp/project-tools", "POST", {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name, arguments: args },
});
if (result.result?.isError || result.error)
throw new Error(JSON.stringify(result));
return result.result.structuredContent;
};
console.log("Deterministic chat provider received a turn");
if (command.action === "hold") {
await comment("Provider is streaming and ready to stop.");
// Keep a real provider process alive so Stop exercises cancellation and tree holds.
setInterval(() => console.log("Streaming discussion"), 250);
} else if (command.action === "delayed") {
await comment("Turn started before feature disable.");
await new Promise((resolve) => setTimeout(resolve, 3000));
await comment("Active turn settled after feature disable.");
} else if (command.action === "project" || command.action === "handoff") {
try {
const args = {
name: command.name ?? "Fixture project",
repositoryUrls: command.urls,
repositoryIds: command.ids,
workspace: command.workspace,
idempotencyKey: command.key ?? current.id,
};
const project = command.projectId
? await api(`/projects/${command.projectId}`)
: command.direct
? await api(`/companies/${task.companyId}/projects`, "POST", args)
: await mcp("create_project", args);
const retry = command.projectId
? project
: await mcp("create_project", args);
if (retry.id !== project.id)
throw new Error("Project retry created a duplicate");
if (command.action === "handoff") {
const plan = command.plan ?? "# Plan\n\nWrite the welcome note.";
if (!ctx.interactionId) await writePlan(plan);
const tasks = [];
for (let index = 0; index < (command.split ? 2 : 1); index++) {
const input = {
title: `Execution ${index + 1}`,
projectId: project.id,
initialPlan: `${plan}\nPart ${index + 1}`,
idempotencyKey: `${current.id}-${index}`,
};
const child = await mcp("create_task", input);
const again = await mcp("create_task", input);
if (again.id !== child.id)
throw new Error("Task retry created a duplicate");
tasks.push(`[${child.identifier}](/issues/${child.id})`);
}
await comment(`Handed off: ${tasks.join(", ")}`);
} else await comment(`Project registered: ${project.name}`);
} catch (error) {
await comment(`Expected tool result: ${error.message}`);
}
} else if (command.action === "plan") {
const plan = await writePlan(command.text);
if (command.approval)
await api(`/issues/${task.id}/interactions`, "POST", {
kind: "request_confirmation",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Hand this plan off to an assigned project task?",
acceptLabel: "Approve handoff",
rejectLabel: "Revise",
rejectRequiresReason: true,
target: {
type: "issue_document",
key: "plan",
revisionId: plan.latestRevisionId,
revisionNumber: plan.latestRevisionNumber,
},
},
});
await comment("The draft plan is ready for discussion.");
} else if (command.action === "question") {
await api(`/issues/${task.id}/interactions`, "POST", {
kind: "ask_user_questions",
idempotencyKey: current.id,
continuationPolicy: "wake_assignee",
payload: {
version: 1,
questions: [
{
id: "audience",
prompt: "Who is the welcome note for?",
selectionMode: "single",
required: true,
options: [
{ id: "garden", label: "Garden club" },
{ id: "book", label: "Book club" },
],
},
],
},
});
await comment("Please choose an audience.");
} else if (command.action === "history") {
for (let index = 0; index < 65; index++)
await comment(`History message ${String(index).padStart(2, "0")}`);
} else {
await comment(
`Reply generation ${ctx.conversationSessionGeneration}: ${command.text}`,
);
}
@@ -324,3 +324,62 @@ test.describe("Multi-user: authenticated mode", () => {
}
});
});
test("agent chats keep personal identity and ordinary company visibility", async ({ browser, page }) => {
test.setTimeout(120_000);
expect((await (await page.request.get(`${BASE}/api/health`)).json()).deploymentMode).toBe("authenticated");
await signUp(page, { ...ownerUser, email: `chat-${ownerUser.email}` });
const bootstrapToken = new URL(createBootstrapInvite()).pathname.split("/").at(-1);
expect((await sessionJsonRequest(page, `${BASE}/api/invites/${bootstrapToken}/accept`, { method: "POST", data: { requestType: "human" } })).ok).toBe(true);
const company = await createCompanyForSession(page, `Chat identity ${runId}`);
const companyPrefix = company.issuePrefix ?? company.id;
const invite = await sessionJsonRequest<{ inviteUrl: string }>(page, `${BASE}/api/companies/${company.id}/invites`, { method: "POST", data: { allowedJoinTypes: "human", humanRole: "operator" } });
expect(invite.ok).toBe(true);
const invited = await newPage(browser);
try {
await signUp(invited.page, { ...invitedUser, email: `chat-${invitedUser.email}` });
const inviteToken = new URL(invite.json!.inviteUrl, BASE).pathname.split("/").at(-1);
const joined = await sessionJsonRequest(invited.page, `${BASE}/api/invites/${inviteToken}/accept`, { method: "POST", data: { requestType: "human" } });
expect(joined.ok).toBe(true);
// Persistent chats are personal identities, not private messaging.
const originalFlags = await sessionJsonRequest<Record<string, boolean>>(page, `${BASE}/api/instance/settings/experimental`);
const enable = await sessionJsonRequest(page, `${BASE}/api/instance/settings/experimental`, { method: "PATCH", data: { enableAgentChat: true } });
expect(enable.ok).toBe(true);
try {
const createdAgent = await sessionJsonRequest<{ id: string }>(page, `${BASE}/api/companies/${company.id}/agents`, { method: "POST", data: {
name: "Personal chat identity", adapterType: "process",
adapterConfig: { command: process.execPath, args: ["-e", "process.exit(0)"] },
runtimeConfig: { heartbeat: { enabled: false } },
} });
expect(createdAgent.ok).toBe(true);
const agentId = createdAgent.json!.id;
const chatEndpoint = `${BASE}/api/companies/${company.id}/chats/${agentId}`;
await page.goto(`${BASE}/${companyPrefix}/chats/${agentId}`);
await invited.page.goto(`${BASE}/${companyPrefix}/chats/${agentId}`);
expect((await sessionJsonRequest(page, chatEndpoint)).json).toBeNull();
expect((await sessionJsonRequest(invited.page, chatEndpoint)).json).toBeNull();
const ownerChat = await sessionJsonRequest<{ id: string; conversationUserId: string }>(page, chatEndpoint, { method: "POST", data: {} });
const memberChat = await sessionJsonRequest<{ id: string; conversationUserId: string }>(invited.page, chatEndpoint, { method: "POST", data: {} });
expect(ownerChat.ok).toBe(true); expect(memberChat.ok).toBe(true);
expect(ownerChat.json!.id).not.toBe(memberChat.json!.id);
expect(ownerChat.json!.conversationUserId).not.toBe(memberChat.json!.conversationUserId);
expect((await sessionJsonRequest(invited.page, `${BASE}/api/issues/${ownerChat.json!.id}`)).ok).toBe(true);
expect((await sessionJsonRequest(page, `${BASE}/api/issues/${memberChat.json!.id}`)).ok).toBe(true);
await page.reload(); await invited.page.reload();
await page.getByRole("button", { name: "Star Personal chat identity", exact: true }).click();
await expect(page.getByRole("button", { name: "Unstar Personal chat identity", exact: true })).toBeAttached();
await expect(invited.page.getByRole("button", { name: "Star Personal chat identity", exact: true })).toBeAttached();
const ownerRecent = await page.evaluate(() => Object.keys(localStorage).filter(key => key.startsWith("paperclip.recentAgentChats:")));
const memberRecent = await invited.page.evaluate(() => Object.keys(localStorage).filter(key => key.startsWith("paperclip.recentAgentChats:")));
expect(ownerRecent.some(key => key.endsWith(ownerChat.json!.conversationUserId))).toBe(true);
expect(memberRecent.some(key => key.endsWith(memberChat.json!.conversationUserId))).toBe(true);
const another = await createCompanyForSession(page, `Private company ${runId}`);
const forbidden = await sessionJsonRequest(invited.page, `${BASE}/api/companies/${another.id}/chats/${agentId}`);
expect([403, 404]).toContain(forbidden.status);
} finally {
const restore = await sessionJsonRequest(page, `${BASE}/api/instance/settings/experimental`, { method: "PATCH", data: { enableAgentChat: originalFlags.json!.enableAgentChat } });
expect(restore.ok).toBe(true);
}
} finally { await invited.context.close(); }
});
+1
View File
@@ -69,6 +69,7 @@ export default defineConfig({
env: {
...process.env,
NODE_ENV: "test",
NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --import=${path.resolve(import.meta.dirname, "fixtures/agent-chat-github.mjs")}`,
PORT: String(PORT),
PAPERCLIP_OPEN_ON_LISTEN: "false",
PAPERCLIP_API_URL: BASE_URL,