mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
fix(chat): keep company links written as //PREFIX/... inside the app
Chat agents sometimes write a company link target with two leading slashes, for example [Failed run](//RUN/agents/<agentId>/runs/<runId>). Browsers read that as a link to a host named RUN, so the run link left the app. Render a protocol-relative link whose host is exactly a known company prefix as that company path, and tell chat agents to start link targets with one slash. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8eea20394b
commit
8632e62833
4 files changed
+42
-4
No files matched your search
@@ -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) => {
|
||||
|
||||
@@ -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 /<prefix>/issues/<identifier>. 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](/<prefix>/approvals/<approvalId>): a bare path or a path in inline code is not clickable in this conversation. For approval links, use /<prefix>/approvals/<approvalId> 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 /<prefix>/agents/<agentId>/runs/<runId> 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 /<prefix>/issues/<identifier>. 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](/<prefix>/approvals/<approvalId>): 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 //<prefix>/issues/<identifier>, points to a different website. For approval links, use /<prefix>/approvals/<approvalId> 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 /<prefix>/agents/<agentId>/runs/<runId> 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 /<prefix>/agents/<urlKey or id> 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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
</code>
|
||||
),
|
||||
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 (
|
||||
<div
|
||||
|
||||
Reference in new issue
Block a user