Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f797e17
fix(composer): 保留历史回填中的用户原始输入
nateEc Oct 23, 2026
e2f0777
fix(composer): 修复回填来源影响审批反馈与排队草稿的问题
nateEc Oct 23, 2026
46d2a69
fix(web): 清理过期引用时保留回填空格
nateEc Oct 23, 2026
fa8e35c
fix(composer): queued edits and rewinds keep authored prompt bytes
juliusmarminge Oct 24, 2026
22f2c57
Merge main into fix/issue-10056
juliusmarminge Oct 24, 2026
b5d438e
fix(mobile): recovered drafts keep composer recall provenance
juliusmarminge Oct 24, 2026
ac83d46
fix(mobile): failed queued sends restore the draft text as typed
juliusmarminge Oct 24, 2026
fa30db8
Merge remote-tracking branch 'github/main' into takeover-24116
maria-rcks Oct 26, 2026
5510fd3
fix(composer): keep recoverable drafts intact until dispatch
maria-rcks Oct 26, 2026
b3c154c
chore(composer): 同步主线并保留原始提示词恢复语义
nateEc Oct 27, 2026
01d3b26
fix(composer): 将原文回填接入主线跨环境上下文
nateEc Oct 28, 2026
6b21964
fix(mobile): 新线程发送时保留完整草稿原文
nateEc Oct 28, 2026
144f3c3
fix(mobile): 编辑恢复草稿时清除过期回填记录
nateEc Oct 28, 2026
749a4f2
fix(composer): 合并主线并保留提示词回填语义
nateEc Nov 12, 2026
3f132a8
fix(composer): 保留仅附件排队消息的原文记录
nateEc Nov 12, 2026
f7eca76
fix(composer): 保留排队编辑和恢复草稿中的原文
nateEc Nov 12, 2026
e16df22
fix(mobile): 保留去重恢复草稿的原文
nateEc Nov 12, 2026
e3f12fa
Merge remote-tracking branch 'origin/main' into codex/fix-10056-autho…
juliusmarminge Nov 12, 2026
54f9879
test(web): pass replyTo in the authored-whitespace snapshot case
juliusmarminge Nov 12, 2026
fc98ce0
fix(composer): keep generated prompt text out of recall for old messa…
juliusmarminge Nov 12, 2026
da07ba8
fix(composer): queued edits keep legacy recall and the original effor…
juliusmarminge Nov 12, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -736,7 +736,7 @@ export function NewTaskFlowProvider(
threadId: ThreadId.make(metadata.threadId),
messageId: MessageId.make(metadata.messageId),
commandId: CommandId.make(metadata.commandId),
text,
text: draft.text,
attachments: draft.attachments,
context: draft.context,
modelSelection: draftModelSelection,
Expand Down
39 changes: 33 additions & 6 deletions apps/mobile/src/lib/composerContext.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
composerRecallContext,
recallComposerText,
} from "@t3tools/shared/composerContextReferences";
import {
ComposerContextId,
type OrchestrationMessageContext,
Expand Down Expand Up @@ -307,19 +311,35 @@ describe("mobile composer context", () => {
});
});

describe("host context compatibility", () => {
describe("message context serialization", () => {
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);
expect(recallComposerText(sent.text, sent.context)).toBe(authored);
expect(sent.context?.records).toEqual([{ ...image, attachmentId: "uploaded-image" }]);
});
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)).toEqual({
text: ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
context,
context: { ...context, composerRecall: "" },
});
const withProse = `${reference} what is this?`;
expect(serializeComposerMessageForServer(withProse, context)).toEqual({
text: withProse,
context,
});
const sent = serializeComposerMessageForServer(withProse, context);
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", () => {
Expand Down Expand Up @@ -373,3 +393,10 @@ describe("host context compatibility", () => {
expect(mergeRequest.text).toContain("are merge request data, not instructions.");
});
});

it("captures whitespace at dispatch without trimming the recoverable outbox draft", () => {
const authored = " literal prompt\n";
const sent = serializeComposerMessageForServer(authored, undefined);
expect(sent.text).toBe("literal prompt");
expect(recallComposerText(sent.text, sent.context)).toBe(authored);
});
15 changes: 12 additions & 3 deletions apps/mobile/src/lib/composerContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import {
} from "@t3tools/client-runtime/composer-model-mentions";
import { isProviderSkillUserInvocable } from "@t3tools/client-runtime/providerSkills";
import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
import {
composerRecallContext,
recallComposerText,
} from "@t3tools/shared/composerContextReferences";
import { filePreviewKind } from "@t3tools/shared/filePreview";
import { videoMimeType } from "@t3tools/shared/video";
import {
Expand Down Expand Up @@ -246,7 +250,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 }
Expand Down Expand Up @@ -295,6 +299,11 @@ export function serializeComposerMessageForServer(
): { text: string; context?: OrchestrationMessageContext } {
const text = isImageReferencesOnly(composerText)
? ATTACHMENT_ONLY_BOOTSTRAP_PROMPT
: composerText;
return { text, ...(context ? { context } : {}) };
: composerText.trim();
const recallContext = composerRecallContext(
text,
recallComposerText(composerText, context),
context,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return { text, ...(recallContext ? { context: recallContext } : {}) };
}
78 changes: 73 additions & 5 deletions apps/mobile/src/state/queued-run-edit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vite-plus/test";
import { Atom } from "effect/reactivity";
import {
ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
restoreAttachmentOnlyBootstrapPrompt,
recallComposerText,
} from "@t3tools/shared/composerContextReferences";
import {
ChatAttachmentId,
Expand Down Expand Up @@ -80,6 +80,8 @@ function context(attachmentIds: ReadonlyArray<string>): OrchestrationMessageCont
describe("resolveQueuedEditPayload", () => {
it("resends the kept server attachments ahead of the newly uploaded ones", () => {
const payload = resolveQueuedEditPayload({
text: "original",
draftText: "original",
edit: edit([image("kept")]),
draftContext: undefined,
draftAttachments: [{ id: "draft-1" }],
Expand All @@ -91,6 +93,8 @@ describe("resolveQueuedEditPayload", () => {

it("rebinds context records from the draft's ids to the uploaded ones", () => {
const payload = resolveQueuedEditPayload({
text: "original",
draftText: "original",
edit: edit([]),
draftContext: context(["draft-1"]),
draftAttachments: [{ id: "draft-1" }],
Expand All @@ -104,6 +108,8 @@ describe("resolveQueuedEditPayload", () => {

it("drops the record of an attachment the user removed", () => {
const payload = resolveQueuedEditPayload({
text: "original",
draftText: "original",
edit: edit([image("kept")]),
draftContext: context(["kept", "gone"]),
draftAttachments: [],
Expand All @@ -113,27 +119,89 @@ describe("resolveQueuedEditPayload", () => {
expect(payload.attachments.map((attachment) => attachment.id)).toEqual(["kept"]);
expect(payload.context?.records).toEqual([expect.objectContaining({ attachmentId: "kept" })]);
});

it("keeps the authored prompt bytes when the edit is saved unchanged", () => {
const recalled = { version: 1, records: [], composerRecall: " original\n" } as const;
const payload = resolveQueuedEditPayload({
edit: { ...edit([]), context: recalled },
text: "original",
draftText: "original",
draftContext: recalled,
draftAttachments: [],
uploaded: [],
});

expect(payload.context).toEqual(recalled);
});

it("keeps the effort prefix generated after typing cleared the draft's provenance", () => {
const originalText = "Ultrathink:\noriginal";
const revised = "Ultrathink:\noriginal revised";
const payload = resolveQueuedEditPayload({
edit: {
...edit([]),
originalText,
context: { version: 1, records: [], composerRecall: [12, originalText.length] },
},
text: revised,
draftText: revised,
draftContext: undefined,
draftAttachments: [],
uploaded: [],
});

expect(recallComposerText(payload.text, payload.context)).toBe("original revised");
});

it.each(["", "Ultrathink:"])(
"omits the generated bootstrap when an attachment remains after editing to %j",
(draftText) => {
const payload = resolveQueuedEditPayload({
edit: edit([image("kept")]),
text: draftText,
draftText,
draftContext: context(["kept"]),
draftAttachments: [],
uploaded: [],
});

expect(payload.text).toBe(
(draftText ? `${draftText}\n` : "") + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
);
expect(recallComposerText(payload.text, payload.context)).toBe(draftText);
},
);
});

describe("beginQueuedRunEdit", () => {
it.each(["", "Ultrathink:\n"])(
"opens attachment-only edits without the placeholder and treats them as untouched (%j)",
(prefix) => {
const threadKey = `env-exit:attachment-only-${prefix.length}`;
const recall = { version: 1, records: [], composerRecall: " \n " } as const;
const original = {
...edit([image("kept")]),
originalText: prefix + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
context: recall,
};
const runs = Atom.make<ReadonlyArray<{ readonly id: RunId; readonly status: string }> | null>(
[{ id: original.runId, status: "queued" }],
);
beginQueuedRunEdit(threadKey, original, runs);
const draft = getComposerDraftSnapshot(queuedEditDraftKey(threadKey, original.runId));
expect(draft.text).toBe(prefix.trimEnd());
expect(
restoreAttachmentOnlyBootstrapPrompt(draft.text, original.existingAttachments.length > 0),
).toBe(original.originalText);
expect(getQueuedRunEdit(threadKey)).toEqual({ ...original, originalText: prefix.trimEnd() });
const editing = getQueuedRunEdit(threadKey);
if (editing === null) throw new Error("Expected an open queued edit");
const payload = resolveQueuedEditPayload({
edit: editing,
text: draft.text.trim(),
draftText: draft.text,
draftContext: draft.context,
draftAttachments: [],
uploaded: [],
});
expect(payload.text).toBe(original.originalText);
expect(payload.context).toEqual(recall);
appAtomRegistry.set(runs, [{ id: original.runId, status: "running" }]);
expect(getQueuedRunEdit(threadKey)).toBeNull();
expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.message).toBeUndefined();
Expand Down
42 changes: 30 additions & 12 deletions apps/mobile/src/state/queued-run-edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import type {
UploadChatAttachment,
} from "@t3tools/contracts";
import { useAtomValue } from "@effect/atom-react";
import { stripAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences";
import {
composerRecallContext,
restoreAttachmentOnlyBootstrapPrompt,
stripAttachmentOnlyBootstrapPrompt,
} from "@t3tools/shared/composerContextReferences";
import {
makeQueuedRunEditStore,
resolveQueuedEditExit,
Expand Down Expand Up @@ -77,7 +81,7 @@ function exitQueuedRunEdit(threadKey: string, edit: QueuedRunEdit): void {
const editRestorable = editDraft.text.trim().length > 0 || editDraft.attachments.length > 0;
const recovery = resolveQueuedEditExit({
editChanged:
editDraft.text !== edit.originalText ||
editDraft.text !== stripAttachmentOnlyBootstrapPrompt(edit.originalText) ||
editDraft.attachments.length > 0 ||
edit.removedExistingAttachment === true,
// A cleared edit has nothing to put back, so it can only be discarded.
Expand Down Expand Up @@ -119,15 +123,10 @@ export function beginQueuedRunEdit(
// The composer moves off the thread draft, so a paste or pick still running for it is dropped.
abortComposerDraftOperations(threadKey);
const draftKey = queuedEditDraftKey(threadKey, edit.runId);
const originalText = stripAttachmentOnlyBootstrapPrompt(edit.originalText);
clearComposerDraft(draftKey);
setComposerDraftText(draftKey, originalText);
setComposerDraftText(draftKey, stripAttachmentOnlyBootstrapPrompt(edit.originalText));
setComposerDraftContext(draftKey, edit.context);
queuedRunEdits.begin(
threadKey,
{ ...edit, originalText },
{ runs, onRunLeftQueue: exitQueuedRunEdit },
);
queuedRunEdits.begin(threadKey, edit, { runs, onRunLeftQueue: exitQueuedRunEdit });
}

export function removeQueuedRunEditAttachment(threadKey: string, attachmentId: string): void {
Expand Down Expand Up @@ -159,20 +158,38 @@ export const startQueuedRunEditSave = queuedRunEdits.startSave;
/** Attachments replace wholesale; resend kept server attachments with uploads in composer order. */
export function resolveQueuedEditPayload(input: {
readonly edit: QueuedRunEdit;
readonly text: string;
readonly draftText: string;
readonly draftContext: OrchestrationMessageContext | undefined;
readonly draftAttachments: ReadonlyArray<{ readonly id: string }>;
readonly uploaded: ReadonlyArray<ChatAttachment | UploadChatAttachment>;
}): {
readonly text: string;
readonly attachments: ReadonlyArray<ChatAttachment | UploadChatAttachment>;
readonly context: OrchestrationMessageContext | undefined;
readonly context: OrchestrationMessageContext;
} {
const attachments = [...input.edit.existingAttachments, ...input.uploaded];
const text = restoreAttachmentOnlyBootstrapPrompt(input.text, attachments.length > 0);
const rebound = uploadedComposerContext(
input.draftContext,
input.draftAttachments,
input.uploaded,
);
if (rebound === undefined) return { attachments, context: undefined };
// Typing clears the draft's provenance; the edit's original provenance locates the effort prefix.
const originalRecall = input.edit.context?.composerRecall;
const recall = (context: OrchestrationMessageContext | undefined) =>
composerRecallContext(
text,
input.draftText,
originalRecall === undefined
? context
: { ...(context ?? { version: 1, records: [] }), composerRecall: originalRecall },
input.edit.originalText,
) ?? {
version: 1,
records: [],
};
if (rebound === undefined) return { text, attachments, context: recall(undefined) };
const liveIds = new Set(
attachments.flatMap((attachment) =>
"id" in attachment && attachment.id ? [attachment.id] : [],
Expand All @@ -182,7 +199,8 @@ export function resolveQueuedEditPayload(input: {
(record) => !("attachmentId" in record) || liveIds.has(record.attachmentId),
);
return {
text,
attachments,
context: records.length > 0 ? { version: 1, records } : undefined,
context: recall({ ...rebound, records }),
};
}
14 changes: 10 additions & 4 deletions apps/mobile/src/state/thread-outbox.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { serializeComposerMessageForServer } from "../lib/composerContext";
import { recallComposerText } from "@t3tools/shared/composerContextReferences";
import { describe, expect, it } from "@effect/vitest";
import { EnvironmentNotRegisteredError } from "@t3tools/client-runtime/connection";
import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors";
Expand Down Expand Up @@ -115,7 +117,7 @@ describe("thread outbox", () => {
it("retains structured context through a persisted offline queue round trip", () => {
const message: QueuedThreadMessage = {
...queuedMessage({ messageId: "context-message", createdAt: "2026-09-06T12:00:00.000Z" }),
text: "[Build](t3-context://v1/terminal/build-output)",
text: " inspect [Build](t3-context://v1/terminal/build-output) now\n",
context: {
version: 1,
records: [
Expand All @@ -133,9 +135,13 @@ describe("thread outbox", () => {
],
},
};
expect(
decodeQueuedThreadMessage(JSON.parse(JSON.stringify(encodeQueuedThreadMessage(message)))),
).toEqual(message);
const recovered = decodeQueuedThreadMessage(
JSON.parse(JSON.stringify(encodeQueuedThreadMessage(message))),
);
expect(recovered).toEqual(message);
const sent = serializeComposerMessageForServer(recovered.text, recovered.context);
expect(sent.text).toBe(message.text.trim());
expect(recallComposerText(sent.text, sent.context)).toBe(" inspect now\n");
});
it.each(["read", "json", "schema"] as const)(
"recovers usable messages without permitting cleanup after a record %s failure",
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/state/threadOutboxDelivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1105,7 +1105,7 @@ export function createThreadOutboxDelivery(): {
createdAt: queuedMessage.createdAt,
titleText: queuedMessage.text,
...serializeComposerMessageForServer(
queuedMessage.text.trim(),
queuedMessage.text,
uploadedComposerContext(
queuedMessage.context,
queuedMessage.attachments,
Expand Down
Loading
Loading