Skip to content

Edit-and-resend revisions reject or drop structured message context across Desktop and TUI #5109

Open

Description

@me2seeks

What happened

Edit-and-resend does not preserve one canonical user-message contract across Desktop and TUI when the selected message or its retained history contains structured context such as quotes or attachments.

Three related behaviors are observable on current main:

  1. Desktop rejects a selected message containing a quote or attachment. A large paste is automatically stored as a QuoteRef, so a normal large-paste message immediately becomes ineligible for Edit & resend. The tooltip says that historical messages containing quotes are not supported.
  2. Desktop also rejects editing a later plain-text message when an earlier retained turn contains an attachment. This renderer guard is stale: Runtime Host conversation-copy now copies referenced user-upload artifacts, rewrites their target Session refs, and has integration coverage for revision copies.
  3. TUI /rewind permits the selected turn but restores only userFacingText(promptMessage). Its subsequent submit carries only text/displayText, so a selected message's quotes or attachments are not restored into the replacement turn. This is a fail-open counterpart to Desktop's fail-closed behavior.

Edit-and-resend is implemented as a non-destructive Session revision: copy the exact history before turn T, then submit a replacement T'. Its expected invariant is therefore:

revision(T) = exactCopy(turns before T) + explicitlyEditedCanonicalContent(T)

CanonicalContent(T) must include the structured context the user keeps, changes, or explicitly removes; it must not degrade implicitly to a display string.

Expected behavior:

  • Earlier retained quotes and attachments are copied exactly or revision preparation fails explicitly.
  • The selected turn's text and structured context are restored into an editable revision draft.
  • Unchanged structured context is preserved by default.
  • Removing or changing a quote/attachment is an explicit edit.
  • Desktop and TUI expose the same revision semantics even if their controls differ.

How to reproduce

A. Desktop large-paste quote

  1. Open a Desktop conversation.
  2. Paste text containing at least 1,000 characters or more than 10 lines.
  3. Send the message and wait for the turn to settle.
  4. Hover or activate Edit & resend on that user message.

Actual: the action is disabled with “Edit & resend does not yet support messages with quotes.”

Expected: create a revision draft containing the original text and quote, allowing the user to keep, edit, replace, or remove the quoted content before sending.

B. Desktop retained attachment

  1. Send turn T1 with an uploaded image.
  2. Send a plain-text turn T2 and wait for it to settle.
  3. Choose Edit & resend on T2.

Actual: the renderer rejects the revision because an earlier retained turn contains an attachment.

Expected: T1 and its rewritten, target-owned artifact are retained in the revision; T2 is restored for editing.

C. TUI selected turn with structured context

  1. Open in TUI a Session containing a user turn with quotes or attachments (for example, a turn created in Desktop).
  2. Run /rewind and select that turn.
  3. Edit the refilled prompt and send it.

Actual: /rewind creates the revision, but only the selected message's human-facing text is refilled and the replacement submit has no quotes or attachments.

Expected: the replacement draft retains the selected turn's structured context unless the user explicitly removes it, or TUI fails closed with a precise unsupported-content notice until it can do so.

Environment

  • Maka commit: fcff80f58910f4de118fd7edbf7f02068239c50a
  • OS and version: Linux; exact distribution/version not captured for the reported Desktop observation
  • Surface: Desktop and TUI; shared Runtime Host revision path
  • Node.js version used for source verification: v26.3.0

Logs, screenshots, or additional context

Verified implementation boundaries

  • Desktop disables selected user messages with attachments, directory references, or quotes before the revision action runs:
    // A revision restages neither attachments, directory references,
    // nor quotes, so a turn carrying any of them can't be edited
    // without silently dropping context the answer was grounded in.
    editDisabled={
    (turn.user.attachments?.length ?? 0) > 0 ||
    (turn.user.directoryReferences?.length ?? 0) > 0 ||
    (turn.user.quotes?.length ?? 0) > 0 ||
    props.editUserMessageTransformed === true ||
    props.editUserMessageDisabled === true ||
    turn.status === 'running' ||
    !!props.liveStreaming
    }
    editDisabledReason={
    (turn.user.attachments?.length ?? 0) > 0
    ? copy.editMessageDisabledAttachments
    : (turn.user.directoryReferences?.length ?? 0) > 0
    ? copy.editMessageDisabledDirectoryReferences
    : (turn.user.quotes?.length ?? 0) > 0
    ? copy.editMessageDisabledQuotes
  • Desktop revision drafts currently retain only string fields (originalText and previousComposerText), not canonical structured message content:
    /** Active edit-and-resend draft owned by the desktop shell. */
    export type TurnRevisionDraft = {
    sourceSessionId: string;
    sourceTurnId: string;
    copyId: string;
    copyPhase: SessionCopyAttemptPhase;
    /** Active owner of the draft. Changes to the branch child after prepare. */
    draftSessionId: string;
    originalText: string;
    /** Composer text that was present before edit began; restored on cancel.
    * Staged Skills ride along inside it as `/skill:<id>` chips. */
    previousComposerText: string;
    };
  • Desktop independently scans earlier turns and rejects any retained attachment history:
    const turnOrder: string[] = [];
    const seenTurns = new Set<string>();
    const turnHasAttachments = new Set<string>();
    for (const message of messages) {
    const messageTurnId = (message as { turnId?: string }).turnId;
    if (messageTurnId && !seenTurns.has(messageTurnId)) {
    seenTurns.add(messageTurnId);
    turnOrder.push(messageTurnId);
    }
    if (message.type === 'user' && message.attachments && message.attachments.length > 0) {
    turnHasAttachments.add(message.turnId);
    }
    }
    const sourceIndex = turnOrder.indexOf(turnId);
    const retainedAttachmentTurn = turnOrder
    .slice(0, Math.max(0, sourceIndex))
    .find((candidate) => turnHasAttachments.has(candidate));
    if (
    (userMessage.attachments && userMessage.attachments.length > 0) ||
    retainedAttachmentTurn
    ) {
    toastApi.info(copy.revisionUnavailableTitle, copy.revisionAttachmentsUnsupported);
    return;
  • Runtime Host revision copies use the exclusive before boundary for the selected turn:
    // An empty copy carries no source transcript: skip the slice so a side
    // conversation can fork before the source has any settled turn.
    let slice: ConversationCopySlice;
    if (input.sourceTurnId === undefined) {
    slice = { messages: [], turnIds: [] };
    } else {
    const throughSlice = createConversationCopySlice(
    source.messages,
    input.sourceTurnId,
    kind === 'revision' ? 'before' : 'through',
    );
    if (!throughSlice) {
    return copyFailure('invalid_request', 'Source turn does not exist');
    }
    slice = throughSlice;
    }
  • Runtime Host already collects referenced Session files, copies artifacts, and rewrites target-owned references for the retained slice:
    const referencedSessionFileIds = collectConversationCopySessionFileRefs({
    sourceSessionId: input.sourceSessionId,
    messages: slice.messages,
    runtimeEvents: plan.runs.flatMap(({ runtimeEvents }) => runtimeEvents),
    archivedResults: archivePreflight.serializedResults,
    });

    const artifactCopy = await this.#artifacts.copyConversationArtifacts({
    sourceSessionId: input.sourceSessionId,
    targetSessionId: input.targetSessionId,
    turnIds: copyTurnIds,
    ...(referencedSessionFileIds.size > 0
    ? { includeArtifactIds: [...referencedSessionFileIds] }
    : {}),
    ...(kind === 'side_conversation' && archivedSnapshotResults.size > 0
    ? { excludeArtifactIds: [...archivedSnapshotResults.keys()] }
    : {}),
    ...(kind === 'side_conversation' && linkedReferences.references.size > 0
    ? {
    linkedArtifacts: [...linkedReferences.references].map(([sessionId, references]) => ({
    sessionId,
    artifactIds: [...references.artifactIds],
    })),
    }
    : {}),
    });
    const references = {
    mode: 'exact' as const,
    sourceSessionId: input.sourceSessionId,
    targetSessionId: input.targetSessionId,
    artifactIds: artifactCopy.artifactIds,
    relativePaths: artifactCopy.relativePaths,
    contextRefs: new Map(
    contextCopy.copied.map(({ sourceRefId, targetRefId }) => [sourceRefId, targetRefId]),
    ),
    linkedChildren:
    kind === 'side_conversation'
    ? {
    mode: 'snapshot' as const,
    archivedResults: archivedSnapshotResults,
    }
    : linkedReferences.references.size > 0
    ? {
    mode: 'preserve_validated' as const,
    references: linkedReferences.references,
    }
    : { mode: 'reject' as const },
    };
    const runtimeCopy = await cloneConversationRuntimeLedger({
    plan,
    copiedMessages: slice.messages,
    referenceMap: references,
    runStore: this.#stores.agentRunStore,
    runtimeEventStore: this.#stores.runtimeEventStore,
    newId: randomUUID,
    });
  • Cross-client integration coverage verifies that revision copies retain readable user-uploaded bytes under a rewritten target Session ref:
    // Every copy kind that retains the upload turn must carry a rewritten,
    // readable copy of the user-uploaded attachment (regression guard for the
    // turn-scoped-only artifact selection that dropped user uploads).
    const assertCopiedUpload = async (sessionId: string): Promise<void> => {
    const sessionMessages = await readLedgerMessages(execution.runtimeEventStore, sessionId);
    const uploadMessage = sessionMessages.find(
    (message) => message.type === 'user' && message.attachments?.[0],
    );
    const uploadRef =
    uploadMessage?.type === 'user' ? uploadMessage.attachments?.[0]?.ref : undefined;
    assert.equal(uploadRef?.kind, 'session_file', `${sessionId} must retain the upload`);
    if (uploadRef?.kind !== 'session_file') return;
    assert.equal(uploadRef.sessionId, sessionId);
    assert.notEqual(uploadRef.relativePath, 'source-artifact');
    assert.deepEqual(await artifacts.readTextInSession(sessionId, uploadRef.relativePath), {
    ok: true,
    text: 'retained bytes',
    });
  • TUI creates the same session.revision.create copy but returns only userFacingText(promptMessage) to its editor:
    async rewindToTurn(turnId: string): Promise<MakaSessionRewindResult> {
    const sourceSessionId = this.#requireSession('rewind');
    const messages = await this.readMessages();
    const promptMessage = messages.find(
    (message): message is Extract<StoredMessage, { type: 'user' }> =>
    message.type === 'user' && message.turnId === turnId,
    );
    if (!promptMessage) throw new Error(`Cannot rewind to turn ${turnId}: no user prompt.`);
    if (promptMessage.origin) {
    throw new Error(`Cannot rewind to turn ${turnId}: Host-triggered prompts are read-only.`);
    }
    const targetSessionId = this.#newId();
    for (let attempt = 0; attempt < MAX_CATALOG_ATTEMPTS; attempt += 1) {
    const current = await getRuntimeHostSession(this.#connection, sourceSessionId);
    if (!current) throw new Error(`Session not found: ${sourceSessionId}`);
    const result = await this.#request('session.revision.create', {
    sourceSessionId,
    targetSessionId,
    sourceTurnId: turnId,
    expectedSourceRevision: current.revision,
    });
    if (result.kind === 'committed') {
    return {
    ...(await this.switchSession(requireSession(result.session).id)),
    prompt: userFacingText(promptMessage),
    };
  • TUI's ordinary submit path sends only text/displayText:
    submitMessage(
    text: string,
    options: MakaSubmitMessageOptions,
    ): Promise<OperationOutput<'turn.message.submit'> | undefined> {
    return this.#admit(() => this.#submitMessage(text, options));
    }
    async #submitMessage(
    text: string,
    options: MakaSubmitMessageOptions,
    ): Promise<OperationOutput<'turn.message.submit'> | undefined> {
    const sessionId = await this.#ensureSession();
    const sessionGeneration = this.#sessionGeneration;
    const configuration = await this.#loadConfiguration(sessionId);
    this.#assertCurrentSession(sessionId, sessionGeneration);
    await this.#ensureChannel(sessionId);
    this.#assertCurrentSession(sessionId, sessionGeneration);
    this.#adoptLoadedConfiguration(configuration);
    const modelText = options.modelText ?? text;
    try {
    return await this.#request('turn.message.submit', {
    originHostEpoch: this.#connection.hostEpoch,
    sessionId,
    messageId: options.messageId,
    content: {
    text: modelText,
    ...(modelText === text ? {} : { displayText: text }),
    },
    placement: options.placement,
    ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}),
    });
    } catch (error) {

Verification performed

The relevant current-source packages were rebuilt, then these focused tests passed:

✔ two Clients share exact retryable Session branch and revision authority
✔ opens a rewind picker from /rewind and branches on select

The first test covers exact retained-history attachment copying in revision copies. The existing TUI rewind test covers plain-text refill only; it does not cover selected-turn quotes or attachments, which is the missing regression case.

Suggested ownership boundary

The Host should remain authoritative for copying Session-owned artifact bytes and rewriting refs. Surface-specific clients should own the editable draft presentation, but consume/submit one shared canonical revision-draft content shape rather than reconstructing a turn from display text.

Unchanged detection must compare canonical structured content, not only text.trim(). In the current Desktop path, hasPendingContext counts attachments/directories but not quotes, so merely wiring the existing quote restore helper would still misclassify quote-only edits as unchanged.

Activity

  1. ggbdpq commented on Sep 9, 2026

    @ggbdpq
    Contributor

    take

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions