diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index 43e1e03863..c0a20dc6d0 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -96,6 +96,64 @@ async function seedAgentWithFileLink(input: LinkedFile) { } test.describe("CodeMirror workspace file editing", () => { + test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => { + const session = await seedMockAgentWorkspace({ + repoPrefix: "encoded-markdown-link-", + title: "Non-ASCII file link", + initialPrompt: "Show the report link", + featureValues: { mockAssistantResponse: "[报告](docs/reports/开户赠金.md)" }, + }); + try { + await mkdir(path.join(session.cwd, "docs/reports"), { recursive: true }); + await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n"); + await openAgentRoute(page, session); + await openAssistantMarkdownLink(page, "报告"); + await expectFileTabOpen(page, "docs/reports/开户赠金.md"); + await expect( + page + .getByTestId("workspace-file-pane") + .filter({ visible: true }) + .getByText("Report opened", { exact: true }), + ).toBeVisible(); + await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") }); + } finally { + await session.cleanup(); + } + }); + + for (const order of ["raw-first", "href-first"] as const) { + test(`opens literal and encoded percent filenames independently ${order}`, async ({ page }) => { + const session = await seedMockAgentWorkspace({ + repoPrefix: "percent-link-cache-", + title: "Distinct file links", + initialPrompt: "Show both filenames", + featureValues: { + mockAssistantResponse: "Raw `literal%20.md`. [Decoded](literal%20.md).", + }, + }); + try { + await writeFile(path.join(session.cwd, "literal%20.md"), "# Literal filename\n"); + await writeFile(path.join(session.cwd, "literal .md"), "# Decoded filename\n"); + await openAgentRoute(page, session); + const links = order === "raw-first" ? ["raw", "href"] : ["href", "raw"]; + for (const link of links) { + await returnToAssistant(page, session.agentId); + if (link === "raw") { + await openAssistantInlinePath(page, "literal%20.md"); + await expectFileTabOpen(page, "literal%20.md"); + await expectPreviewText(page, "Literal filename"); + } else { + await openAssistantMarkdownLink(page, "Decoded"); + await expectFileTabOpen(page, "literal .md"); + await expectPreviewText(page, "Decoded filename"); + } + } + } finally { + await session.cleanup(); + } + }); + } + test("jumps to each relative reference to the same file", async ({ page }) => { const session = await seedMockAgentWorkspace({ repoPrefix: "relative-reference-qa-", @@ -757,3 +815,24 @@ test.describe("CodeMirror workspace file editing", () => { await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible(); }); }); + +async function openAssistantMarkdownLink(page: Page, name: string): Promise { + await page.getByRole("link", { name, exact: true }).first().click(); +} + +async function returnToAssistant(page: Page, agentId: string): Promise { + await page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).click(); +} + +async function openAssistantInlinePath(page: Page, token: string): Promise { + await page.getByText(token, { exact: true }).click(); +} + +async function expectPreviewText(page: Page, text: string): Promise { + await expect( + page + .getByTestId("workspace-file-pane") + .filter({ visible: true }) + .getByText(text, { exact: true }), + ).toBeVisible(); +} diff --git a/packages/app/src/assistant-file-links/parse.test.ts b/packages/app/src/assistant-file-links/parse.test.ts index 12921b855c..3390d0cfa9 100644 --- a/packages/app/src/assistant-file-links/parse.test.ts +++ b/packages/app/src/assistant-file-links/parse.test.ts @@ -417,6 +417,128 @@ describe("parseAssistantFileLink", () => { } expect(() => parseFileProtocolUrl("file:///tmp/100% packet loss")).not.toThrow(); }); + + it("decodes percent-encoded relative markdown hrefs", () => { + // markdown-it percent-encodes non-ASCII characters in hrefs, so a link + // written as [x](docs/reports/开户赠金.md) arrives here percent-encoded. + expect( + parseAssistantFileLink("docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md", { + workspaceRoot: "/Users/test/project", + decodeHref: true, + }), + ).toEqual({ + raw: "docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md", + path: "/Users/test/project/docs/reports/开户赠金.md", + lineStart: undefined, + lineEnd: undefined, + }); + }); + + it("decodes percent-encoded spaces in relative markdown hrefs", () => { + expect( + parseAssistantFileLink("docs/my%20file.md#L5", { + workspaceRoot: "/Users/test/project", + decodeHref: true, + }), + ).toEqual({ + raw: "docs/my%20file.md#L5", + path: "/Users/test/project/docs/my file.md", + lineStart: 5, + lineEnd: undefined, + }); + }); + + it("decodes percent-encoded absolute hrefs with line suffixes", () => { + expect( + parseAssistantFileLink("/tmp/%E5%BC%80%E6%88%B7.md:3", { + workspaceRoot: "/Users/test/project", + decodeHref: true, + }), + ).toEqual({ + raw: "/tmp/%E5%BC%80%E6%88%B7.md:3", + path: "/tmp/开户.md", + lineStart: 3, + lineEnd: undefined, + }); + }); + + it("decodes percent-encoded home-relative hrefs", () => { + expect( + parseAssistantFileLink("~/notes/%E5%BC%80%E6%88%B7.md", { + workspaceRoot: "/Users/test/project", + decodeHref: true, + }), + ).toEqual({ + raw: "~/notes/%E5%BC%80%E6%88%B7.md", + path: "~/notes/开户.md", + lineStart: undefined, + lineEnd: undefined, + }); + }); + + it("decodes percent-encoded Windows hrefs", () => { + expect( + parseAssistantFileLink("C:/repo/my%20dir/app.tsx#L8", { + workspaceRoot: "C:/repo", + decodeHref: true, + }), + ).toEqual({ + raw: "C:/repo/my%20dir/app.tsx#L8", + path: "C:/repo/my dir/app.tsx", + lineStart: 8, + lineEnd: undefined, + }); + }); + + it("keeps literal percent signs in raw paths that are not hrefs", () => { + // Inline-code and linkified tokens are raw file paths: a real file named + // "my%20file.md" must not be decoded into "my file.md". + expect( + parseAssistantFileLink("docs/my%20file.md", { + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + raw: "docs/my%20file.md", + path: "/Users/test/project/docs/my%20file.md", + lineStart: undefined, + lineEnd: undefined, + }); + expect( + parseAssistantFileLink("/tmp/my%20file.md:3", { + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + raw: "/tmp/my%20file.md:3", + path: "/tmp/my%20file.md", + lineStart: 3, + lineEnd: undefined, + }); + }); + + it("keeps non-ASCII characters in raw absolute paths", () => { + // `new URL` percent-encodes non-ASCII, so raw paths must not be routed + // through URL parsing — `/tmp/开户.md` must round-trip unchanged. + expect( + parseAssistantFileLink("/tmp/开户.md", { + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + raw: "/tmp/开户.md", + path: "/tmp/开户.md", + lineStart: undefined, + lineEnd: undefined, + }); + expect( + parseAssistantFileLink("/tmp/开户.md#L4", { + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + raw: "/tmp/开户.md#L4", + path: "/tmp/开户.md", + lineStart: 4, + lineEnd: undefined, + }); + }); }); describe("normalizeInlinePathTarget", () => { @@ -469,3 +591,23 @@ describe("normalizeInlinePathTarget", () => { }); }); }); + +describe("encoded filename line markers", () => { + it.each(["docs/report.md%3A12", "docs/report.md%2812%2C4%29"])( + "keeps encoded line syntax in the filename %s", + (href) => { + expect( + parseAssistantFileLink(href, { + workspaceRoot: "/workspace", + decodeHref: true, + }), + ).toMatchObject({ path: `/workspace/${decodeURIComponent(href)}` }); + expect( + parseAssistantFileLink(href, { + workspaceRoot: "/workspace", + decodeHref: true, + })?.lineStart, + ).toBeUndefined(); + }, + ); +}); diff --git a/packages/app/src/assistant-file-links/parse.ts b/packages/app/src/assistant-file-links/parse.ts index 092f3b4dcd..0a8089acca 100644 --- a/packages/app/src/assistant-file-links/parse.ts +++ b/packages/app/src/assistant-file-links/parse.ts @@ -80,6 +80,13 @@ const ASSISTANT_FILE_EXTENSIONS = new Set([ export interface AssistantHrefParseOptions { workspaceRoot?: string; + /** + * True only when `value` is a markdown href — the renderer percent-encodes + * non-ASCII and space characters, so the path must be decoded. Raw path + * text (inline code, linkified tokens) is not encoded; decoding it would + * send a real file named `my%20file.md` to `my file.md`. + */ + decodeHref?: boolean; } export type AssistantFileLinkClassification = @@ -234,13 +241,20 @@ export function parseFileProtocolUrl(value: string): InlinePathTarget | null { }; } -function parseAssistantInlinePathLink(value: string): InlinePathTarget | null { +function parseAssistantInlinePathLink( + value: string, + options: Pick = {}, +): InlinePathTarget | null { const inlinePathTarget = parseInlinePathToken(value); if (!inlinePathTarget) { return null; } - const normalizedPath = normalizePathToken(inlinePathTarget.path); + let pathPart = inlinePathTarget.path; + if (options.decodeHref) { + pathPart = safeDecodeURIComponent(pathPart); + } + const normalizedPath = normalizePathToken(pathPart); if (!normalizedPath || !isAbsolutePath(normalizedPath)) { return null; } @@ -277,7 +291,7 @@ export function classifyAssistantFileLink( return null; } - if (isAmbiguousWorkspaceCandidate(trimmed, target, options.workspaceRoot)) { + if (isAmbiguousWorkspaceCandidate(trimmed, target, options)) { return { kind: "ambiguousFileCandidate", target, @@ -308,32 +322,19 @@ export function parseAssistantFileLink( return null; } - const inlinePathTarget = parseAssistantInlinePathLink(trimmed); + const inlinePathTarget = parseAssistantInlinePathLink(trimmed, options); if (inlinePathTarget) { return inlinePathTarget; } - const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/); - if (windowsPathMatch) { - const normalizedPath = normalizePathToken(windowsPathMatch[1] ?? ""); - if (!normalizedPath) { - return null; - } - - const lines = parseLineFragment(windowsPathMatch[2] ?? ""); - if (!lines) { - return null; - } - - return { - raw: value, - path: normalizedPath, - ...lines, - }; + const windowsTarget = parseWindowsFileLink(trimmed, value, options); + if (windowsTarget) { + return windowsTarget; } const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, { workspaceRoot: options.workspaceRoot, + decodeHref: options.decodeHref, }); if (relativeTarget) { return relativeTarget; @@ -343,19 +344,33 @@ export function parseAssistantFileLink( return null; } - let parsedUrl: URL; - try { - parsedUrl = new URL(trimmed, "http://paseo.invalid"); - } catch { - return null; + // Hrefs go through URL parsing so `%`-escapes and `.`/`..` segments + // normalize before decode. Raw paths must not: `new URL` percent-encodes + // non-ASCII characters, which would corrupt `/tmp/开户.md` into the + // still-encoded name. + let pathPart: string; + let hashPart: string; + if (options.decodeHref) { + let parsedUrl: URL; + try { + parsedUrl = new URL(trimmed, "http://paseo.invalid"); + } catch { + return null; + } + pathPart = safeDecodeURIComponent(parsedUrl.pathname); + hashPart = parsedUrl.hash; + } else { + const hashIndex = trimmed.indexOf("#"); + pathPart = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed; + hashPart = hashIndex >= 0 ? trimmed.slice(hashIndex) : ""; } - const normalizedPath = normalizePathToken(safeDecodeURIComponent(parsedUrl.pathname)); + const normalizedPath = normalizePathToken(pathPart); if (!normalizedPath || !isAbsolutePath(normalizedPath)) { return null; } - const lines = parseLineFragment(parsedUrl.hash); + const lines = parseLineFragment(hashPart); if (!lines) { return null; } @@ -386,11 +401,42 @@ export function isFileLookingAssistantToken(value: string): boolean { return isPlausibleAssistantLocalPath(path); } +function parseWindowsFileLink( + trimmed: string, + raw: string, + options: Pick = {}, +): InlinePathTarget | null { + const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/); + if (!windowsPathMatch) { + return null; + } + + let pathPart = windowsPathMatch[1] ?? ""; + if (options.decodeHref) { + pathPart = safeDecodeURIComponent(pathPart); + } + const normalizedPath = normalizePathToken(pathPart); + if (!normalizedPath) { + return null; + } + + const lines = parseLineFragment(windowsPathMatch[2] ?? ""); + if (!lines) { + return null; + } + + return { + raw, + path: normalizedPath, + ...lines, + }; +} + function parseWorkspaceRelativeFileLink( value: string, options: AssistantHrefParseOptions, ): InlinePathTarget | null { - const parsed = parseLocalPathParts(value); + const parsed = parseLocalPathParts(value, options); if (!parsed || isAbsolutePath(parsed.path)) { return null; } @@ -422,7 +468,11 @@ function parseWorkspaceRelativeFileLink( function parseLocalPathParts( value: string, -): { path: string; lines: Pick } | null { + options: Pick = {}, +): { + path: string; + lines: Pick; +} | null { const normalized = normalizePathToken(value); if (!normalized || normalized.includes("?")) { return null; @@ -436,14 +486,19 @@ function parseLocalPathParts( return null; } + // Split literal line markers before decoding; encoded punctuation belongs + // to the filename. Raw inline-code/path text stays untouched. const inlinePathTarget = parseInlinePathToken(beforeHash); if (inlinePathTarget) { - if (!isPlausibleAssistantLocalPath(inlinePathTarget.path)) { + let pathPart = inlinePathTarget.path; + if (options.decodeHref) { + pathPart = safeDecodeURIComponent(pathPart); + } + if (!isPlausibleAssistantLocalPath(pathPart)) { return null; } - return { - path: inlinePathTarget.path, + path: pathPart, lines: { lineStart: inlinePathTarget.lineStart, lineEnd: inlinePathTarget.lineEnd, @@ -455,12 +510,17 @@ function parseLocalPathParts( return null; } - if (!isPlausibleAssistantLocalPath(beforeHash)) { + let decodedBeforeHash = beforeHash; + if (options.decodeHref) { + decodedBeforeHash = safeDecodeURIComponent(beforeHash); + } + const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash; + if (!isPlausibleAssistantLocalPath(plausiblePath)) { return null; } return { - path: beforeHash, + path: decodedBeforeHash, lines: fragmentLines, }; } @@ -545,14 +605,14 @@ function isExternalHref(value: string): boolean { function isAmbiguousWorkspaceCandidate( value: string, target: InlinePathTarget, - workspaceRoot?: string, + options: AssistantHrefParseOptions = {}, ): boolean { - const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot); + const normalizedWorkspaceRoot = normalizePathInput(options.workspaceRoot); if (!normalizedWorkspaceRoot || !isAllowedAbsolutePath(target.path, normalizedWorkspaceRoot)) { return false; } - const parsed = parseLocalPathParts(value); + const parsed = parseLocalPathParts(value, options); if (!parsed || isAbsolutePath(parsed.path)) { return false; } diff --git a/packages/app/src/assistant-file-links/resolver.test.ts b/packages/app/src/assistant-file-links/resolver.test.ts index 312bfff1fd..384c3b6857 100644 --- a/packages/app/src/assistant-file-links/resolver.test.ts +++ b/packages/app/src/assistant-file-links/resolver.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it } from "vitest"; +import MarkdownIt from "markdown-it"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { classifyForResolution, fetchDaemonResolution, @@ -14,6 +18,25 @@ const CONTEXT: AssistantFileLinkContext = { workspaceRoot: "/Users/test/project", }; +it("opens the existing non-ASCII file named by a rendered markdown link", async () => { + const workspaceRoot = await mkdtemp(path.join(tmpdir(), "assistant-file-link-")); + try { + await mkdir(path.join(workspaceRoot, "docs/reports"), { recursive: true }); + await writeFile(path.join(workspaceRoot, "docs/reports/开户赠金.md"), "report content"); + const tokens = new MarkdownIt().parseInline("[报告](docs/reports/开户赠金.md)", {}); + const href = tokens[0].children?.find((token) => token.type === "link_open")?.attrGet("href"); + expect(href).toBe("docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md"); + const resolution = classifyForResolution({ href: href! }, { workspaceRoot }); + expect(resolution.kind).toBe("resolved"); + if (resolution.kind !== "resolved" || resolution.value.kind !== "file") { + throw new Error("Expected a directly resolved file link"); + } + expect(await readFile(resolution.value.target.path, "utf8")).toBe("report content"); + } finally { + await rm(workspaceRoot, { recursive: true, force: true }); + } +}); + function resolvedSuggestions( entries: DirectorySuggestionResult["entries"], ): DirectorySuggestionResult { @@ -52,6 +75,27 @@ const unavailableSuggestions: GetDirectorySuggestions = async () => { }; describe("classifyForResolution", () => { + it.each([ + ["C:/repo/my%20file.md", undefined], + ["C:\\repo\\my%20file.md", undefined], + ["C:/repo/my%20file.md:12", 12], + ["C:\\repo\\my%20file.md:12", 12], + ])("keeps the literal filename in Windows inline code %s", (href, lineStart) => { + for (const text of [href, undefined]) { + const result = classifyForResolution( + { href, text, sourceType: "inline-code" }, + { workspaceRoot: "C:/repo" }, + ); + expect(result).toMatchObject({ + kind: "resolved", + value: { kind: "file", target: { path: "C:/repo/my%20file.md", lineStart } }, + }); + } + expect(classifyForResolution({ href }, { workspaceRoot: "C:/repo" })).toMatchObject({ + kind: "resolved", + value: { kind: "file", target: { path: "C:/repo/my file.md", lineStart } }, + }); + }); it("returns the directFile target synchronously", () => { const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT); @@ -165,6 +209,46 @@ describe("classifyForResolution", () => { expect(result).toEqual({ kind: "resolved", value: { kind: "ignored" } }); }); + + it("decodes percent-encoded markdown hrefs", () => { + const result = classifyForResolution({ href: "docs/my%20file.md" }, CONTEXT); + + expect(result).toEqual({ + kind: "resolved", + value: { + kind: "file", + target: { + raw: "docs/my%20file.md", + path: "/Users/test/project/docs/my file.md", + lineStart: undefined, + lineEnd: undefined, + }, + }, + }); + }); + + it("keeps literal percent signs in inline-code paths", () => { + const result = classifyForResolution( + { + href: "docs/my%20file.md", + text: "docs/my%20file.md", + sourceType: "inline-code", + }, + CONTEXT, + ); + + expect(result).toEqual({ + kind: "needsLookup", + ambiguousQuery: "docs/my%20file.md", + token: "docs/my%20file.md", + target: { + raw: "docs/my%20file.md", + path: "/Users/test/project/docs/my%20file.md", + lineStart: undefined, + lineEnd: undefined, + }, + }); + }); }); describe("fetchDaemonResolution", () => { diff --git a/packages/app/src/assistant-file-links/resolver.ts b/packages/app/src/assistant-file-links/resolver.ts index 5fa4849fc8..774d39278b 100644 --- a/packages/app/src/assistant-file-links/resolver.ts +++ b/packages/app/src/assistant-file-links/resolver.ts @@ -108,13 +108,15 @@ export function classifyForResolution( source: AssistantFileLinkSource, context: AssistantFileLinkContext, ): AssistantFileLinkResolution { - const token = getAssistantFileLinkToken(source).trim(); + const tokenInfo = getAssistantFileLinkTokenInfo(source); + const token = tokenInfo.token.trim(); if (!token) { return { kind: "resolved", value: { kind: "ignored" } }; } const classification = classifyAssistantFileLink(token, { workspaceRoot: context.workspaceRoot, + decodeHref: tokenInfo.fromHref, }); if (!classification) { return { kind: "resolved", value: { kind: "ignored" } }; @@ -148,14 +150,26 @@ export function classifyForResolution( } export function getAssistantFileLinkToken(source: AssistantFileLinkSource): string { - if (isLinkifiedSource(source) || source.sourceType === "inline-code") { + return getAssistantFileLinkTokenInfo(source).token; +} + +// Markdown link hrefs are percent-encoded by the renderer; inline-code and +// linkified text are raw file paths that must not be decoded. +function getAssistantFileLinkTokenInfo(source: AssistantFileLinkSource): { + token: string; + fromHref: boolean; +} { + if (source.sourceType === "inline-code") { + return { token: source.text?.trim() || source.href, fromHref: false }; + } + if (isLinkifiedSource(source)) { const text = source.text?.trim(); if (text && isFileLookingAssistantToken(text)) { - return text; + return { token: text, fromHref: false }; } } - return source.href; + return { token: source.href, fromHref: true }; } export function getAmbiguousSuggestionQuery( diff --git a/packages/app/src/assistant-file-links/use-file-link.ts b/packages/app/src/assistant-file-links/use-file-link.ts index 194405d2ec..603645f0fe 100644 --- a/packages/app/src/assistant-file-links/use-file-link.ts +++ b/packages/app/src/assistant-file-links/use-file-link.ts @@ -29,14 +29,21 @@ export interface AssistantFileLinkActions { canResolveFile(source: AssistantFileLinkSource): boolean; } +interface AssistantFileLinkLookupKey { + query: string; + path: string; + lineStart: number | null; + lineEnd: number | null; +} + type AssistantFileLinkQueryKey = readonly [ "assistantFileLink", string | null, string | null, - string, + AssistantFileLinkLookupKey | null, ]; -const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const; +const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, null] as const; export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult { const { t } = useTranslation(); @@ -59,7 +66,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult ? assistantFileLinkQueryKey({ serverId, workspaceRoot, - token: resolution.token, + resolution, }) : DISABLED_QUERY_KEY, [resolution, serverId, workspaceRoot], @@ -179,7 +186,7 @@ function openAssistantFileLink(input: { const capturedQueryKey = assistantFileLinkQueryKey({ serverId: capturedConfig.serverId, workspaceRoot: capturedConfig.workspaceRoot, - token: capturedResolution.token, + resolution: capturedResolution, }); const run = async () => { @@ -245,9 +252,19 @@ function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSour function assistantFileLinkQueryKey(input: { serverId?: string; workspaceRoot?: string; - token: string; + resolution: Extract; }): AssistantFileLinkQueryKey { - return ["assistantFileLink", input.serverId ?? null, input.workspaceRoot ?? null, input.token]; + return [ + "assistantFileLink", + input.serverId ?? null, + input.workspaceRoot ?? null, + { + query: input.resolution.ambiguousQuery, + path: input.resolution.target.path, + lineStart: input.resolution.target.lineStart ?? null, + lineEnd: input.resolution.target.lineEnd ?? null, + }, + ]; } async function dispatchResolvedLink(input: {