feat(server): expose session forking over HTTP#3199
Conversation
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.
…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.
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.
docker-agent
left a comment
There was a problem hiding this comment.
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 fullGetSession → ForkSession → AddSessionsequence, 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:
copySessionMetadatanow includesMaxConsecutiveToolCallsandMaxOldToolCallTokens, matchingClone()behavior. User-configured tool-call cutoff and token truncation budget carry over to the fork. ✅ - Index translation correctness:
flatMessageIndexToItemIndexcorrectly mirrorsGetAllMessages'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 witherrors.Isso future rewording of error strings cannot silently flip HTTP status codes. ✅ - Fork title regex:
forkSuffixReis 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. ✅
docker-agent
left a comment
There was a problem hiding this comment.
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.
| switch { | ||
| case errors.Is(err, ErrForkInvalidMessage), | ||
| errors.Is(err, ErrForkOutOfRange), | ||
| errors.Is(err, ErrForkInSubSession): |
There was a problem hiding this comment.
[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.
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>
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/sessionsa 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.BranchSessionalready 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
message_indexis a 0-based index into the flat, user-visible message list (the same shapeGET /api/sessions/:idreturns). 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.Design notes
tool_usewithout its matchingtool_resultin the forked session and break the next provider call. The handler validates the resolved item's role and returns 400 otherwise.Session.GetAllMessages()returns a flat list (skipping system messages, flattening sub-sessions) whileBranchSessionexpects an index intoSession.Messages(theItemslice). The new handler bridges the two viaflatMessageIndexToItemIndex, so the API stays stable ifItemever grows new variants.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 privatebranchSessionWithTitlehelper.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 inmetadatafor now.Notes for reviewers
session.BranchSession, which already has regression tests forMultiContentpointer aliasing, sub-session cloning, and message ID resets. The new code re-uses that.BranchSessiondeliberately does not copy (Cost,InputTokens/OutputTokensare recomputed;MaxConsecutiveToolCalls/MaxOldToolCallTokensare not preserved) are unchanged here — happy to revisit in a follow-up if downstream needs them.