diff --git a/server/src/__tests__/agent-conversations.test.ts b/server/src/__tests__/agent-conversations.test.ts index a1aece716bf..f8252696bcb 100644 --- a/server/src/__tests__/agent-conversations.test.ts +++ b/server/src/__tests__/agent-conversations.test.ts @@ -1084,6 +1084,16 @@ describe("chat prompt policy", () => { expect(prompt).toContain("//agents//runs/"); }); + it("tells chat how to link projects and to link only the workspace pages a tool result gives", () => { + const app = readFileSync(new URL("../../../ui/src/App.tsx", import.meta.url), "utf8"); + expect(app).toMatch(//projects/."); + expect(AGENT_CHAT_DIRECTIVE).toContain("When a tool result gives a record's href, such as a project or a workspace, link that href; a workspace without an href has no page on this instance"); + const prompt = buildPaperclipTaskMarkdown({ issue: { id: "chat", identifier: null, title: "Chat", workMode: "ask", conversationAgentId: "agent" } }); + expect(prompt).toContain("//projects/"); + }); + it("says the task-chat lab exists only under pnpm dev in the person's own checkout", () => { expect(AGENT_CHAT_DIRECTIVE).toContain("Some developer labs, including the task-chat interaction lab at //dev/task-chat-lab, are registered only in a development build of the UI"); expect(AGENT_CHAT_DIRECTIVE).not.toContain("Developer labs, such as"); diff --git a/server/src/__tests__/chat-project-tools.test.ts b/server/src/__tests__/chat-project-tools.test.ts index 7def311320d..80a4ada56de 100644 --- a/server/src/__tests__/chat-project-tools.test.ts +++ b/server/src/__tests__/chat-project-tools.test.ts @@ -5,7 +5,7 @@ import { createLocalAgentJwt } from "../agent-auth-jwt.js"; import { randomUUID } from "node:crypto"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { and, eq } from "drizzle-orm"; -import { agents, issues, heartbeatRuns, projects, companyMemberships, executionWorkspaces } from "@paperclipai/db"; +import { agents, companies, issues, heartbeatRuns, projects, companyMemberships, executionWorkspaces } from "@paperclipai/db"; import { startRunnerApiTestServer } from "./helpers/runner-api-server.js"; import { issueService } from "../services/issues.js"; import { documentService } from "../services/documents.js"; @@ -20,6 +20,24 @@ import { recoveryPostgresSupport as support } from "./helpers/recovery-postgres. afterAll(async () => { await server?.close(); if (originalSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET; else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalSecret; }); const call = (fixture: Awaited>, tool: string, args: Record) => fixture.authority.execute({ tool, arguments: args, callId: randomUUID() }); + it("gives MCP list_projects rows the company-scoped project page href", async () => { + const f = await server.fixture({ conversation: true, disableWakeOnDemand: true }); + const [{ issuePrefix }] = await server.db.select({ issuePrefix: companies.issuePrefix }).from(companies).where(eq(companies.id, f.companyId)); + const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!; + const originalApiUrl = process.env.PAPERCLIP_API_URL; + process.env.PAPERCLIP_API_URL = server.apiUrl; + try { + const response = await fetch(`${server.apiUrl}/api/mcp/project-tools`, { method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "list_projects", arguments: {} } }) }); + const { result } = await response.json() as { result: { isError?: boolean; structuredContent?: { projects: Array<{ id: string; href?: string }> } } }; + expect(result.isError).toBeUndefined(); + expect(result.structuredContent?.projects.find(row => row.id === f.projectId)?.href).toBe(`/${issuePrefix}/projects/${f.projectId}`); + } finally { + if (originalApiUrl === undefined) delete process.env.PAPERCLIP_API_URL; else process.env.PAPERCLIP_API_URL = originalApiUrl; + } + }); + it("pages only visible projects and prefilters low-trust agent and run scopes", async () => { const f = await server.fixture({ disableWakeOnDemand: true }); const ids = Array.from({ length: 53 }, (_, i) => `abcdefab-0000-4000-8000-${String(i).padStart(12, "0")}`); diff --git a/server/src/__tests__/project-tools-pagination.test.ts b/server/src/__tests__/project-tools-pagination.test.ts index 8276e0b8ac1..3cf52b40d35 100644 --- a/server/src/__tests__/project-tools-pagination.test.ts +++ b/server/src/__tests__/project-tools-pagination.test.ts @@ -21,6 +21,13 @@ describe("project discovery result bounds", () => { .toMatchObject({ properties: { cursor: expect.any(Object), limit: expect.any(Object) } }); }); + it("adds each project's company-scoped page href when the company prefix is known", async () => { + const page = { projects: [{ id: "abcdefab-0000-4000-8000-000000000001", name: "Launch", status: "backlog" }], nextCursor: null }; + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => page }))); + expect(await callProjectTool({ ...input, companyPrefix: "COM" })).toEqual({ ...page, + projects: [{ ...page.projects[0], href: "/COM/projects/abcdefab-0000-4000-8000-000000000001" }] }); + }); + it.each([{ limit: 0 }, { limit: 51 }, { cursor: "invalid" }])("rejects invalid paging arguments %j before fetching", async (arguments_) => { const fetch = vi.fn(); vi.stubGlobal("fetch", fetch); diff --git a/server/src/__tests__/task-workspace-api.test.ts b/server/src/__tests__/task-workspace-api.test.ts index cee003d1b66..2a9e7acca14 100644 --- a/server/src/__tests__/task-workspace-api.test.ts +++ b/server/src/__tests__/task-workspace-api.test.ts @@ -3,11 +3,12 @@ import { mkdtemp } from "node:fs/promises"; import { dirname, join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { and, eq } from "drizzle-orm"; -import { agents, authUsers, companyMemberships, executionWorkspaceRepositories, executionWorkspaces, issues, heartbeatRuns, projects, projectWorkspaces } from "@paperclipai/db"; +import { agents, authUsers, companies, companyMemberships, executionWorkspaceRepositories, executionWorkspaces, issues, heartbeatRuns, projects, projectWorkspaces } from "@paperclipai/db"; import { createLocalAgentJwt } from "../agent-auth-jwt.js"; import { startRunnerApiTestServer } from "./helpers/runner-api-server.js"; import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js"; import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; const support = await getEmbeddedPostgresTestSupport(); (support.supported ? describe : describe.skip)("task workspace API and native semantic authority", () => { @@ -261,4 +262,25 @@ const support = await getEmbeddedPostgresTestSupport(); expect((await server.db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, unrelated.id)))[0]?.cwd).toBe(f.workspace); expect((await server.db.select().from(issues).where(eq(issues.id, f.issueId)))[0]?.projectId).toBeNull(); }); + it("returns real page hrefs for projects and, only while their pages are routed, for workspaces", async () => { + const f = await server.fixture({ conversation: true, disableWakeOnDemand: true }); + const [{ issuePrefix }] = await server.db.select({ issuePrefix: companies.issuePrefix }).from(companies).where(eq(companies.id, f.companyId)); + const [workspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, sourceIssueId: f.issueId, projectId: null, + name: "Website files", cwd: f.workspace, mode: "shared_workspace", strategyType: "task_directory" }).returning(); + await server.db.update(issues).set({ projectId: null, projectWorkspaceId: null, executionWorkspaceId: workspace.id }).where(eq(issues.id, f.issueId)); + const call = (tool: string) => f.authority.execute({ tool, arguments: {}, callId: randomUUID() }) as Promise; + const projects = (await call("list_projects")).projects as Array<{ id: string; href?: string }>; + expect(projects.find(row => row.id === f.projectId)?.href).toBe(`/${issuePrefix}/projects/${f.projectId}`); + const settings = instanceSettingsService(server.db); + const workspaceHrefs = async () => [(await call("get_workspace")).workspace?.href, + ((await call("list_workspaces")).workspaces as Array<{ id: string; href?: string }>).find(row => row.id === workspace.id)?.href]; + try { + await settings.updateExperimental({ enableIsolatedWorkspaces: false }); + // The detail route redirects to the dashboard while the feature is off, so no href is offered. + expect(await workspaceHrefs()).toEqual([undefined, undefined]); + await settings.updateExperimental({ enableIsolatedWorkspaces: true }); + const href = `/${issuePrefix}/execution-workspaces/${workspace.id}`; + expect(await workspaceHrefs()).toEqual([href, href]); + } finally { await settings.updateExperimental({ enableIsolatedWorkspaces: false }); } + }); }); diff --git a/server/src/routes/project-tools.ts b/server/src/routes/project-tools.ts index 29fa92111dc..cc744edf30c 100644 --- a/server/src/routes/project-tools.ts +++ b/server/src/routes/project-tools.ts @@ -1,5 +1,6 @@ import { Router } from "express"; -import type { Db } from "@paperclipai/db"; +import { eq } from "drizzle-orm"; +import { companies, type Db } from "@paperclipai/db"; import { projectToolContext } from "../services/project-tool-context.js"; import { callProjectTool, projectToolDefinitions } from "../services/project-tools.js"; import { assertCompanyAccess } from "./authz.js"; @@ -27,6 +28,8 @@ export function projectToolRoutes(db: Db) { token: req.header("authorization")!.replace(/^Bearer\s+/i, ""), companyId: context.run.companyId, issueId: context.issue.id, agentId: context.run.agentId, conversation: Boolean(context.issue.conversationAgentId), + companyPrefix: params.name === "list_projects" ? (await db.select({ issuePrefix: companies.issuePrefix }).from(companies) + .where(eq(companies.id, context.run.companyId)))[0]?.issuePrefix : null, }); return send({ content: [{ type: "text", text: JSON.stringify(result) }], structuredContent: result }); } catch (error) { diff --git a/server/src/services/agent-conversations.ts b/server/src/services/agent-conversations.ts index d7e2eb9ca8d..4e81a5464d4 100644 --- a/server/src/services/agent-conversations.ts +++ b/server/src/services/agent-conversations.ts @@ -116,7 +116,7 @@ Create ordinary assigned tasks; this conversation is never a parent. Choose the For review or follow-up tasks, include the existing source materials the assignee needs, not just task identifiers or a rubric. Read the source task's saved documents and comments, then copy the relevant existing content into the handoff or attach resources the recipient can actually read. A new task's tools may only expose its own documents and history. Supplying existing source material is coordination, not creating the assigned deliverable. -For task links, use the company-prefixed relative path //issues/. The company prefix is the part of a task identifier before its final hyphen and number: for task RUN-1 the prefix is RUN. This conversation's own task identifier gives it, and every company link below (tasks, approvals, runs, agent profiles) uses that same prefix. Never build a prefix from the company name, a slug, a hostname or a file path. Write every company link as a Markdown link, such as [Review the approval](//approvals/): a bare path or a path in inline code is not clickable in this conversation. Start each link target with exactly one slash: a target beginning with //, such as ///issues/, points to a different website. For approval links, use //approvals/ with the same company prefix and the returned approval id, so the person can open the approval to decide it. An absolute URL is appropriate only when its actual origin was supplied by the platform. Never invent a hostname for a Paperclip link. For an execution run, link //agents//runs/ using the run's returned agentId. Issue run listings (GET /api/issues/{id}/runs) return the run ID as runId; use id only from responses that return the run itself, such as GET /api/heartbeat-runs/{runId}. Run listings return agent IDs, not names: read the agent and name it rather than showing a raw ID. +For task links, use the company-prefixed relative path //issues/. The company prefix is the part of a task identifier before its final hyphen and number: for task RUN-1 the prefix is RUN. This conversation's own task identifier gives it, and every company link below (tasks, approvals, runs, agent profiles, projects) uses that same prefix. Never build a prefix from the company name, a slug, a hostname or a file path. Write every company link as a Markdown link, such as [Review the approval](//approvals/): a bare path or a path in inline code is not clickable in this conversation. Start each link target with exactly one slash: a target beginning with //, such as ///issues/, points to a different website. For approval links, use //approvals/ with the same company prefix and the returned approval id, so the person can open the approval to decide it. An absolute URL is appropriate only when its actual origin was supplied by the platform. Never invent a hostname for a Paperclip link. For an execution run, link //agents//runs/ using the run's returned agentId. Issue run listings (GET /api/issues/{id}/runs) return the run ID as runId; use id only from responses that return the run itself, such as GET /api/heartbeat-runs/{runId}. Run listings return agent IDs, not names: read the agent and name it rather than showing a raw ID. For a project, link //projects/. When a tool result gives a record's href, such as a project or a workspace, link that href; a workspace without an href has no page on this instance, so name it instead of linking it. When a reply concerns a specific agent, such as one you paused, resumed, configured, or reported on, link its profile at //agents/ using the agent's returned urlKey or id. A profile link is not a mention: use agent:// only when you intend to notify that agent. When you send a person to a control on an agent's profile, link the page that holds it instead of the bare profile: //agents//api-keys for API keys, /identity for identity and instructions, /runtime for harness settings, /secrets for secrets and variables, /permissions for permissions. When you cannot hire an agent yourself, send the person to the hire form at //agents/new, where they can hire it directly, and to your own /permissions page, where they can turn on hiring for you. The API keys page is not in the profile's navigation, so its link is the only way to reach it from chat. diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index dcfc6b26ef5..832581b6030 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -851,7 +851,8 @@ export class PaperclipRunnerToolAuthority { const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL; const token = this.#apiToken(context); if (!apiUrl || !token) throw new Error("Workspace tool authentication is unavailable"); - return callTaskWorkspaceTool({ name: call.tool, arguments: input, apiUrl, token, companyId: this.binding.companyId, issueId: this.binding.issueId, workspaceRoot: this.binding.workspaceRoot }); + return callTaskWorkspaceTool({ name: call.tool, arguments: input, apiUrl, token, companyId: this.binding.companyId, issueId: this.binding.issueId, workspaceRoot: this.binding.workspaceRoot, + workspacePagePrefix: ["get_workspace", "list_workspaces"].includes(call.tool) ? await this.#workspacePagePrefix() : null }); } case "create_project": case "list_project_repositories": @@ -861,7 +862,8 @@ export class PaperclipRunnerToolAuthority { if (!apiUrl || !token) throw new Error("Project tool authentication is unavailable"); return callProjectTool({ name: call.tool, arguments: input, apiUrl, token, companyId: this.binding.companyId, issueId: this.binding.issueId, agentId: this.binding.agentId, - conversation: Boolean(context.issue.conversationAgentId) }); + conversation: Boolean(context.issue.conversationAgentId), + companyPrefix: call.tool === "list_projects" ? await this.#companyPagePrefix() : null }); } case "search_api": return searchRunnerApi(call.arguments); case "hire_agent": { @@ -1079,6 +1081,19 @@ export class PaperclipRunnerToolAuthority { issueId: this.binding.issueId, workMode: context.issue.workMode }); } + /** Company prefix for page hrefs in tool results, so chat replies link real pages. */ + async #companyPagePrefix() { + const [row] = await this.db.select({ issuePrefix: companies.issuePrefix }).from(companies) + .where(eq(companies.id, this.binding.companyId)); + return row?.issuePrefix ?? null; + } + + /** Workspace pages are routed only while isolated workspaces are enabled. */ + async #workspacePagePrefix() { + return (await instanceSettingsService(this.db).getExperimental()).enableIsolatedWorkspaces + ? this.#companyPagePrefix() : null; + } + #apiToken(context: { actor: { adapterType: string }; issue: { workMode: string }; diff --git a/server/src/services/project-tools.ts b/server/src/services/project-tools.ts index 29e7c244609..e5315208769 100644 --- a/server/src/services/project-tools.ts +++ b/server/src/services/project-tools.ts @@ -19,6 +19,8 @@ export function projectToolDefinitions(workMode: string, includeTask = false) { export async function callProjectTool(input: { name: string; arguments: Record; apiUrl: string; token: string; companyId: string; issueId: string; agentId: string; conversation: boolean; receiptOnly?: boolean; + /** The company's issue prefix; list_projects rows then carry their page href. */ + companyPrefix?: string | null; }) { if (input.receiptOnly && input.name !== "create_project") throw badRequest("Receipt recovery is only supported for project creation"); const args = input.arguments; @@ -71,5 +73,11 @@ export async function callProjectTool(input: { if (input.receiptOnly && response.status === 204) return undefined; const result = await response.json(); if (!response.ok) throw new Error(typeof result.error === "string" ? result.error : `Project tool failed (${response.status})`); + // Chat replies link a project only when they know its real page; without an + // href the agent wrote the route as an unclickable path or omitted it. + if (page && input.companyPrefix && Array.isArray(result.projects)) { + return { ...result, projects: result.projects.map((project: { id: string }) => ({ ...project, + href: `/${encodeURIComponent(input.companyPrefix!)}/projects/${encodeURIComponent(project.id)}` })) }; + } return result; } diff --git a/server/src/services/task-workspace-tools.ts b/server/src/services/task-workspace-tools.ts index dd4bc47a001..b06a7eb07eb 100644 --- a/server/src/services/task-workspace-tools.ts +++ b/server/src/services/task-workspace-tools.ts @@ -31,6 +31,12 @@ function repositoryView(repo: Record) { state: repo.state, pinnedCommit: repo.pinnedCommit, branchName: repo.branchName }; } +/** The workspace detail page exists only while isolated workspaces are enabled, + * so callers pass the company prefix only then; a missing href means no page. */ +function workspaceHref(prefix: string | null | undefined, id: unknown) { + return prefix && typeof id === "string" ? { href: `/${encodeURIComponent(prefix)}/execution-workspaces/${encodeURIComponent(id)}` } : {}; +} + function workspacePage(rows: Array>, limit: number) { const workspaces: Array> = []; const result = () => ({ workspaces, limit, returnedCount: workspaces.length, @@ -57,7 +63,7 @@ function workspacePage(rows: Array>, limit: number) { return result(); } -export async function callTaskWorkspaceTool(input: { name: string; arguments: Record; apiUrl: string; token: string; companyId: string; issueId: string; workspaceRoot?: string }) { +export async function callTaskWorkspaceTool(input: { name: string; arguments: Record; apiUrl: string; token: string; companyId: string; issueId: string; workspaceRoot?: string; workspacePagePrefix?: string | null }) { let route = `/issues/${input.issueId}/workspace`; let method = "GET"; let body: unknown; @@ -78,7 +84,7 @@ export async function callTaskWorkspaceTool(input: { name: string; arguments: Re const result = await response.json(); if (!response.ok) throw new Error(typeof result.error === "string" ? result.error : `Workspace tool failed (${response.status})`); if (page) { - return workspacePage(result, page.limit); + return workspacePage(result.map((row: Record) => ({ ...row, ...workspaceHref(input.workspacePagePrefix, row.id) })), page.limit); } if (input.name === "prepare_repository") { const { operationId, ...rest } = result; @@ -90,7 +96,8 @@ export async function callTaskWorkspaceTool(input: { name: string; arguments: Re const workspace = result.workspace; return { issueId: result.issueId, bindingRevision: result.bindingRevision, selection: result.selection, pendingSelection: result.pendingSelection, cwd: input.workspaceRoot ?? null, - workspace: workspace ? { id: workspace.id, projectId: workspace.projectId, mode: workspace.mode, status: workspace.status } : null, + workspace: workspace ? { id: workspace.id, projectId: workspace.projectId, mode: workspace.mode, status: workspace.status, + ...workspaceHref(input.workspacePagePrefix, workspace.id) } : null, repositories: (result.repositories ?? []).map(repositoryView), capabilities: result.capabilities, fileLocations: result.fileLocations }; }