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
ggbdpq commentedon Oct 22, 2026
Status reconciliation after the two sibling PRs landed — two of the three reported behaviors are fixed on current
main, and the remaining one is by design rather than a stale guard:92b8c086c): the edit now stages the selected message's quotes and they ride the revision submit.revision-actions.tsdocuments "retained historical attachments are fine"./rewinddropping structured context — fixed by fix(cli): stage a rewound turn's quotes into the replacement submit #5265 (currently open, awaiting review).What remains is the fail-closed corner the issue itself anticipated: editing a selected message that itself carries attachments still refuses with
revisionAttachmentsUnsupported. That is not a stale guard —ComposerHandlehas no attachment API, and the attachments' rewritten revision targets are not exposed to clients, so a re-attached draft cannot be represented client-side today. Opening this up needs a shape decision first: either the Host exposes rewritten refs for revision drafts, or the revision request carries the source attachment refs and the Host performs the rewrite at send time. Happy to implement once the direction is picked — flagging @me2seeks @liugddx for the call.ggbdpq commentedon Oct 22, 2026
Implemented the remaining corner — PR: #6246
The Host now stages the edited turn's attachments into the revision copy (the include path built for user uploads) and returns the rewritten target-owned refs on the committed response; the shared lifecycle restages them as retained pending items at edit start, swaps the refs on prepare, and re-keys the plate onto the branch child — the same flow quotes follow since #5274. The fail-closed corner in my status comment above is thereby closed rather than recommended away.
fix(host): stage uploaded revision attachments by artifact id too