From c6781f5b2ca659203c5954f582840cb199cd2eed Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:42:00 -0600 Subject: [PATCH] fix(chat): keep company links written as //PREFIX/... inside the app (#19986) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - People ask chat agents about their work, and agents answer with Markdown links to tasks, runs and agents > - A chat agent sometimes writes a company link target with two leading slashes, for example `(//ACME/agents//runs/)` > - A browser reads that target as a link to a website whose host is `ACME`, so the link leaves the app and fails > - Task links still worked because the issue-reference parser finds `/issues/` in any path, but run, approval and agent links did not > - This pull request renders such a link as the company path when its "host" is exactly a known company prefix, and tells chat agents to start link targets with one slash > - The benefit is that a person can open the run, approval or agent the agent linked ## Linked Issues or Issue Description No public issue exists. Related, but different: #17864 (same-origin absolute links). **What happened** In a chat-feature eval, a person asked what happened on a failed website attempt. The agent read the run, explained the failure and linked both runs as `[...](//ACME/agents//runs/)`. The rendered anchors kept `href="//ACME/..."`, which opens `http://acme/...`. The agent made the same `//` slip in an earlier attempt of the same case. **Expected behavior** A link to a company page opens that page in the app. A target like `//ACME/...`, where `ACME` is a company prefix, can only mean the company path `/ACME/...`. **Steps to reproduce** 1. Render Markdown `[Failed run](//ACME/agents/a/runs/r)` in a chat or comment while the viewer belongs to company prefix `ACME`. 2. Inspect the anchor. 3. See `href="//ACME/agents/a/runs/r"`. Click it, and the browser goes to host `acme`. ## What Changed - `ui/src/components/MarkdownBody.tsx`: add `companyPathFromProtocolRelativeHref`. When a link target is `//...` and `` is exactly one of the viewer's company prefixes, render it as `/...` (query and fragment kept). Other protocol-relative links, absolute URLs and renders without known prefixes stay unchanged. - `server/src/services/agent-conversations.ts`: chat guidance now says to start each link target with exactly one slash, because a `//` target points to a different website. - Tests for both. ## Verification - `cd ui && pnpm exec vitest run src/components/MarkdownBody.test.tsx`: 69 passed. With the normalization removed, the new company-path test fails (red/green checked). - `server/src/__tests__/agent-conversations.test.ts` gets one added `toContain` assertion for the new guidance sentence. I could not run that suite locally in this worktree (its linked workspace packages are from an older checkout), so CI supplies it. - End to end: the change is cherry-picked onto a chat-eval case branch and run through the remote `runs-adapters.inspect-failed-run` eval. ## Risks - Low risk. The rewrite applies only when the protocol-relative host matches a company prefix the viewer already has, exactly and case-sensitively. A real single-label intranet host with the same uppercase name as a company prefix would now open the company path instead. Chat guidance already forbids invented hostnames for Paperclip links. - Guidance text grows by one sentence. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .../src/__tests__/agent-conversations.test.ts | 1 + server/src/services/agent-conversations.ts | 2 +- ui/src/components/MarkdownBody.test.tsx | 25 ++++++++++++++++++- ui/src/components/MarkdownBody.tsx | 18 +++++++++++-- 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/server/src/__tests__/agent-conversations.test.ts b/server/src/__tests__/agent-conversations.test.ts index 03d7dae24e1..ad3ee277347 100644 --- a/server/src/__tests__/agent-conversations.test.ts +++ b/server/src/__tests__/agent-conversations.test.ts @@ -1048,6 +1048,7 @@ describe("chat prompt policy", () => { expect(AGENT_CHAT_DIRECTIVE).toContain("Never invent a hostname for a Paperclip link."); expect(AGENT_CHAT_DIRECTIVE).toContain("Write every company link as a Markdown link"); expect(AGENT_CHAT_DIRECTIVE).toContain("a bare path or a path in inline code is not clickable"); + expect(AGENT_CHAT_DIRECTIVE).toContain("Start each link target with exactly one slash"); }); it.each([true, false])("preserves rejected-plan changes in task markdown (includeDescription=%s)", (includeDescription) => { diff --git a/server/src/services/agent-conversations.ts b/server/src/services/agent-conversations.ts index f5797934bc8..c601099276d 100644 --- a/server/src/services/agent-conversations.ts +++ b/server/src/services/agent-conversations.ts @@ -110,7 +110,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. 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) 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. 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. diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index d1597c163c8..3496b908dc0 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -14,7 +14,7 @@ import { appearanceForPalette, } from "@paperclipai/shared"; import { ThemeProvider } from "../context/ThemeContext"; -import { MarkdownBody } from "./MarkdownBody"; +import { companyPathFromProtocolRelativeHref, MarkdownBody } from "./MarkdownBody"; import { StatusIcon } from "./StatusIcon"; import { taskStatusIconProps } from "../lib/task-status-presentation"; import type { IssueBlockerAttention } from "@paperclipai/shared"; @@ -102,6 +102,29 @@ function renderMarkdown( } describe("MarkdownBody", () => { + it("renders a company link written as //PREFIX/... as that company path", () => { + mockUseOptionalCompany.mockReturnValue({ companies: [{ issuePrefix: "RUN" }] }); + const html = renderMarkdown("[Failed run](//RUN/agents/agent-1/runs/run-1)"); + expect(html).toContain('href="/RUN/agents/agent-1/runs/run-1"'); + expect(html).not.toContain('href="//RUN'); + expect(html).not.toContain('target="_blank"'); + }); + + it("keeps the query and fragment of a normalized company link", () => { + expect(companyPathFromProtocolRelativeHref("//RUN/issues/RUN-1#document-plan", ["RUN"])).toBe("/RUN/issues/RUN-1#document-plan"); + expect(companyPathFromProtocolRelativeHref("//RUN", ["RUN"])).toBe("/RUN"); + expect(companyPathFromProtocolRelativeHref("/RUN/issues/RUN-1", ["RUN"])).toBe("/RUN/issues/RUN-1"); + expect(companyPathFromProtocolRelativeHref("https://RUN/x", ["RUN"])).toBe("https://RUN/x"); + }); + + it("leaves protocol-relative links to other hosts unchanged", () => { + mockUseOptionalCompany.mockReturnValue({ companies: [{ issuePrefix: "RUN" }] }); + expect(renderMarkdown("[Docs](//docs.example.com/RUN/guide)")).toContain('href="//docs.example.com/RUN/guide"'); + expect(renderMarkdown("[Other](//OTHER/agents/a/runs/r)")).toContain('href="//OTHER/agents/a/runs/r"'); + mockUseOptionalCompany.mockReturnValue(null); + expect(renderMarkdown("[Unknown](//RUN/agents/a/runs/r)")).toContain('href="//RUN/agents/a/runs/r"'); + }); + it("preserves a saved document anchor in an explicit task link", () => { // Hover/focus can resolve the issue to its plain identifier. Both the old // fragment-bearing cache key and the corrected plain key model that load. diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 602d985031b..01636789cd6 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -459,6 +459,19 @@ function parseWikiLinkBody(body: string): ParsedWikiLink | null { }; } +/** + * Agents sometimes write a company link as `//PAP/agents/...`. A browser reads + * that as a protocol-relative URL for a host named after the company prefix, + * which leaves the app. When the "host" is exactly a known company prefix, the + * link can only mean the company path, so render it as `/PAP/agents/...`. + */ +export function companyPathFromProtocolRelativeHref(href: string | undefined, knownPrefixes?: readonly string[]): string | undefined { + if (!href || !knownPrefixes?.length) return href; + const match = /^\/\/([^/?#]+)((?:[/?#].*)?)$/.exec(href.trim()); + if (!match || !knownPrefixes.includes(match[1]!)) return href; + return `/${match[1]}${match[2] || ""}`; +} + function encodeWikiLinkTarget(target: string): string | null { const trimmed = target.trim(); if (!trimmed || /^[a-z][a-z\d+.-]*:/i.test(trimmed) || trimmed.startsWith("//")) return null; @@ -846,7 +859,8 @@ function MarkdownBodyImpl({ {codeChildren} ), - a: ({ node: _node, href, style: linkStyle, children: linkChildren, ...anchorProps }) => { + a: ({ node: _node, href: rawHref, style: linkStyle, children: linkChildren, ...anchorProps }) => { + const href = companyPathFromProtocolRelativeHref(rawHref, knownPrefixes); const workspaceFileRef = parseWorkspaceFileHref(href); if (workspaceFileRef) { return ( @@ -988,7 +1002,7 @@ function MarkdownBodyImpl({ }; } return map; - }, [theme, coordinator, linkIssueReferences, linkCaseReferences, externalReferenceLookup, resolveImageSrc, resolveLinkHref, onImageClick, mediaMode]); + }, [theme, coordinator, linkIssueReferences, linkCaseReferences, externalReferenceLookup, resolveImageSrc, resolveLinkHref, onImageClick, mediaMode, knownPrefixes]); return (