Repository navigation
Edit-and-resend revisions reject or drop structured message context across Desktop and TUI #5109
Description
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:
- 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. - 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.
- TUI
/rewindpermits the selected turn but restores onlyuserFacingText(promptMessage). Its subsequent submit carries onlytext/displayText, so a selected message'squotesorattachmentsare 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
- Open a Desktop conversation.
- Paste text containing at least 1,000 characters or more than 10 lines.
- Send the message and wait for the turn to settle.
- 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
- Send turn T1 with an uploaded image.
- Send a plain-text turn T2 and wait for it to settle.
- 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
- Open in TUI a Session containing a user turn with
quotesorattachments(for example, a turn created in Desktop). - Run
/rewindand select that turn. - 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:
maka/packages/ui/src/chat-turn.tsx
Lines 580 to 598 in fcff80f
// 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 (
originalTextandpreviousComposerText), not canonical structured message content:
maka/apps/desktop/src/renderer/app-shell-revision-actions.ts
Lines 54 to 66 in fcff80f
/** 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:
maka/apps/desktop/src/renderer/app-shell-revision-actions.ts
Lines 156 to 178 in fcff80f
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
beforeboundary for the selected turn:
maka/packages/runtime-host/src/server/session-revision-coordinator.ts
Lines 358 to 373 in fcff80f
// 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:
maka/packages/runtime-host/src/server/session-revision-coordinator.ts
Lines 423 to 428 in fcff80f
const referencedSessionFileIds = collectConversationCopySessionFileRefs({ sourceSessionId: input.sourceSessionId, messages: slice.messages, runtimeEvents: plan.runs.flatMap(({ runtimeEvents }) => runtimeEvents), archivedResults: archivePreflight.serializedResults, });
maka/packages/runtime-host/src/server/session-revision-coordinator.ts
Lines 554 to 602 in fcff80f
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:
maka/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts
Lines 1630 to 1647 in fcff80f
// 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.createcopy but returns onlyuserFacingText(promptMessage)to its editor:
maka/packages/cli/src/runtime-host-session-driver.ts
Lines 855 to 880 in fcff80f
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:
maka/packages/cli/src/runtime-host-session-driver.ts
Lines 519 to 550 in fcff80f
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
ggbdpq commentedon Sep 9, 2026
take
fix(desktop): drop the stale retained-attachment revision refusal
fix(desktop,cli): preserve structured-history revision boundaries (#5118
fix(cli): avoid unsafe optional chaining in the staged-quotes assertion
chore(desktop): refresh the renderer architecture ledger
refactor(desktop,ui): move revision staged-context helpers into @maka/ui
refactor(desktop,ui): tighten the revision staged-context call sites
fix(cli): scope quote restaging to the origin draft and admit quote-o…
refactor(desktop,ui): move the revision lifecycle into @maka/ui
chore: retrigger CI
fix(desktop): keep the revision assembler at one @maka/ui import
fix(cli): make quote restaging reachable and generation-safe
fix(cli): supersede a pending quote restoration on submits and clears
fix(desktop): send the revision's quotes from a pre-gate snapshot
fix(desktop): send the revision's quotes from a pre-gate snapshot
fix(cli): restage a retracted message's quotes instead of dropping them
fix(cli): fence a retraction to the session it was asked for
fix(desktop,ui): restage attachments on edit and count quote comments…
fix(desktop,ui): restage attachments on edit and count quote comments…
fix(cli): fence the whole retraction, and its stop, to the owning ses…
fix(cli): serialize session switches behind an in-flight retraction
fix(cli): keep retraction-restored quotes and close-window typing fro…
fix(cli): keep retry provenance across failed submissions and switches