diff --git a/packages/shared/src/assistantCitations.ts b/packages/shared/src/assistantCitations.ts index 9af6a29706724..765b5d3e6c0d8 100644 --- a/packages/shared/src/assistantCitations.ts +++ b/packages/shared/src/assistantCitations.ts @@ -131,6 +131,23 @@ export function assistantCitationsToPlainText(prompt: string): string { }); } +// Claude 5.1+ writes some user-facing updates as thinking blocks, which the adapter shows as +// assistant messages whose percent-encoded item id ends in `:thinking:`. The API rejects +// that text coming back in a prompt as reasoning extraction, so only the comment goes out. +const THINKING_DERIVED_MESSAGE_ID = /%3Athinking%3A\d+$/u; + +export function withholdThinkingCitationText(prompt: string): string { + let text = ""; + let cursor = 0; + for (const { citation, start, end } of collectAssistantCitations(prompt)) { + if (!THINKING_DERIVED_MESSAGE_ID.test(citation.messageId)) continue; + const comment = citation.comment === undefined ? "" : ` User comment: ${citation.comment}`; + text += `${prompt.slice(cursor, start)}[Quote of one of your earlier updates, text withheld.${comment}]`; + cursor = end; + } + return text + prompt.slice(cursor); +} + export function expandAssistantCitationsForProvider(prompt: string): string { const matches = collectAssistantCitations(prompt); if (matches.length === 0) return prompt; diff --git a/packages/shared/src/composerContextReferences.test.ts b/packages/shared/src/composerContextReferences.test.ts index 7212a1b64a984..266a9cc91e6ab 100644 --- a/packages/shared/src/composerContextReferences.test.ts +++ b/packages/shared/src/composerContextReferences.test.ts @@ -2,10 +2,12 @@ import { ComposerContextId, type ComposerContextRecord, EnvironmentId, + MessageId, ThreadId, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; +import { serializeAssistantCitation } from "./assistantCitations.ts"; import { collectComposerContextReferences, formatComposerContextHref, @@ -156,6 +158,37 @@ describe("provider projection", () => { expect(projected).toContain("- font-size: 12px → 20px"); }); + it("withholds quoted text of thinking-derived messages but keeps the comment and other quotes", () => { + const quote = { + version: 1, + environmentId: EnvironmentId.make("env"), + threadId: ThreadId.make("thread"), + text: "private plan", + start: 0, + end: 12, + prefix: "before ", + suffix: " after", + } as const; + const thinking = serializeAssistantCitation({ + ...quote, + messageId: MessageId.make("message:provider:claudeAgent:native-item:msg_01%3Athinking%3A1"), + comment: "fix this?", + }); + const reply = serializeAssistantCitation({ + ...quote, + text: "plain reply", + messageId: MessageId.make("msg_02"), + }); + const projected = projectComposerContextForProvider({ + text: `${thinking} and ${reply}`, + records: [], + }); + expect(projected).toBe( + `[Quote of one of your earlier updates, text withheld. User comment: fix this?] and ${reply}`, + ); + expect(projected).not.toContain("private"); + }); + it("uses the payload kind when a reference disagrees with its record", () => { const projected = projectComposerContextForProvider({ text: "[log](t3-context://v1/image/ctx_t)", diff --git a/packages/shared/src/composerContextReferences.ts b/packages/shared/src/composerContextReferences.ts index 88361606a9a64..a15b6e8719a63 100644 --- a/packages/shared/src/composerContextReferences.ts +++ b/packages/shared/src/composerContextReferences.ts @@ -9,6 +9,7 @@ import { type ScopedThreadRef, type ThreadContextRecord, } from "@t3tools/contracts"; +import { withholdThinkingCitationText } from "./assistantCitations.ts"; const CONTEXT_PROTOCOL = "t3-context:"; const COMPOSER_CONTEXT_HREF_PREFIX = `${CONTEXT_PROTOCOL}//v1/`; @@ -312,13 +313,14 @@ export function projectComposerContextForProvider(input: { text: string; records: ReadonlyArray; }): string { - const occurrences = collectComposerContextReferences(input.text); - if (occurrences.length === 0) return input.text; + const text = withholdThinkingCitationText(input.text); + const occurrences = collectComposerContextReferences(text); + if (occurrences.length === 0) return text; const recordsById = new Map(); for (const record of input.records) { recordsById.set(record.contextId, recordsById.has(record.contextId) ? undefined : record); } - const body = replaceComposerContextReferences(input.text, (occurrence) => + const body = replaceComposerContextReferences(text, (occurrence) => formatComposerContextProviderMarker( recordsById.get(occurrence.contextId)?.kind ?? occurrence.kind, occurrence.label,