Skip to content

feat(conversation): fork a conversation into a new one at a chosen message - #772

Merged
kaizhou-lab merged 11 commits into
mainfrom
feat/conversation-fork
Aug 4, 2026
Merged

feat(conversation): fork a conversation into a new one at a chosen message#772
kaizhou-lab merged 11 commits into
mainfrom
feat/conversation-fork

Conversation

@kaizhou-lab

@kaizhou-lab kaizhou-lab commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

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:

backend mechanism fork point
codex app-server thread/fork + lastTurnId any turn
claude (direct CLI) --resume <parent> --fork-session HEAD
ACP vendors session/fork (capability-gated via sessionCapabilities.fork) HEAD
antigravity refused (no headless fork surface)

How

  • Lazy "fork as an open mode" — the fork API is pure bookkeeping (validate → create row with extra.fork lineage + 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 existing runtime/ensure endpoint gives callers eager failure surfacing.
  • codex: run_handshake three-mode (Fresh/Resume/Fork); Fork sends thread/fork with the full thread/start override surface, pre-seeds NO thread binding (the NEW thread's id arrives via thread/started), and a rejection poisons the bound-thread wait via a pending_fork slot — fail fast, never a silent fresh/HEAD fallback. turn/started now lowers BackendTurnBound (codex's own Turn.id) which the relay stamps onto messages.backend_turn_id — the lookup key for mid-history fork anchors; unresolvable anchors are refused with 422.
  • claude: LegacySessionSpec::ForkFrom--resume <parent> --fork-session (no --session-id; the new sid is learned from system/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.
  • ACP: open_session_fork mirrors the session/load flow (cwd/mcp_servers/response absorption/SessionAssigned), with a live sessionCapabilities.fork gate as the second line of defense behind the agent_metadata check. SessionNotFound and missing capability are explicit errors — an initial fork never silently degrades to session/new.
  • Capability declaration: migration 036 seeds session_capabilities.fork for the builtin claude ({}) / codex ({"at_turn":true}) rows via json_patch; ACP agents get it from the persisted handshake. Projected as ConversationResponse.fork_capability on the detail path only (no list N+1).
  • Guard rails: create() strips a client-supplied extra.fork (server-minted only); deleting a parent keeps an auto-provisioned workspace other conversations still share (list_associated guard, fails closed); team conversations and in-flight turns are refused (409).
  • Error contract with stable reason prefixes: 409 FORK_TURN_IN_FLIGHT / FORK_PARENT_UNBOUND, 422 FORK_UNSUPPORTED / FORK_POINT_UNSUPPORTED.

Tests

  • 4 repository tests: inclusive copy with monotonic-UUIDv7 id reminting, transaction atomicity, anchor resolution
  • codex fork handshake down/up/error legs, BackendTurnBound emission (hermetic FakeAgentIo)
  • claude ForkFrom argv, wake-slot rotation unit + wake-after-rotation end-to-end (also covers the pre-existing rotation bug)
  • spec_mode_model sid×fork quadrant matrix; ACP fork-capability gate trio
  • 8 service integration tests over the real in-memory DB (036 seeds included): HEAD/mid-history forks, anchor refusal, error matrix, create-strip, shared-workspace delete protection
  • Full targeted suites green: aionui-db 329 / aionui-session 526 / aionui-ai-agent 891 / aionui-conversation 376 (+ integration)

Frontend counterpart: iOfficeAI/AionUi#3841

Verified capability landscape (dev DB, post-036)

Queried agent_metadata.agent_capabilities.session_capabilities.fork on a live dev database after this branch's backend ran (migration 036 applied, json_patch correctly merged into pre-existing handshake blobs instead of clobbering them):

Fork supported (14):

  • Codex CLIfork {at_turn: true} (only backend with arbitrary-turn forks; migration-constructed)
  • Claude Codefork {} HEAD-only (migration-merged; the row already carried historical ACP-bridge handshake data incl. _meta.claudeCode.promptQueueing, all preserved)
  • OpenCode, Qoder, Vibe, Hermes, Junie, Kilo, Nova, MiMo Code, GLM Agent, Autohand, omp, siGit Code — HEAD-only via their own ACP handshake / registry seeds advertising sessionCapabilities.fork

No 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_id stamped (15 rows, one turn id), while copied/legacy rows stayed NULL and are refused with FORK_POINT_UNSUPPORTED — matching the no-silent-degradation contract (the frontend now hides those entries preemptively via the exposed backend_turn_id).

zk 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)
zk added 5 commits August 4, 2026 17:07
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
kaizhou-lab enabled auto-merge (squash) August 4, 2026 09:47
@kaizhou-lab
kaizhou-lab merged commit bb7c14e into main Aug 4, 2026
6 checks passed
@kaizhou-lab
kaizhou-lab deleted the feat/conversation-fork branch August 4, 2026 10:01
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: fork a conversation into a new one (codex thread/fork, claude --fork-session, ACP session/fork)

1 participant