feat(conversation): fork a conversation into a new one at a chosen message - #772
Merged
Conversation
added 6 commits
August 4, 2026 16:37
… fork capability seeds - migration 036: messages.backend_turn_id (codex thread/fork lastTurnId anchor) and constructed session_capabilities.fork declarations for the claude/codex builtin agent_metadata rows (json_patch merge; agy intentionally omitted) - MessageRow.backend_turn_id threaded through both INSERT paths (upsert keeps the first non-NULL anchor) and all struct literals workspace-wide (None until the stream-persistence wiring lands) - IConversationRepository::copy_messages_up_to — single-transaction inclusive copy up to a (created_at, id) cursor with monotonic UUIDv7 id reminting, msg_id/created_at preserved, anchors dropped, one recency bump - IConversationRepository::resolve_backend_turn_anchor — nearest non-NULL anchor at or before the fork point, user-scoped - 4 new repository tests covering inclusivity, order preservation, transaction atomicity, and anchor resolution
…nchor event
- SessionSpec::Fork{session_id, parent_backend_session_id, at_turn_id}: fork is
an OPEN mode, not a runtime Command - each forked conversation forks on its
own connection, so codex's unconditional thread/started rebind can never
hijack the parent session's binding or resume anchor
- codex: run_handshake three-mode HandshakeMode{Fresh,Resume,Fork}; Fork sends
thread/fork{threadId,lastTurnId?} with the full thread/start override surface,
pre-seeds NO binding (the NEW thread's id arrives via thread/started), and a
fork rejection poisons the bound-thread wait via the new pending_fork slot -
fail fast, never a silent fresh/HEAD fallback
- codex: turn/started now also lowers SessionEvent::BackendTurnBound (codex's
own Turn.id) so the conversation layer can stamp messages.backend_turn_id,
the lookup key for mid-history fork anchors
- claude: LegacySessionSpec::ForkFrom maps to argv "--resume <parent>
--fork-session" (no --session-id; claude mints the new sid and reports it in
system/init)
- claude wake-recipe fix (pre-existing bug, fork makes it fatal): the resume
anchor is now a shared mutable slot the init sniffer rotates to whatever sid
claude actually reports, so an idle-wake resumes the live session instead of
re-forking / resurrecting the parent
- event/reducer: BackendTurnBound classified orchestration-lowered, reducer
no-op, never persisted; acp_conn/antigravity explicitly reject Fork specs
- tests: codex fork down/up/error legs, claude ForkFrom argv, slot rotation
unit + wake-after-rotation end-to-end
…, turn-anchor stamping - AcpBuildExtra.fork: Option<ForkSpec> (new aionui-api-types type) — the fork spec rides conversations.extra into the build context with zero new plumbing - spec_mode_model: unbound + fork spec opens SessionSpec::Fork against the parent's snapshotted sid; a bound sid ALWAYS resumes (fork completed, the spec degrades to lineage data); a post-fork dead-anchor self-heal therefore RE-FORKS back to the fork point instead of opening Fresh - ACP manager: ensure_session_opened routes unbound fork-spec sessions to the new open_session_fork — live sessionCapabilities.fork gate (second line of defense behind the fork API's agent_metadata check), session/fork request mirrors session/load's cwd/mcp_servers, response absorbed like load, new sid persisted via SessionAssigned; SessionNotFound and missing capability are explicit errors, never a silent session/new fallback - factory: antigravity build rejects fork specs defensively - relay/persistence: internal-only AgentStreamEvent::BackendTurnBound frame (never forwarded to the WS) lets StreamPersistenceAdapter stamp messages.backend_turn_id on every row of the in-flight turn - tests: spec quadrant matrix, fork-capability gate trio
…ability projection
- POST /api/conversations/{id}/fork {message_id, name?}: pure-bookkeeping fork
(the backend session materializes lazily on the fork's first open; the
frontend follows with runtime/ensure for eager failure surfacing)
- ConversationService::fork: ownership/team/turn-in-flight/capability/anchor
validation with stable reason prefixes (409 FORK_TURN_IN_FLIGHT,
FORK_PARENT_UNBOUND; 422 FORK_UNSUPPORTED, FORK_POINT_UNSUPPORTED), parent
sid snapshotted into extra.fork, same workspace (claude cwd constraint),
assistant snapshot + acp_session identity + mode/model runtime state copied,
visible history copied up to the fork point inclusive
- codex mid-history forks resolve messages.backend_turn_id to thread/fork's
lastTurnId; unresolvable anchors refuse explicitly, never degrade to HEAD
- create() strips a client-supplied extra.fork (server-minted only)
- delete() keeps an auto-provisioned workspace while other conversations still
share it (list_associated guard, checked before the row delete, fails closed)
- ConversationResponse.fork_capability: Option<ForkCapabilityView{at_turn}>
projected from agent_metadata.agent_capabilities on the DETAIL path only
- 8 integration tests over the real in-memory DB (036 seeds included)
…rce inheritance, wake-slot test seam)
Merged
13 tasks
added 5 commits
August 4, 2026 17:07
…cise fork-entry gating
Without it, live-streamed messages have no anchor until a history reload, so codex mid-history fork entries only appeared after leaving and re-entering the conversation. The relay now injects the adapter's current turn anchor into every message.stream payload (same or_insert pattern as turn_id).
codex answers thread/start with thread/started in single-digit milliseconds (local bookkeeping, no LLM call), so on a fresh open the live BackendBound broadcast routinely fires before SessionAgentTask::new subscribes and is lost - the conversation then never persists acp_session.session_id, resume after a backend restart silently opens a NEW thread, and the fork API refuses with FORK_PARENT_UNBOUND. Forked conversations were unaffected only because thread/fork copies rollout history and responds slowly enough for the subscription to win the race. events() now prefaces every new subscription with a synthetic BackendBound carrying the current binding (idempotent downstream: same-value set_session_id + same-value DB write). Pre-existing bug, observed live: every fresh dev codex conversation had a NULL anchor while every forked one was bound.
Live-streamed frontend messages carry a frontend-local id that is never persisted; only their stream msg_id matches a DB row. The fork API now falls back to an (conversation, msg_id) lookup (earliest match) when the row-id lookup misses, so forking a message that arrived over the live stream works without a history reload.
kaizhou-lab
enabled auto-merge (squash)
August 4, 2026 09:47
kaizhou-lab
pushed a commit
that referenced
this pull request
Aug 4, 2026
🤖 I have created a release *beep* *boop* --- ## [0.1.58](v0.1.57...v0.1.58) (2026-08-04) ### Features * **conversation:** agent-driven session auto-naming (ACP session_info_update + claude generate_session_title) ([#768](#768)) ([c551c66](c551c66)) * **conversation:** fork a conversation into a new one at a chosen message ([#772](#772)) ([bb7c14e](bb7c14e)) * **fs:** add ChatFileRef content endpoints ([#757](#757)) ([e8b6f4c](e8b6f4c)) * **fs:** pdf stream endpoint + office ChatFileRef resolve + retire fs/resolve & WS fs/read ([#762](#762)) ([e4e991a](e4e991a)) ### Bug Fixes * **agent:** keep the thought-level picker on a resumed conversation ([#763](#763)) ([dcf015e](dcf015e)) * **runtime:** add bounded retry to managed node version probe ([#771](#771)) ([1dee9f1](1dee9f1)) * **session:** keep claude session cost cumulative across process respawns ([#767](#767)) ([f498e9d](f498e9d)) * **session:** settle cards through teardown and resume so no stored row spins forever ([#766](#766)) ([3f61cc4](3f61cc4)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #770
What
Message-level conversation fork:
POST /api/conversations/{id}/fork {message_id, name?}branches a conversation into a NEW one that inherits the agent-side context, per backend mechanism:thread/fork+lastTurnId--resume <parent> --fork-sessionsession/fork(capability-gated viasessionCapabilities.fork)How
extra.forklineage + parent-sid snapshot → copy visible history up to the fork point inclusive). The backend session materializes on the fork's FIRST open, on the fork's own connection/process:SessionSpec::Fork(direct CLI) /open_session_fork(ACP manager). The existingruntime/ensureendpoint gives callers eager failure surfacing.run_handshakethree-mode (Fresh/Resume/Fork); Fork sendsthread/forkwith the fullthread/startoverride surface, pre-seeds NO thread binding (the NEW thread's id arrives viathread/started), and a rejection poisons the bound-thread wait via apending_forkslot — fail fast, never a silent fresh/HEAD fallback.turn/startednow lowersBackendTurnBound(codex's ownTurn.id) which the relay stamps ontomessages.backend_turn_id— the lookup key for mid-history fork anchors; unresolvable anchors are refused with 422.LegacySessionSpec::ForkFrom→--resume <parent> --fork-session(no--session-id; the new sid is learned fromsystem/init). Also fixes a pre-existing wake-recipe bug fork would have made fatal: the resume anchor is now a shared slot the init sniffer rotates, so an idle-wake resumes the sid claude actually reported instead of re-forking / resurrecting the parent.open_session_forkmirrors thesession/loadflow (cwd/mcp_servers/response absorption/SessionAssigned), with a livesessionCapabilities.forkgate as the second line of defense behind theagent_metadatacheck.SessionNotFoundand missing capability are explicit errors — an initial fork never silently degrades tosession/new.session_capabilities.forkfor the builtin claude ({}) / codex ({"at_turn":true}) rows viajson_patch; ACP agents get it from the persisted handshake. Projected asConversationResponse.fork_capabilityon the detail path only (no list N+1).create()strips a client-suppliedextra.fork(server-minted only); deleting a parent keeps an auto-provisioned workspace other conversations still share (list_associatedguard, fails closed); team conversations and in-flight turns are refused (409).FORK_TURN_IN_FLIGHT/FORK_PARENT_UNBOUND, 422FORK_UNSUPPORTED/FORK_POINT_UNSUPPORTED.Tests
BackendTurnBoundemission (hermetic FakeAgentIo)ForkFromargv, wake-slot rotation unit + wake-after-rotation end-to-end (also covers the pre-existing rotation bug)spec_mode_modelsid×fork quadrant matrix; ACP fork-capability gate trioFrontend counterpart: iOfficeAI/AionUi#3841
Verified capability landscape (dev DB, post-036)
Queried
agent_metadata.agent_capabilities.session_capabilities.forkon a live dev database after this branch's backend ran (migration 036 applied,json_patchcorrectly merged into pre-existing handshake blobs instead of clobbering them):Fork supported (14):
fork {at_turn: true}(only backend with arbitrary-turn forks; migration-constructed)fork {}HEAD-only (migration-merged; the row already carried historical ACP-bridge handshake data incl._meta.claudeCode.promptQueueing, all preserved)sessionCapabilities.forkNo fork (entry hidden): Antigravity (by product decision), and ~20 handshaked vendors without the capability (Gemini CLI, Qwen, Copilot, Kimi, Goose, Droid, Auggie, CodeBuddy, Kiro, Amp, Devin, Pi, …) plus never-handshaked rows (Cursor, Snow, Aion CLI, Nanobot, OpenClaw Gateway).
Live fork smoke (codex): two HEAD forks of a real codex conversation created lineage rows + copied history correctly; new turns after the fork got
messages.backend_turn_idstamped (15 rows, one turn id), while copied/legacy rows stayed NULL and are refused withFORK_POINT_UNSUPPORTED— matching the no-silent-degradation contract (the frontend now hides those entries preemptively via the exposedbackend_turn_id).