Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 77 additions & 52 deletions apps/web/components/chat/message/agent-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { isWebSearchToolName } from "@/lib/chat-web-search-tools"
import {
buildCitationIndex,
fetchDocumentsByIds,
getCitationDisplay,
getDocumentSourceUrl,
isMemoryToolOutputReady,
mapDocumentsByKnownIds,
Expand All @@ -31,7 +32,9 @@ import {
extractMemoryToolOutputs,
} from "@/lib/chat-memory-tools"
import {
hasRenderableSourceAnnotations,
parseSourceAnnotatedMarkdown,
sourceAnnotatedTextRun,
stripSourceMarkup,
} from "@/lib/source-annotations"
import { modelNames, type ModelId } from "@/lib/models"
Expand Down Expand Up @@ -190,37 +193,64 @@ function CitationLink({
)
}

function sourceTitle(
function documentForCitationTarget(
target: CitationTarget,
document?: DocumentWithMemories,
): string {
documentByKnownId: Map<string, DocumentWithMemories>,
): DocumentWithMemories | undefined {
return (
document?.title?.trim() ||
target.title?.trim() ||
document?.customId ||
target.customId ||
target.documentId ||
target.sourceId
(target.documentId
? documentByKnownId.get(target.documentId)
: undefined) ??
(target.customId ? documentByKnownId.get(target.customId) : undefined)
)
}

function sourceSummary(
target: CitationTarget,
document?: DocumentWithMemories,
): string | null {
const summary =
document?.summary ||
target.summary ||
(document as { content?: string } | undefined)?.content ||
null
return summary ? summary.trim() : null
}
function MemorySourcesFallback({
citationIndex,
documentByKnownId,
}: {
citationIndex: Map<string, CitationTarget>
documentByKnownId: Map<string, DocumentWithMemories>
}) {
const sources = Array.from(citationIndex.values()).slice(0, 6)
if (sources.length === 0) return null

function sourceKind(
target: CitationTarget,
document?: DocumentWithMemories,
): string {
return (document?.type || target.type || "memory").replaceAll("_", " ")
return (
<div className="mt-3 flex flex-wrap gap-1.5 text-xs text-white/45">
<span className="mr-0.5 self-center text-white/35">Sources</span>
{sources.map((target) => {
const document = documentForCitationTarget(target, documentByKnownId)
const display = getCitationDisplay(target, document)
const url = safeExternalUrl(
document ? getDocumentSourceUrl(document) : target.url,
)
const label = `${target.sourceId}: ${display.title}`
const className =
"max-w-56 truncate rounded-full border border-white/10 bg-white/[0.035] px-2 py-1 text-white/55 transition-colors hover:bg-white/[0.06] hover:text-white/75"

return url ? (
<a
key={target.sourceId}
href={url}
target="_blank"
rel="noopener noreferrer"
className={className}
title={display.summary ?? label}
>
{label}
</a>
) : (
<span
key={target.sourceId}
className={className}
title={display.summary ?? label}
>
{label}
</span>
)
})}
</div>
)
}

function SourceCitationLink({
Expand All @@ -237,16 +267,11 @@ function SourceCitationLink({
const target = citationIndex.get(sourceId)
if (!target) return <>{children}</>

const document =
(target.documentId
? documentByKnownId.get(target.documentId)
: undefined) ??
(target.customId ? documentByKnownId.get(target.customId) : undefined)
const document = documentForCitationTarget(target, documentByKnownId)
const url = safeExternalUrl(
document ? getDocumentSourceUrl(document) : target.url,
)
const title = sourceTitle(target, document)
const summary = sourceSummary(target, document)
const display = getCitationDisplay(target, document)

return (
<span className="group/source relative inline rounded-[3px] border-b border-dotted border-white/20 bg-white/[0.025] px-px text-white/90 transition-colors hover:border-white/35 hover:bg-white/[0.045] focus-within:border-white/35 focus-within:bg-white/[0.045]">
Expand Down Expand Up @@ -274,15 +299,15 @@ function SourceCitationLink({
<span className="pointer-events-auto block rounded-xl border border-white/10 bg-[#0B0F16]/95 p-3 text-left shadow-[0_16px_44px_rgba(0,0,0,0.48)] backdrop-blur-xl">
<span className="mb-1 flex items-center justify-between gap-2">
<span className="truncate text-xs font-medium text-white/85">
{title}
{display.title}
</span>
<span className="shrink-0 rounded-full bg-white/5 px-2 py-0.5 text-[10px] capitalize text-white/40">
{sourceKind(target, document)}
{display.kind}
</span>
</span>
{summary ? (
{display.summary ? (
<span className="line-clamp-3 text-xs leading-snug text-white/55">
{summary}
{display.summary}
</span>
) : null}
{url ? (
Expand Down Expand Up @@ -754,6 +779,12 @@ export function AgentMessage({
() => makeMarkdownComponents(webSources, citationIndex, documentByKnownId),
[webSources, citationIndex, documentByKnownId],
)
const hasInlineSourceAnnotations = useMemo(
() => hasRenderableSourceAnnotations(message.parts, allowedSourceIds),
[message.parts, allowedSourceIds],
)
const showMemorySourcesFallback =
citationIndex.size > 0 && !hasInlineSourceAnnotations

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fallback gate ignores text runs

High Severity

hasInlineSourceAnnotations parses all text parts as one space-joined string, while the answer UI parses each contiguous text run separately and stops at tool and other non-text parts. Response markup split across those boundaries can look fully parsed in the gate check but produce no citation links in any rendered run, so showMemorySourcesFallback stays off and memory sources vanish despite a populated citationIndex.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6798b56. Configure here.

const responseModelLabel = responseModel
? `${modelNames[responseModel].name} ${modelNames[responseModel].version}`
: null
Expand Down Expand Up @@ -798,22 +829,10 @@ export function AgentMessage({
)
}
if (part.type === "text") {
// Skip fragments mid-run — source-url citations split one answer into
// many text parts; rendering each separately tears markdown (lists etc.).
let prev = partIndex - 1
while (prev >= 0 && message.parts[prev]?.type === "source-url") {
prev--
}
if (prev >= 0 && message.parts[prev]?.type === "text") {
return null
}
let runText = ""
for (let j = partIndex; j < message.parts.length; j++) {
const p = message.parts[j]
if (p?.type === "text") runText += p.text
else if (p?.type === "source-url") continue
else break
}
// source-url citations split one answer into many text parts;
// render each contiguous text/source-url run as one markdown block.
const runText = sourceAnnotatedTextRun(message.parts, partIndex)
if (runText === null) return null
return (
<div
key={`${message.id}-${partIndex}`}
Expand Down Expand Up @@ -865,6 +884,12 @@ export function AgentMessage({
}
return null
})}
{showMemorySourcesFallback && (
<MemorySourcesFallback
citationIndex={citationIndex}
documentByKnownId={documentByKnownId}
/>
)}
</div>
</div>
{hasAssistantText && (
Expand Down
93 changes: 93 additions & 0 deletions apps/web/lib/chat-memory-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
buildCitationIndex,
extractDocumentIdsFromMemoryOutput,
extractMemoryToolOutputs,
getCitationDisplay,
getDocumentSourceUrl,
mapDocumentsByKnownIds,
} from "./chat-memory-tools"
Expand Down Expand Up @@ -76,9 +77,101 @@ describe("chat memory tool citation mapping", () => {

expect(index.get("S1")?.documentId).toBe("docA")
expect(index.get("S1")?.customId).toBe("customA")
expect(index.get("S1")?.content).toBe("memo")
expect(index.has("ignored")).toBe(false)
})

it("maps source ids to matching result ids when citation ids are absent", () => {
const outputs = extractMemoryToolOutputs({
parts: [
{
type: "tool-searchMemories",
state: "output-available",
output: {
sourceIds: ["memory_no_citation"],
results: [
{
id: "memory_no_citation",
kind: "memory",
content: "Fallback source text.",
},
],
},
},
],
})

expect(buildCitationIndex(outputs).get("memory_no_citation")).toMatchObject(
{
sourceId: "memory_no_citation",
memoryId: "memory_no_citation",
content: "Fallback source text.",
},
)
})

it("keeps memory text for citations without source documents", () => {
const outputs = extractMemoryToolOutputs({
parts: [
{
type: "tool-recallContext",
state: "output-available",
output: {
sourceIds: ["memory_1"],
results: [
{
id: "memory_1",
citationId: "memory_1",
kind: "memory",
content: "User prefers concise answers.",
},
],
},
},
],
})

expect(buildCitationIndex(outputs).get("memory_1")).toMatchObject({
sourceId: "memory_1",
memoryId: "memory_1",
kind: "memory",
content: "User prefers concise answers.",
})
})

it("uses memory display only for citations without document metadata", () => {
expect(
getCitationDisplay({
sourceId: "memory_1",
memoryId: "memory_1",
kind: "memory",
content: "User prefers concise answers.",
}),
).toEqual({
title: "Memory",
kind: "memory",
summary: "User prefers concise answers.",
})
})

it("prefers tool-provided document metadata over generic memory display", () => {
expect(
getCitationDisplay({
sourceId: "S1",
memoryId: "memory_1",
content: "Memory text",
documentId: "doc_1",
customId: "custom_1",
title: "Project Plan",
type: "google_doc",
}),
).toMatchObject({
title: "Project Plan",
kind: "google doc",
summary: "Memory text",
})
})

it("extracts graph highlight document ids from memory outputs", () => {
const [output] = extractMemoryToolOutputs(assistantMessage)

Expand Down
Loading
Loading