Skip to content

feat(server): expose session forking over HTTP#3199

Merged
trungutt merged 3 commits into
docker:mainfrom
trungutt:feat/fork-session
Jun 23, 2026
Merged

feat(server): expose session forking over HTTP#3199
trungutt merged 3 commits into
docker:mainfrom
trungutt:feat/fork-session

Conversation

@trungutt

Copy link
Copy Markdown
Contributor

Why

Downstream HTTP consumers want to let users branch a conversation from a specific user turn — "rewind to this message and try a different question" — without losing the history that came before. Today the only way to do this over the API is to read out the full message list and POST /api/sessions a brand-new session with replayed messages, which is lossy (no metadata carry-over, no shared title lineage) and forces every client to re-implement the deep-copy semantics.

session.BranchSession already exists in-tree and is exercised by the TUI for the same purpose, but it isn't reachable over HTTP. This PR exposes it.

What the endpoint does

POST /api/sessions/:id/fork
  body: { "message_index": <int> }
  200:  api.SessionResponse for the new session
  400:  index out of range / not a user message / falls inside a sub-session
  404:  parent session not found

message_index is a 0-based index into the flat, user-visible message list (the same shape GET /api/sessions/:id returns). The fork excludes the message at that index, so the new session contains messages [0, message_index) of the parent. This lets a client prefill the excluded user message into its own input UI for the user to edit and resubmit.

parent.messages:   [u0]──[a0]──[u1]──[a1]──[u2]──[a2]
                                 ▲
                            fork here (message_index = 2)

forked.messages:   [u0]──[a0]
forked.title:      "<parent.title> (fork 1)"  →  "(fork 2)" on repeat forks

Design notes

  • User-message-only fork point. Branching mid-tool-call would dangle a tool_use without its matching tool_result in the forked session and break the next provider call. The handler validates the resolved item's role and returns 400 otherwise.
  • Index translation. Session.GetAllMessages() returns a flat list (skipping system messages, flattening sub-sessions) while BranchSession expects an index into Session.Messages (the Item slice). The new handler bridges the two via flatMessageIndexToItemIndex, so the API stays stable if Item ever grows new variants.
  • Title series kept separate. BranchSession's callers (the TUI's branch-from-edit + fork-session handlers) keep their (branched) / (branch N) titles. The new HTTP path uses (fork N) so consumers can tell the two flows apart in a sessions list. The two functions share their body via a private branchSessionWithTitle helper.
  • No persisted parent link. Migration 016 once carried branch_parent_* columns and migration 019 dropped them. Adding them back is out of scope for this PR — clients that need provenance can stash it in metadata for now.

Notes for reviewers

  • The deep-copy path is session.BranchSession, which already has regression tests for MultiContent pointer aliasing, sub-session cloning, and message ID resets. The new code re-uses that.
  • Fields BranchSession deliberately does not copy (Cost, InputTokens/OutputTokens are recomputed; MaxConsecutiveToolCalls/MaxOldToolCallTokens are not preserved) are unchanged here — happy to revisit in a follow-up if downstream needs them.
Add POST /api/sessions/:id/fork that deep-copies a session up to (but
excluding) a chosen user message, returning a new session with a
fork-numbered title. The fork point must be a user message; assistant
turns, tool responses, and sub-session items are rejected with 400.

session.ForkSession wraps the existing BranchSession with the fork
title generator so the TUI's branch-from-edit flow keeps its own
(branched) / (branch N) titling scheme.
@trungutt trungutt requested a review from docker-agent June 22, 2026 15:48
@aheritier aheritier added area/api For features/issues/fixes related to the usage of the cagent API kind/feat PR adds a new feature (maps to feat:). Use on PRs only. labels Jun 22, 2026
docker-agent

This comment was marked as resolved.

…tring

Three changes triggered by review on the original fork endpoint:

  * Drop the implicit "after the last message" full-clone shortcut on
    flatMessageIndexToItemIndex. It bypassed the user-role check, so
    a caller could silently get a whole-session clone anchored on an
    assistant turn. The endpoint now requires the index to point at a
    real visible message; ErrForkOutOfRange is returned otherwise.

  * Replace strings.Contains(err.Error(), ...) in the HTTP handler
    with errors.Is over ErrForkInvalidMessage / ErrForkOutOfRange /
    ErrForkInSubSession sentinels, so the 400-vs-500 classification
    is robust to future rephrasing of the error strings.

  * Anchor generateForkTitle's '(fork N)' detection at the end of the
    title with a regexp, so a mid-title occurrence in a user-chosen
    title like 'Q1 (fork 2) Analysis' is not mis-detected as a suffix
    and the trailing content is not silently dropped.
@trungutt trungutt requested a review from docker-agent June 22, 2026 16:35
docker-agent

This comment was marked as resolved.

Two review fixes on the new fork path:

  * Hold sm.mux around the GetSession -> ForkSession -> AddSession
    sequence, matching DeleteSession and RunSession. Without it, two
    concurrent fork requests on the same parent would each compute
    '"(fork 1)'" off the same Title and produce two distinct sessions
    with the same auto-numbered title.

  * Copy MaxConsecutiveToolCalls and MaxOldToolCallTokens in
    copySessionMetadata. These are safety / context rails the user
    or operator may have set deliberately (consecutive-tool-call
    cutoff, old-tool-call truncation budget). Dropping them silently
    made forks (and TUI branches) behave differently from their
    parent. Session.Clone() already preserves both; this aligns the
    fork/branch path with that.
@trungutt trungutt requested a review from docker-agent June 22, 2026 17:08

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assessment: 🟢 APPROVE

This PR exposes session.ForkSession over HTTP with solid implementation across the stack.

What was reviewed: 7 files — pkg/api/types.go, pkg/server/server.go, pkg/server/server_test.go, pkg/server/session_manager.go, pkg/server/session_manager_test.go, pkg/session/branch.go, pkg/session/branch_test.go

No bugs found. Key correctness properties verified:

  • Mutex serialization: sm.mux.Lock() is held across the full GetSession → ForkSession → AddSession sequence, preventing concurrent forks from racing to the same parent title counter (e.g., two requests both seeing no existing forks and both computing "(fork 1)"). ✅
  • Safety-rail limits preserved: copySessionMetadata now includes MaxConsecutiveToolCalls and MaxOldToolCallTokens, matching Clone() behavior. User-configured tool-call cutoff and token truncation budget carry over to the fork. ✅
  • Index translation correctness: flatMessageIndexToItemIndex correctly mirrors GetAllMessages's system-message skip rule, and the off-by-one boundary (equal to visible count → ErrForkOutOfRange) prevents bypassing the role check. ✅
  • Error classification: Sentinel errors (ErrForkInvalidMessage, ErrForkOutOfRange, ErrForkInSubSession) are matched with errors.Is so future rewording of error strings cannot silently flip HTTP status codes. ✅
  • Fork title regex: forkSuffixRe is anchored at the end of the string ($) so a mid-title "(fork N)" (e.g. "Q1 (fork 2) Analysis") is not treated as a suffix — the trailing content is preserved. ✅
  • Test coverage: Unit tests cover happy path, non-user message rejection, out-of-range (negative, equal-to-count, past-end), deep-copy isolation, system-message skip, sub-session boundary, and safety-rail preservation. End-to-end server test covers the full HTTP path including persistence verification. ✅
@trungutt trungutt marked this pull request as ready for review June 23, 2026 10:50
@trungutt trungutt requested a review from a team as a code owner June 23, 2026 10:50
@trungutt trungutt merged commit 4bda7b3 into docker:main Jun 23, 2026
15 checks passed

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assessment: 🔴 CRITICAL

One confirmed high-severity bug in the newly introduced forkSession handler: a missing 404 path causes the server to return 500 Internal Server Error when the parent session ID does not exist. All other details of the PR (index translation, title generation, deep-copy, concurrency) look correct.

Comment thread pkg/server/server.go
switch {
case errors.Is(err, ErrForkInvalidMessage),
errors.Is(err, ErrForkOutOfRange),
errors.Is(err, ErrForkInSubSession):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Missing 404 response when parent session does not exist — returns 500 instead

The forkSession error switch handles ErrForkInvalidMessage, ErrForkOutOfRange, and ErrForkInSubSession (all → 400), but has no case for session.ErrNotFound. When a client POSTs to /api/sessions/<unknown-id>/fork, sm.sessionStore.GetSession returns session.ErrNotFound, ForkSession propagates it unwrapped (return nil, err), and the handler falls through to the 500 branch — contradicting the PR spec ("404: parent session not found") and the pattern every other session handler follows.

Fix — add a session.ErrNotFound case to the switch:

switch {
case errors.Is(err, session.ErrNotFound):
    return echo.NewHTTPError(http.StatusNotFound, err.Error())
case errors.Is(err, ErrForkInvalidMessage),
    errors.Is(err, ErrForkOutOfRange),
    errors.Is(err, ErrForkInSubSession):
    return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}

A matching test case in TestServer_ForkSession would pin this behaviour.

aheritier added a commit that referenced this pull request Jun 24, 2026
Add POST /api/sessions/:id/fork to the Sessions endpoint table and
document the Session Forking section: request body, message_index
semantics (exclusive cut), response shape, title numbering, and
validation errors (non-user message, out-of-range, sub-session).

Signed-off-by: Arnaud Héritier <aheritier@users.noreply.github.com>
dgageot added a commit that referenced this pull request Jun 24, 2026
docs: update API server, CLI, DMR provider, and Go SDK for PRs #3199 #3201 #3206 #3182
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/api For features/issues/fixes related to the usage of the cagent API kind/feat PR adds a new feature (maps to feat:). Use on PRs only.

4 participants