Repository navigation
design(desktop): unify Regenerate, Branch, and Edit-and-resend into one retry-from-turn model #5132
Description
Problem
A settled turn exposes three ways to "go back and try again", and they do not share a model. Each one lands in a different place, keeps a different kind of history, and enforces a different set of rules. The seams between them are where users get stuck.
Current behaviour, verified against main (8b3655a17):
| Entry point | Host operation | Where the result lands | What the model sees |
|---|---|---|---|
| Assistant footer Regenerate | turn.regenerate (interactive-turn-coordinator.ts:191) |
A new turn appended to the same Session, tagged regeneratedFromTurnId |
The full linear history, including the answer being regenerated. model-history.ts has no lineage-aware exclusion. |
| Assistant footer Branch | session.branch.create, slice mode through (session-revision-coordinator.ts:364) |
A new Session copied up to and including the turn | Clean copy |
| User message Edit (pencil) | session.revision.create, slice mode before, then a normal send (app-shell-revision-actions.ts:75) |
A new Session copied up to but excluding the turn; the TUI /rewind uses the same operation |
Clean copy |
Concrete symptoms:
- Regenerate is not a fork, it is "ask the same thing again in the same conversation". The old answer stays in the transcript and in the model context, so the model is told the user asked twice. The UI stacks both turns and links them with "regenerated from / regenerated to" badges. Users who expected a replacement or a branch read this as the app re-sending their message.
- Sending an unchanged edit is refused and redirected to a different feature. app-shell.tsx:1747 blocks the send with "Nothing changed. Use Regenerate if you only want a new answer." The user is already in a flow the toast described as "rewound to before that message", and is now told to leave it and use a button with different semantics (same Session, old answer kept). fix(ui): expose Regenerate for failed turns with no assistant output #2372 recorded one shape of this dead end where Regenerate was not even rendered.
- Rewind has no entry point of its own; it is hidden inside Edit. Clicking the pencil only refills the composer. The revision copy is created lazily at send time, which is why the desktop shell carries a
reserved / started / abandoningcopy-attempt state machine and a rollback path (app-shell-revision-actions.ts:278-407) whose whole job is to make "nothing happened yet" survive cancel, navigation and send failures. - Branch and Edit create two differently-named kinds of Session. Branch records
branchOfTurnId; Edit records a revision family (revisionRootSessionId,revisionParentSessionId,revisionIndex). The sidebar shows "branch" tasks and "version N" tasks for what the user experiences as one action: continue from an earlier point. - Edit eligibility is a pile of independent guards (attachments, directory references, quotes, transformed skill text, slash commands, another edit in progress), each surfaced as a separate toast (chat-turn.tsx:583-601). Edit-and-resend revisions reject or drop structured message context across Desktop and TUI #5109 / fix(desktop,cli): stop rejecting legal edit-and-resend / rewind on structured history #5118 are already peeling one of them off, which shows they are patches rather than one contract.
- Footer availability is derived from
TurnStatusonly (turn-footer-actions.ts); the Host then rejects withsession_busywhen the Session has a live root turn. The user learns the action is unavailable only after clicking.
The root of all six is that "go back to before turn T and submit again, with the text either changed or unchanged" is one operation, but it is implemented as two entry points (Regenerate, Edit) with two storage semantics (append in place vs. revision copy) and two rule sets. The "unchanged text" boundary falls exactly in the gap between them.
Desired outcome
One decision on the model, recorded here before any implementation:
- One primitive for "retry from T". Regenerate becomes the unchanged-text case of the same operation Edit uses, so both produce the same kind of history and the old answer is never in the model context of the new attempt. Whether that operation lands in a new Session (today's revision copy) or as a hidden sibling in the same Session is the design question to settle; either is acceptable, both at once is not.
- The unchanged-text guard goes away. Sending the same text from an edit draft is simply a regenerate.
- Branch stays as the one "continue from here in a new Session, keep the answer" action, and Branch and revision copies share one lineage vocabulary in the catalog and sidebar, or the two are explicitly documented as different products with different names.
- Eligibility is one contract, stated once, ideally by the Host (
operation_unavailable/session_busyreasons) and reflected in the footer before the click, not a set of renderer-side toasts.
Non-goals for this issue: any change to what Branch copies, side conversations (/side), or the Agent Graph revision history in #2039.
Alternatives or workarounds
- Keep three entry points but rename them honestly ("Ask again", "Branch", "Edit") and drop the unchanged-text redirect. This removes the worst confusion but leaves the double storage model and the duplicated answer in the model context.
- Keep Regenerate as an in-Session sibling but exclude the superseded turn from
model-historyand collapse it in the transcript. This fixes the context problem without touching the revision path, at the cost of a third lineage rule.
Related: #2372, #5109, #5118, #4994, #1287 (the PR that introduced edit-and-resend as a revision copy).
Activity
fix(ui): render message action rows with Astryx Icon and IconButton (#…
Nyvo-io commentedon Sep 13, 2026
I'd like to take #5132, starting with the design decision rather than an implementation PR.
After reading #700, #1287, #5109/#5118, #4994 and the backend architecture, my initial preference is to make Regenerate the unchanged-input case of the existing revision path: retry before T into a new physical Session within the same logical conversation/version family. This is a proposal, not an assumption that the issue has already settled the choice.
Importantly, #1287 deliberately distinguishes conversation versions from ordinary branches. I would preserve that product distinction: Branch still continues through T as an independent conversation; retry continues before T. A consistent vocabulary need not erase their different lifecycle semantics.
I propose a short design note covering:
Two useful precedents, with different tradeoffs:
Before recommending the revision-copy option conclusively, I would compare its long-history latency and copied bytes with the sibling alternative, taking the copy/retention concerns in Discussion #4704 into account. Verification would also capture actual provider inputs and inject failures at copy/admission boundaries, rather than relying only on UI screenshots.
Does this design-first scope fit the intended direction? I will wait for agreement on the model before implementation, and follow CONTRIBUTING's public dev-list process for material product decisions.
AI disclosure: this comment was prepared and posted by OpenAI Codex at @Nyvo-io's request.
docs(desktop): propose unified retry-from-turn model for apache#5132
docs(desktop): propose unified retry-from-turn model for apache#5132
docs(desktop): propose unified retry-from-turn model for apache#5132
docs(desktop): propose unified retry-from-turn model for apache#5132
docs(desktop): propose unified retry-from-turn model for apache#5132
Nyvo-io commentedon Sep 16, 2026
1. The model
Retry uses the existing Host-owned
session.revision.createpath with abeforeslice (packages/runtime-host/src/server/session-revision-coordinator.ts:378) and creates a new physical Session in the existing revision family. The source Session and its Runtime Events are not rewritten by retry and remain available for independent later use. The target holds the source prefix strictly beforeTplus one normally admitted user turn.regenerateTurnstays as a client name but routes through this operation instead of the current same-Session append (packages/runtime/src/session-manager.ts:4147-4160).Branch keeps its distinct lifecycle: it copies through
Tas an independent conversation. Retry continues beforeT. The two are not unified in storage.Out of scope, unchanged: files, Git state, external side effects, Side Conversations, Agent Graph revisions.
2. Lineage
New retries do not write
regeneratedFromTurnId. Its Desktop consumer renders that edge only when the referenced Turn exists in the displayed Session's materialized view (apps/desktop/src/renderer/app-shell-turn-view-model.ts:137,derive-turn-lineage-badges.ts:56-61), and the retry target deliberately does not containT. New retries use the existing revision provenance instead:revisionRootSessionId,revisionParentSessionId,revisionOfTurnId,revisionIndex(packages/core/src/session.ts:285-293). ExistingregeneratedFromTurnIdrecords stay readable and are not rewritten.3. Binding between copy identity and the first replacement admission
requestFingerprintidentifies copy kind, source Session, target Session, and source turn (conversationCopyFingerprint,session-revision-coordinator.ts:225). It answers whether a target exists; it does not identify the replacement content or the first admitted Turn.Root-turn admission already supplies the durable half:
admitRootTurnkeys on(sessionId, turnId)and returnsadmitted | existing | conflict, with canonical content, placement, and intent compared on replay (packages/storage/src/agent-run-store.ts:365-378). What is missing is a retry-owned stable identity: Desktop generatescrypto.randomUUID()per submit (apps/desktop/src/renderer/app-shell-chat-actions.ts:345,:513), so a resend after a disconnect is a new identity and the existing idempotency never fires.The binding extends
SessionConversationCopythe same waySubagentSessionSpawnalready works. That type carriesrequestFingerprint,initialTurnId,initialRunId, is registered in a strict shape guard, and has a decoder guard (packages/core/src/session.ts:164-169,:497-500,:575). Conversation copies should take the same shape:SessionConversationCopygains the derived first-message identity, the derived first-Turn identity, and the canonical-content digest of the replacement input. Its entry inSESSION_CONVERSATION_COPY_SHAPE, the strictisSessionConversationCopyguard, andisValidConversationCopyTransitionare extended in the same change (packages/storage/src/session-conversation-copy.ts:32-48, enforced on every write atsqlite-session-metadata-store.ts:6244). The new fields stay optional so an old record remains readable; a malformed new one fails closed; and the transition check holds them immutable alongsiderequestFingerprint, since the binding is fixed when the copy commits. Without that third extension a later patch could rewrite the binding while every existing check still passes.existing; different identity or canonical content for the same target copy isconflict.The digest is not content storage. Before admission the initiating client still holds the full canonical payload; the Host holds the binding, not a recoverable draft.
4. Recovery: four boundaries, with the abandoned/absent choice made
session.revision.abandonon cancel or orderly exit; if it disappears without cleanup, Hostrecover()discards the orphan under existing rules.The choice, stated explicitly: abandoned and absent are indistinguishable.
discardStableConversationCopydeletes the metadata and claim rows with no tombstone (packages/storage/src/session-store.ts:457-478,sqlite-session-metadata-store.ts:1005-1010), so a replay of a discarded target resolves asabsent. It does not resume that attempt: the client starts a new attempt after fresh eligibility checks, which means a newcopyId, a newtargetSessionId, and a new version seat in the family. No tombstone or retention rule is added.Preparing copies are excluded from the Host catalog query (
packages/storage/src/sqlite-session-catalog-query.ts:41). Acommittedcopy awaiting its first admission is still returned by that query; the hiding is client-side, invisibleSessionRevisionMembers(packages/core/src/session-revisions.ts:33-43), which also keeps the currently active version visible. This decision does not change either behavior. An admitted target remains recoverable from its own admission facts.5. Prerequisites before implementation
dev@maka.apache.org, perCONTRIBUTING— this is a product decision.Astro-Han commentedon Sep 17, 2026
Updated position after auditing this against
main: remove Regenerate entirely. Edit-and-resend already covers its only function with strictly better semantics, so this issue becomes a deletion rather than a redesign.@Nyvo-io your model had the right shape: retry as a revision copy
beforeT plus a normal admission, and the binding analysis was correct for the two-RPC flow. None of it is needed if we never add the operation. One thing worth keeping from the discussion: had we kept two RPCs, the window between committed copy and first admission is optional anyway, since a fused copy-plus-admit operation removes it. That argument no longer matters here, but the instinct is right.What deletion buys:
/rewindalready is this operation, so the vocabulary settles on its own.Deletion surface, verified on
main:turn.regenerate: protocol registration (protocol/turn.ts:273,operations.ts:354), handler (interactive-turn-coordinator.ts:62,194), wiring (execution-composition.ts:1908), Desktop IPC/preload/bridge contract.regenerateTurn(session-manager.ts:4147) and the:4248), plus the read-model and recovery projections of it.regeneratedFromTurnIdwrite (turn-footer-actions.ts), lineage badges (derive-turn-lineage-badges.ts,app-shell-turn-view-model.ts), the unchanged guard inapp-shell.tsx, copy strings.session-turn-access-request-coordinator.ts:36-48wrapsregenerateTurnfor turn-request flows; that path needs an explicit answer (drop the guest affordance or route it through revision copy).regeneratedFromTurnIdrecords stay readable; whether the badge consumer keeps rendering old data or goes with it, pick one in the PR.One gap to name rather than silently ship: turns carrying attachments, quotes, or directory references are Edit-ineligible today, so once Regenerate is gone those turns have no retry path until #5109/#5118 land the structured-content support. Accept that explicitly or sequence the removal after them.
What survives from the original issue: eligibility as one Host-owned contract reflected in the footer before the click. That is now a small cleanup on the single remaining path, not a redesign.
@Nyvo-io if you still want the work, it is now a removal PR and a smaller one than the design implied.
中文版
更新结论:整个砍掉 Regenerate。"编辑上一条重发"已覆盖它唯一的功能且语义更好,本 issue 从设计建造变成纯删除。Nyvo-io 的模型方向是对的(retry = 修订拷贝 before T + 正常准入),但只要不加这个操作,绑定/恢复那套机制全都不需要。删除面:
turn.regenerate协议注册、coordinator、session-manager 与 regeneratedFromTurnId 投影、footer 按钮与 badge、Desktop IPC 桥、guest-turn 包装(需单独处置);epoch 需 bump,旧记录保持可读。唯一要点名的缺口:带附件/引用/目录的 turn 现在 Edit 不可用,删 Regenerate 后它们没有重试路径,要么明确接受要么排在 #5109/#5118 之后。幸存的诉求只剩"eligibility 一个 Host 契约、点击前反映到 footer",降级成单一路径的小清理。Astro-Han commentedon Sep 17, 2026
A related but separable question, probably worth its own thread if it gains traction: the storage model underneath revisions.
Every revision and branch today materializes the sliced prefix into a new Session: transcript messages, ledger events, artifacts, model_call rows, linked references, all physically copied. The alternative is a shared-prefix fork tree, where a revision stores a pointer (parent session plus through-sequence) and shares the prefix instead of duplicating it. This is the model pi uses.
What the current choice buys: a Session is a self-contained unit of ownership. Delete means delete its rows, export and import bundles stand alone (load-bearing since #5308), usage attribution is a group-by on sessionId, and ledger repair never crosses session boundaries.
What a fork tree buys: O(1) forks and unbounded cheap retries. What it costs: every read path resolves the ancestor chain (transcript, usage screens, repair, export), deletion needs a policy (refuse, cascade, or materialize-on-delete), usage attribution on the shared prefix gets ambiguous, export still materializes lazily so the copy moves rather than vanishes, and the shared prefix must stay frozen while parents today can grow turns, repair rewrites events, and retention compacts.
This may be YAGNI for now. The decision gate is the same #4704 copy-cost baseline: if prefix copies stay cheap at real history sizes, materialization is right and this whole question is moot. If not, fork tree is where Edit and Branch costs end up long-term, and that is a bigger conversation than this issue.
@likun666661 @M4n5ter curious how you two see it: is the self-contained-session invariant worth defending at copy cost, or is shared lineage the long-term direction?
likun666661 commentedon Sep 17, 2026
My view is that shared lineage is probably the better long-term direction. A revision or branch only needs topology metadata to describe the fork:
The important constraint is that the child must reference an immutable snapshot boundary, not a live view of the parent. Parents may continue growing, and repair or retention may rewrite or compact their physical representation; none of that should change the prefix observed by an existing child.
The fork itself is therefore straightforward and O(1). Most of the real design work is in lifecycle and projection semantics: deletion policy, artifact/blob ownership or reference counting, usage attribution, standalone export, repair across lineage, and preventing deep ancestor chains from making reads progressively more expensive.
A practical middle ground would be shared-prefix lineage with local suffixes, plus checkpoints or materialization when a chain becomes deep or when an operation such as export or parent deletion requires a self-contained Session. Content-addressed immutable artifacts would fit that model as well. This keeps ordinary Edit/Branch operations cheap without requiring every read path to traverse an unbounded tree forever.
I would keep physical self-contained copies if the #4704 baseline shows that they remain cheap at representative history sizes and the operational simplicity materially outweighs the duplication. If copying becomes visible in latency, storage, or write amplification, though, metadata-backed shared lineage seems preferable to optimizing repeated full-prefix copies.
M4n5ter commentedon Sep 17, 2026
English
I lean toward sharing immutable history. Being able to use, delete, and export each Session independently does not mean every Session must store a separate copy of its entire history. We should preserve that independence while allowing Sessions to share the same underlying historical data.
My direction is broadly aligned with @likun666661’s. A child Session should reference a fixed history snapshot and append its own content.
parentSessionIdcan record provenance, but reading that history should not require the parent Session to remain alive, nor should subsequent appends, repairs, or compaction in the parent change what the child sees.I would favor settling a few semantics explicitly:
On implementation, I would avoid having every Edit add another Session-parent layer that readers must traverse. Shared history blocks and their indexes should be able to outlive individual Sessions. Checkpoints or materialization can bound read costs; a prototype can establish the appropriate structure.
One relevant detail: the current context-offload
copyReferencespath already creates target-Session references while reusing the sameblobId. We are therefore not starting from entirely exclusive storage. It is worth mapping the existing sharing boundary before deciding which additional Artifact payloads or event history should be shared.I support collecting a copy-cost baseline on the current implementation. It should guide optimization priorities and migration timing, but should not alone determine history identity or accounting semantics. A useful next step would be a small storage prototype that verifies stable inherited history and model inputs across parent continuation, deletion, and repair, as well as standalone export/import of the child. We can then compare write volume, read latency, and recovery cost. This work can proceed independently of the interaction simplification in #5132.
简体中文
我倾向共享不可变历史。会话能独立使用、删除和导出,并不意味着每个会话都必须复制并保存一份完整历史。我们应该保留会话操作上的独立性,同时允许底层共用相同的历史数据。
我和 @likun666661 的方向基本一致。子会话应引用一个固定的历史快照,再追加自己的内容。
parentSessionId可以记录来源,但读取历史不应依赖父 Session 继续存在,也不能受父会话后续追加、修复或压缩影响。几个语义我倾向直接确定下来:
实现上,我不希望每次 Edit 都增加一层必须遍历的 Session 父链。共享历史块及其索引应能独立于会话生命周期存在;checkpoint 或物化是控制读取成本的手段,具体结构可以通过原型确定。
还有一点:当前 context-offload 的
copyReferences已经为目标 Session 创建独立引用、复用相同blobId,所以我们并非从完全独占存储起步。可以先明确现有共享边界,再判断哪些 Artifact 正文或事件历史值得继续共享。我支持补充当前实现上的 copy-cost baseline,但它应该决定优化优先级和迁移时机,不应独自决定历史身份与计费语义。下一步更有价值的是一个小范围存储原型,验证父会话继续、删除、修复,以及子会话独立导出再导入后,继承历史和模型输入仍然稳定,再比较写入量、读取延迟和恢复成本。这项工作可以独立于 #5132 的交互简化推进。
Astro-Han commentedon Sep 17, 2026
Storage-model discussion moved to #5439 — both @likun666661 and @M4n5ter lean toward shared-prefix lineage with boundary materialization; prototype and baseline live there now. (Correction: the copy-cost baseline I cited as #4704 does not exist as an issue; it is now tracked as the first deliverable in #5439.)
For the removal PR itself, three scoping decisions:
setTextmachinery already exists on the send-failure path); no new Host op, and side conversations stay out of the revision family.session-turn-access-request-coordinator'sregenerateTurnwrap should route through revision copy if the guest has copy permission; otherwise drop the guest retry affordance in the same PR.regeneratedFromTurnId: stop writing, keep reading. Old badges keep rendering from existing records; removal only stops producing new ones.Nyvo-io commentedon Sep 17, 2026
@Astro-Han I’ll follow these decisions in one removal PR: replace side-chat regenerate with composer refill, route guest retry through revision copy when permitted and otherwise remove the affordance, and keep legacy lineage reads/badges while stopping new writes. I’ll document the temporary structured-content retry gap against #5109/#5118, bump the compatibility epoch, and keep eligibility Host-owned and reflected in the footer. Branch stays unchanged; storage work stays in #5439.
Nyvo-io commentedon Sep 18, 2026
Implemented in #5468. Regenerate is removed; main chat uses Edit & resend, Side Chat refills the composer through message edit, and historical lineage remains readable.