mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
feat(ui): add agent chat using the shared task surface
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user