From f797e17a5cc3ba326964dce1f73e0063fdf71a54 Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Fri, 23 Oct 2026 20:39:00 +0800 Subject: [PATCH 01/15] =?UTF-8?q?fix(composer):=20=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E5=9B=9E=E5=A1=AB=E4=B8=AD=E7=9A=84=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=8E=9F=E5=A7=8B=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在发送前记录文本来源,避免按前缀或标签误删用户内容,并以范围减少长提示词复制。 - 覆盖网页、移动端、计划发送和排队编辑,服务端清除旧客户端编辑后过期的回填信息。 - 通过 203 项相关测试和变更检查,并在隔离客户端验证修复前后及生成上下文对照。 --- .../threads/new-task-flow-provider.tsx | 3 +- apps/mobile/src/lib/composerContext.test.ts | 36 +++++-- apps/mobile/src/lib/composerContext.ts | 13 ++- .../src/state/use-thread-composer-state.ts | 8 +- .../src/state/use-thread-outbox-drain.ts | 2 +- .../src/orchestration-v2/Orchestrator.ts | 11 ++- .../ThreadLaunchService.test.ts | 31 ++++++ apps/web/src/components/ChatView.logic.ts | 17 ++-- .../chat/composerPromptHistory.test.ts | 81 +++++---------- .../components/chat/composerPromptHistory.ts | 99 +------------------ .../src/components/chat/useComposerSend.ts | 38 ++++--- .../components/chat/useMultipleModelSend.ts | 7 +- .../components/chat/useProposedPlanSend.ts | 7 +- .../src/components/chat/useQueuedRunEdit.ts | 25 +++-- apps/web/src/proposedPlan.ts | 2 +- .../contracts/src/composerContext.test.ts | 9 ++ packages/contracts/src/composerContext.ts | 6 ++ .../shared/src/composerContextReferences.ts | 43 ++++++++ packages/shared/src/composerRecall.test.ts | 84 ++++++++++++++++ 19 files changed, 322 insertions(+), 200 deletions(-) create mode 100644 packages/shared/src/composerRecall.test.ts diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index af20d81146ad3..ac4bf887acb7f 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -1,3 +1,4 @@ +import { composerRecallContext } from "@t3tools/shared/composerContextReferences"; import { ActiveNewTaskContext } from "../../state/active-new-task"; import * as Dialog from "../../components/ui/Dialog"; import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; @@ -1037,7 +1038,7 @@ export function NewTaskFlowProvider( commandId: CommandId.make(metadata.commandId), text, attachments: draft.attachments, - context: draft.context, + context: composerRecallContext(text, draft.text, draft.context), modelSelection: draftModelSelection, runtimeMode: draft.runtimeMode ?? defaultRuntimeMode, interactionMode: resolvePendingTaskInteractionMode({ diff --git a/apps/mobile/src/lib/composerContext.test.ts b/apps/mobile/src/lib/composerContext.test.ts index b809ebc3ac51c..0fbc2a06d5ded 100644 --- a/apps/mobile/src/lib/composerContext.test.ts +++ b/apps/mobile/src/lib/composerContext.test.ts @@ -1,4 +1,8 @@ import { upgradeLegacyContextMessage } from "@t3tools/shared/composerContextLegacy"; +import { + composerRecallContext, + recallComposerText, +} from "@t3tools/shared/composerContextReferences"; import { buildProjectThreadStartTurnInput } from "./projectThreadStartTurn"; import { ProjectId, @@ -269,18 +273,35 @@ describe("mobile composer context", () => { }); describe("host context compatibility", () => { + it("keeps captured whitespace through outbox serialization and attachment rebinding", () => { + const authored = " Ultrathink:\nThis prefix is literal\n"; + const sentText = authored.trim(); + const queuedContext = composerRecallContext(sentText, authored, { + version: 1, + records: [image], + }); + const rebound = uploadedComposerContext( + queuedContext, + [{ id: "local-image" }], + [{ id: "uploaded-image" }], + ); + const sent = serializeComposerMessageForServer(sentText, rebound, true); + expect(recallComposerText(sent.text, sent.context)).toBe(authored); + expect(sent.context?.records).toEqual([{ ...image, attachmentId: "uploaded-image" }]); + expect(serializeComposerMessageForServer(sentText, rebound, false).context).toBeUndefined(); + }); it("sends an image-only prompt as the attachment-only prompt and keeps the image", () => { const context: OrchestrationMessageContext = { version: 1, records: [image] }; const reference = formatComposerContextReference(image); expect(serializeComposerMessageForServer(reference, context, true)).toEqual({ text: ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, - context, + context: { ...context, composerRecall: "" }, }); const withProse = `${reference} what is this?`; - expect(serializeComposerMessageForServer(withProse, context, true)).toEqual({ - text: withProse, - context, - }); + const sent = serializeComposerMessageForServer(withProse, context, true); + expect(sent.text).toBe(withProse); + expect(sent.context?.records).toEqual(context.records); + expect(recallComposerText(sent.text, sent.context)).toBe("what is this?"); }); it("carries the current PR diff file within the composer wire limits", () => { @@ -374,7 +395,10 @@ describe("host context compatibility", () => { expect(message.text).toContain(review.text); expect(message.text).toContain(review.diff); expect(message.text).toContain(pr.pullRequest!.url); - expect(serializeComposerMessageForServer(text, context, true)).toEqual({ text, context }); + expect(serializeComposerMessageForServer(text, context, true)).toEqual({ + text, + context: { ...context, composerRecall: "" }, + }); expect(context.records).toEqual([terminal, review, pr]); }); }); diff --git a/apps/mobile/src/lib/composerContext.ts b/apps/mobile/src/lib/composerContext.ts index 03e50acf3de57..c00a3e2089c77 100644 --- a/apps/mobile/src/lib/composerContext.ts +++ b/apps/mobile/src/lib/composerContext.ts @@ -1,5 +1,9 @@ import { isProviderSkillUserInvocable } from "@t3tools/client-runtime/providerSkills"; import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend"; +import { + composerRecallContext, + recallComposerText, +} from "@t3tools/shared/composerContextReferences"; import { filePreviewKind } from "@t3tools/shared/filePreview"; import { videoMimeType } from "@t3tools/shared/video"; import { @@ -209,7 +213,7 @@ export function uploadedComposerContext( if (!context) return undefined; const ids = new Map(drafts.map((draft, index) => [draft.id, uploaded[index]?.id])); return { - version: 1, + ...context, records: context.records.map((record) => "attachmentId" in record ? { ...record, attachmentId: ids.get(record.attachmentId) ?? record.attachmentId } @@ -263,7 +267,12 @@ export function serializeComposerMessageForServer( const text = isImageReferencesOnly(composerText) ? ATTACHMENT_ONLY_BOOTSTRAP_PROMPT : composerText; + const recallContext = composerRecallContext( + text, + recallComposerText(composerText, context), + context, + ); return supportsInlineMessageContext - ? { text, ...(context ? { context } : {}) } + ? { text, ...(recallContext ? { context: recallContext } : {}) } : { text: serializeLegacyContextMessage({ text, records: context?.records ?? [] }) }; } diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index ec71677013b44..e304f65b12c62 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,3 +1,4 @@ +import { composerRecallContext } from "@t3tools/shared/composerContextReferences"; import { useComposerAttachmentActions } from "./use-composer-attachment-actions"; import * as Dialog from "../components/ui/Dialog"; import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; @@ -507,7 +508,10 @@ export function useThreadComposerState() { edit: { messageId: edit.messageId, attachments: payload.attachments, - ...(payload.context ? { context: payload.context } : {}), + context: composerRecallContext(text, draft.text, payload.context) ?? { + version: 1, + records: [], + }, }, }, }); @@ -706,7 +710,7 @@ export function useThreadComposerState() { commandId: CommandId.make(metadata.commandId), text, attachments, - context: draft.context, + context: composerRecallContext(text, draft.text, draft.context), modelSelection, runtimeMode: draft.runtimeMode ?? thread.runtimeMode, interactionMode: resolveProviderInteractionMode( diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index a979632029461..b67fba8e7a272 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -1021,7 +1021,7 @@ export function useThreadOutboxDrain(): void { createdAt: queuedMessage.createdAt, titleText: queuedMessage.text, ...serializeComposerMessageForServer( - queuedMessage.text.trim(), + queuedMessage.text, uploadedComposerContext( queuedMessage.context, queuedMessage.attachments, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 33b434169fed5..baee4c0f67e9e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -8385,6 +8385,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const emitEvent = emit(events, command); const editedAttachments = command.attachments === undefined ? {} : { attachments: command.attachments }; + // An older client or MCP caller can edit without capturing new recall provenance. + // Keep its context records, but never recall the previous draft after a text edit. + const editedContext = + command.context ?? + (command.text === queuedMessage.text || queuedMessage.context === undefined + ? queuedMessage.context + : { version: 1 as const, records: queuedMessage.context.records }); yield* emitEvent({ type: "message.updated", threadId: command.threadId, @@ -8396,7 +8403,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...queuedMessage, text: command.text, ...editedAttachments, - ...(command.context ? { context: command.context } : {}), + ...(editedContext ? { context: editedContext } : {}), updatedAt: now, }, }); @@ -8412,7 +8419,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...queuedTurnItem, text: command.text, ...editedAttachments, - ...(command.context ? { context: command.context } : {}), + ...(editedContext ? { context: editedContext } : {}), updatedAt: now, }, }); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 0391412fe5f9b..16b6651c5f10b 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1737,6 +1737,7 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch text: "First [file](t3-context://v1/file/intake-file)", context: { version: 1 as const, + composerRecall: "First", records: [ { version: 1 as const, @@ -1779,6 +1780,7 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch ); assert.isDefined(stored); assert.notEqual(stored.attachments[0]?.id, attachment.id); + assert.equal(stored.context?.composerRecall, "First"); assert.equal( (stored.context?.records[0] as { attachmentId: string }).attachmentId, stored.attachments[0]?.id, @@ -1844,6 +1846,7 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch threadId: input.threadId, messageId: MessageId.make("intake-second"), text: "Second", + context: { version: 1, records: [], composerRecall: "Original draft" }, attachments: [attachment], dispatchMode: { type: "queue_after_active" }, createdBy: "user", @@ -1861,6 +1864,19 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch (message) => message.id === queuedRun.userMessageId, ); assert.isDefined(queuedMessage); + yield* ThreadMessageIntake.dispatchCommand({ + type: "queued-run.edit", + commandId: CommandId.make("intake-unchanged-text"), + threadId: input.threadId, + runId: queuedRun.id, + text: queuedMessage.text, + }); + assert.equal( + (yield* threads.getThreadProjection(input.threadId)).messages.find( + (message) => message.id === queuedMessage.id, + )?.context?.composerRecall, + "Original draft", + ); const file: ChatAttachment = { type: "file", id: ChatAttachmentId.make(createPendingAttachmentId("pdf")), @@ -1889,6 +1905,21 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch (message) => message.id === queuedRun.userMessageId, ); assert.isDefined(editedMessage); + assert.isUndefined(editedMessage.context?.composerRecall); + yield* ThreadMessageIntake.dispatchCommand({ + type: "queued-run.edit", + commandId: CommandId.make("intake-new-provenance"), + threadId: input.threadId, + runId: queuedRun.id, + text: "Ultrathink:\nEdited with a file", + context: { version: 1, records: [], composerRecall: "Edited with a file" }, + }); + const recaptured = yield* threads.getThreadProjection(input.threadId); + assert.equal( + recaptured.messages.find((message) => message.id === editedMessage.id)?.context + ?.composerRecall, + "Edited with a file", + ); assert.equal(editedMessage.attachments.length, 2); assert.deepEqual(editedMessage.attachments[0], queuedMessage.attachments[0]); assert.notEqual(editedMessage.attachments[1]?.id, file.id); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 0ce51ee5e5534..e9e885050e9af 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1500,6 +1500,13 @@ export function snapshotComposerSend(contents: ComposerSendContents) { const images = [...contents.images]; const files = [...contents.files]; const terminalContexts = [...contents.sendableTerminalContexts]; + const composerText = contents.terminalContexts + .filter((context) => !terminalContexts.includes(context)) + .reduce( + (text, context) => + removeInlineContextReference(text, terminalContextReference(context).contextId).prompt, + contents.prompt, + ); return { images, files, @@ -1508,14 +1515,8 @@ export function snapshotComposerSend(contents: ComposerSendContents) { previewAnnotations: [...contents.previewAnnotations], reviewComments: [...contents.reviewComments], threadContexts: [...contents.threadContexts], - messageText: contents.terminalContexts - .filter((context) => !terminalContexts.includes(context)) - .reduce( - (text, context) => - removeInlineContextReference(text, terminalContextReference(context).contextId).prompt, - contents.prompt, - ) - .trim(), + composerText, + messageText: composerText.trim(), }; } diff --git a/apps/web/src/components/chat/composerPromptHistory.test.ts b/apps/web/src/components/chat/composerPromptHistory.test.ts index 39bf0c1f21e74..e5b82fe2fb857 100644 --- a/apps/web/src/components/chat/composerPromptHistory.test.ts +++ b/apps/web/src/components/chat/composerPromptHistory.test.ts @@ -25,64 +25,35 @@ function forward(position: ComposerPromptHistoryPosition | null, currentPrompt: describe("recallableComposerPrompt", () => { it.each([ - "Render ", - "Render\n\nhere", - "Render <preview_annotation>", - ])("strips an annotation whose comment contains a literal opening tag: %s", (comment) => { - expect( - recallableComposerPrompt( - `Prompt\n\nComment: ${comment}\n`, - ), - ).toBe("Prompt"); - }); - - it("preserves a malformed annotation containing a nested opening tag", () => { - const text = - "Prompt\n\nouter literal\n\ninner\n"; + "Ultrathink:\nKeep this prefix in my example", + 'Keep this example\nliteral', + " Preserve surrounding whitespace\n", + "PLEASE IMPLEMENT THIS PLAN:\nThis is a literal example", + ])("preserves authored text when its provenance is unknown: %s", (text) => { expect(recallableComposerPrompt(text)).toBe(text); }); - it("does not strip a terminal label embedded in ordinary prose", () => { + it("uses origin metadata and skips generated-only messages", () => { + const text = "Ultrathink:\nKeep this prefix"; expect( - recallableComposerPrompt( - "email@build:7\n\n- Build line 7:\n output\n", - ), - ).toBe("email@build:7"); - }); - it("strips legacy send-time context blocks and the ultrathink prefix", () => { - const sent = - "Ultrathink:\nInvestigate this\n\n\n- Terminal 1 lines 12-13:\n 12 | git status\n 13 | On branch main\n\n\n\n-