feat(studio): branch a conversation from any reply into its own session

Studio had no click entry for branching: /branch copied only the current tip and
swapped the pane onto it. A finished reply now carries a 'Branch into a new
conversation' button that copies the conversation up to the end of that reply's
turn into a new session, titled '<title> - branch' so it never reads as a second
copy of its source, and opens it while the original stays as it was.

Controller.BranchAt cuts at a live turn boundary and shares the file-and-meta
writing with Branch. POST /branch/at answers with the new path and refuses with
typed codes (running, unavailable, failed).
This commit is contained in:
yhh committed 2026-10-11 23:36:04 -07:00
1 parent 0968731518
commit 4f4b43978b
24 files changed
+426 -26

No files matched your search

+1
View File
@@ -496,6 +496,7 @@ export const ACTIONS: UIAction[] = [
{ id: "palette.pick", kind: "view", target: "entity", proof: "interaction" },
{ id: "palette.search", kind: "view", target: "none", proof: "interaction" },
{ id: "reply.quote", kind: "view", target: "entity", proof: "interaction" },
{ id: "reply.branch", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "reply.retry", kind: "view", target: "entity", proof: "interaction" },
{ id: "reply.retry-now", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "reply.configure", kind: "view", target: "none", proof: "interaction" },
+5
View File
@@ -1291,6 +1291,8 @@ export const EN: Record<string, string> = {
"任务清单": "Task list",
"引用到输入框": "Quote into the composer",
"重新生成": "Regenerate",
"分支为新会话": "Branch into a new conversation",
"分支": "branch",
"当前回复会留在运行历史里": "The current reply stays in the run history",
"按当前配置重试": "Retry as configured",
"先调整模型与强度": "Adjust the model and effort first",
@@ -1368,6 +1370,9 @@ export const EN: Record<string, string> = {
"The staged diff is long; the draft reads only its first part",
"这个工作区不是 git 仓库,无法提交":
"This workspace is not a git repository, so there is nothing to commit",
"回合运行中,等它结束后再分支": "A turn is running; branch after it ends",
"这个位置无法分支(重启后恢复的会话,或已被压缩的历史)": "This point cannot be branched (a session restored after a restart, or history that was compacted)",
"分支失败,请重试": "Branching failed; try again",
"暂存区是空的,先用 git add 暂存要提交的文件":
"Nothing is staged; stage the files to commit with git add first",
"暂存区在你确认之后又变了,请重新起草":
+4
View File
@@ -244,6 +244,10 @@ const SAID: Record<string, string> = {
"prompt_refine.failed": "优化失败,请重试",
"prompt_refine.bad_request": "优化请求格式不正确",
"conversation_branch.turn_running": "回合运行中,等它结束后再分支",
"conversation_branch.unavailable": "这个位置无法分支(重启后恢复的会话,或已被压缩的历史)",
"conversation_branch.failed": "分支失败,请重试",
"commit.no_repository": "这个工作区不是 git 仓库,无法提交",
"commit.nothing_staged": "暂存区是空的,先用 git add 暂存要提交的文件",
"commit.staged_changed": "暂存区在你确认之后又变了,请重新起草",
+4
View File
@@ -404,6 +404,10 @@ export class MockPort extends MockWorkspace implements AgentPort {
return this.prompts.map((prompt, i) => ({ turn: i, prompt, files: this.prompts.length > 1 ? 3 : 0, msgIndex: mockMsgIndex(i + 1) }));
}
async branchAt(turn: number): Promise<string> {
return `/mock/branch-${turn}.jsonl`;
}
// The second prompt onwards is scripted to have run bash, so mock mode can
// show the consent stage the real kernel demands on partial coverage.
async prepareRewind(turn: number, scope: RewindScope): Promise<RewindPlan> {
+3
View File
@@ -456,6 +456,9 @@ export interface AgentPort {
// the user agreed to.
prepareRewind(turn: number, scope: RewindScope): Promise<RewindPlan>;
commitRewind(planId: string): Promise<RewindResult>;
// Copies the conversation up to the end of a turn into a new session and
// answers with its path; this session stays where it is.
branchAt(turn: number, name: string): Promise<string>;
// Reverses a committed rewind. Only reachable while the caller still holds the
// transaction id the commit returned.
undoRewind(transactionId: string): Promise<void>;
+4
View File
@@ -497,6 +497,10 @@ export class SsePort extends SseWorkspace implements AgentPort {
return this.post0<RewindResult>("/rewind/commit", { planId });
}
async branchAt(turn: number, name: string) {
return (await this.post0<{ path: string }>("/branch/at", { turn, name })).path;
}
undoRewind(transactionId: string) {
return this.post("/rewind/undo", { transactionId });
}
+1
View File
@@ -682,6 +682,7 @@ export function App({ hub }: { hub: HubPort }) {
setClaimed(true);
}}
onSettings={(section) => section ? showPrefs(section) : showPrefs()}
onOpenSession={(path) => void openPane({ root: rt.root, sessionPath: path }).catch(fail)}
theme={scheme}
dockW={shownDockW}
dockMax={dockLimit}
+2 -2
View File
@@ -71,7 +71,7 @@ const totalsOf = (st: SessionStatus) => ({
incompleteReason: st.sessionCostQuote?.incompleteReason,
});
function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, onSettings, needsProject, onOpenProject, onKeepHere, theme, dockW, dockMax, onDockW, manualBrowser = false, browserAsk = 0, onManualBrowser, alert }: PaneProps) {
function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, onSettings, onOpenSession, needsProject, onOpenProject, onKeepHere, theme, dockW, dockMax, onDockW, manualBrowser = false, browserAsk = 0, onManualBrowser, alert }: PaneProps) {
const [s, dispatch] = useReducer(reduce, initialState);
const [traj, trajDispatch] = useReducer(reduceTraj, initialTraj);
const [sessionRead, setSessionRead] = useState<SessionRead>({ kind: "pending" });
@@ -352,7 +352,7 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
const find = useFind(s.items, findPulse, active, useCallback(() => showView("flow"), [showView]), receiptPref);
const onRunDetail = useCallback(() => showView("analysis"), [showView]);
const { quote, reply, onResend } = useReplyActions({ port, items: s.items, checkpoints, running, model: status?.label, submit, reloadSession, onSettings, onRunDetail, onError: fail });
const { quote, reply, onResend } = useReplyActions({ port, items: s.items, checkpoints, running, model: status?.label, submit, reloadSession, onSettings, onRunDetail, onError: fail, branchName: `${title} · ${t("分支")}`, onBranched: onOpenSession });
// Where the bottom is moves as blocks mount under it, so this only asks the
// transcript to follow again and lets it scroll itself into place.
@@ -74,7 +74,7 @@ describe("the reply menus", () => {
});
const card = (onRegenerate = () => {}) =>
render(<SayCard item={item} reply={{ onQuote: () => {}, canRegenerate: () => true, hasLaterTurns: () => false, onRegenerate, onConfigureModel: () => {}, onRunDetail: () => {} }} />);
render(<SayCard item={item} reply={{ onQuote: () => {}, canRegenerate: () => true, hasLaterTurns: () => false, onRegenerate, canBranch: () => false, onBranch: () => {}, onConfigureModel: () => {}, onRunDetail: () => {} }} />);
const list = () => document.querySelector<HTMLElement>(".acts-pop");
for (const action of ["reply.retry", "reply.more"]) {
@@ -62,6 +62,8 @@ export interface ReplyActions {
canRegenerate: (id: string) => boolean;
hasLaterTurns: (id: string) => boolean;
onRegenerate: (id: string) => void;
canBranch: (id: string) => boolean;
onBranch: (id: string) => void;
model?: string;
onConfigureModel?: () => void;
onRunDetail?: () => void;
@@ -143,6 +145,11 @@ export function SayCard({ item, afterAnswer, reply, quiet }: { item: Extract<Ite
<StudioIcon name="quote" />
</button>
)}
{reply?.canBranch(item.id) && (
<button type="button" data-action="reply.branch" title={t("分支为新会话")} aria-label={t("分支为新会话")} onClick={() => reply.onBranch(item.id)}>
<StudioIcon name="branch" />
</button>
)}
{reply?.canRegenerate(item.id) && (
<span className="acts-menu">
<button ref={retry} type="button" data-action="reply.retry" title={t("重新生成")} aria-label={t("重新生成")} aria-expanded={menu === "retry"} onClick={() => setMenu((m) => (m === "retry" ? "" : "retry"))}>
@@ -43,6 +43,8 @@ export interface PaneProps {
pulse: number;
findPulse: number;
onSettings: (section?: string) => void;
// Open a session this pane made, beside it: a branch is its own conversation.
onOpenSession?: (path: string) => void;
// 这个窗口还没有人选过的项目文件夹。空转录是唯一说得出这句话的地方 —— 那里
// 本来就在替一段还没开始的对话说明它该怎么开始。
needsProject: boolean;
@@ -0,0 +1,60 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import type { AgentPort, Checkpoint } from "../port/port";
import type { Item } from "../state/session";
import { SayCard } from "./cards/SayCard";
import { useReplyActions } from "./reply";
afterEach(cleanup);
const checkpoint = (turn: number, msgIndex: number): Checkpoint => ({ turn, msgIndex, prompt: "问", files: 0 });
const items: Item[] = [
{ t: "user", id: "u1", text: "第一问", msgIndex: 1 },
{ t: "say", id: "a1", text: "答一", done: true },
{ t: "user", id: "u2", text: "第二问", msgIndex: 5 },
{ t: "say", id: "a2", text: "答二", done: true },
];
function draw({ running = false, branchAt = vi.fn(async (_turn: number, _name: string) => "/s/branch.jsonl") } = {}) {
const onBranched = vi.fn();
const onError = vi.fn();
const port = { branchAt } as unknown as AgentPort;
function Replies() {
const { reply } = useReplyActions({
port, items, checkpoints: [checkpoint(0, 1), checkpoint(1, 5)], running, branchName: "我的会话 · 分支",
submit: vi.fn(async () => true), reloadSession: vi.fn(async () => {}),
onSettings: vi.fn(), onRunDetail: vi.fn(), onError, onBranched,
});
return <>{items.filter((i): i is Extract<Item, { t: "say" }> => i.t === "say").map((i) => <SayCard key={i.id} item={i} reply={reply} />)}</>;
}
render(<Replies />);
return { branchAt, onBranched, onError };
}
it("branches a reply's conversation at its own turn and opens the result", async () => {
const { branchAt, onBranched } = draw();
const buttons = screen.getAllByRole("button", { name: "分支为新会话" });
expect(buttons).toHaveLength(2);
await userEvent.click(buttons[0]);
await waitFor(() => expect(onBranched).toHaveBeenCalledWith("/s/branch.jsonl"));
expect(branchAt).toHaveBeenCalledWith(0, "我的会话 · 分支");
await userEvent.click(buttons[1]);
await waitFor(() => expect(branchAt).toHaveBeenLastCalledWith(1, "我的会话 · 分支"));
});
it("is not offered while a turn runs", () => {
draw({ running: true });
expect(screen.queryByRole("button", { name: "分支为新会话" })).toBeNull();
});
it("reports a refusal instead of opening anything", async () => {
const refusal = new Error("no boundary");
const { onBranched, onError } = draw({ branchAt: vi.fn(async () => { throw refusal; }) });
await userEvent.click(screen.getAllByRole("button", { name: "分支为新会话" })[0]);
await waitFor(() => expect(onError).toHaveBeenCalledWith(refusal));
expect(onBranched).not.toHaveBeenCalled();
});
+10 -2
View File
@@ -16,6 +16,9 @@ interface Inputs {
onSettings: (section?: string) => void;
onRunDetail: () => void;
onError: (e: unknown) => void;
// The title the branch is given and what to do with the session it makes.
branchName?: string;
onBranched?: (path: string) => void;
}
interface ReplyTurn {
@@ -36,7 +39,7 @@ function sameTurns(a: Map<string, ReplyTurn>, b: Map<string, ReplyTurn>) {
/** What a finished reply can be acted on with, and the draft signal a quote
* travels to the composer on. The transcript owns neither: the pane holds the
* session these read from, and the composer is where a quote has to land. */
export function useReplyActions({ port, items, checkpoints, running, model, submit, reloadSession, onSettings, onRunDetail, onError }: Inputs) {
export function useReplyActions({ port, items, checkpoints, running, model, submit, reloadSession, onSettings, onRunDetail, onError, branchName, onBranched }: Inputs) {
const [quote, setQuote] = useState<Quote>({ text: "", n: 0 });
// Re-running a turn is a conversation rewind and then the same words again:
@@ -107,11 +110,16 @@ export function useReplyActions({ port, items, checkpoints, running, model, subm
const ask = replyTurns.get(id);
if (ask) void regenerate(ask.turn, ask.text).catch(onError);
},
canBranch: (id: string) => !running && !!onBranched && replyTurns.has(id),
onBranch: (id: string) => {
const ask = replyTurns.get(id);
if (!running && ask && onBranched) void port.branchAt(ask.turn, branchName ?? "").then(onBranched, onError);
},
model,
onConfigureModel: () => onSettings("model"),
onRunDetail,
}),
[replyTurns, running, regenerate, model, onSettings, onRunDetail, turnOf, onError],
[replyTurns, running, regenerate, model, onSettings, onRunDetail, turnOf, onError, port, branchName, onBranched],
);
// Rewriting a message is the same act with different words: the turn goes
+1 -1
View File
@@ -19,7 +19,7 @@ const ROUTES = [
"/model", "/default-model", "/effort", "/new", "/sessions", "/delete-session", "/provider-setup",
"/inbox", "/trajectory", "/mcp", "/skills", "/complete", "/prompt", "/workspace", "/capability-scope",
"/providers", "/decision-models", "/roles", "/account", "/backups", "/hooks", "/memory", "/network", "/shell", "/todos",
"/changes", "/commit", "/attachments", "/drop", "/checkpoints", "/branches", "/compact", "/compaction", "/rewind",
"/changes", "/commit", "/attachments", "/drop", "/checkpoints", "/branches", "/compact", "/compaction", "/rewind", "/branch",
"/extensions", "/themes", "/plugins", "/market", "/surfaces", "/feedback",
"/fork", "/summarize", "/forget", "/bypass", "/auto-approve-tools",
"/permissions", "/sandbox", "/progress-watch", "/ask-timeout", "/changed-files-settings", "/fold-work-settings", "/display-currency", "/auto-archive", "/context", "/storage", "/tray", "/browser", "/browser-host", "/asks", "/update", "/receipt-settings",
+2
View File
@@ -1357,6 +1357,8 @@ Markdown files under `.reasonix/commands/` (project) or `~/.reasonix/commands/`
(`git/commit.md` → `/git:commit`). The body is a prompt template; invoking the
command sends it as a turn.
In Studio, a finished reply has a "Branch into a new conversation" button. It copies the conversation up to that reply into a new session titled "<title> · branch" and leaves the original as it was. It needs a live turn boundary, so a session restored after a restart does not offer it.
### Subagent profiles
Subagent profiles are manual Skills with `runAs: subagent` and
+2
View File
@@ -1065,6 +1065,8 @@ RPC 调用。两者都可按服务器覆盖。
`review.md` 即 `/review`,子目录构成命名空间(`git/commit.md` → `/git:commit`)。文件正文
是 prompt 模板,调用即作为一轮对话发出。
在 Studio 里,每条已完成的回复下有「分支为新会话」按钮:把对话复制到这条回复为止,生成标题为「原标题 · 分支」的新会话,原会话保持不变。它依赖运行中产生的回合边界,所以重启后恢复的会话不提供。
### 子智能体 Profile
子智能体 profile 是带有 `runAs: subagent` 和 `invocation: manual` 的手动 Skill。
+42
View File
@@ -0,0 +1,42 @@
package serve
import (
"encoding/json"
"errors"
"net/http"
"reasonix/internal/session/control"
)
// branchAt copies the conversation up to the end of one turn into a new session
// and answers with its path; this controller stays on its own session, so the
// frontend opens the branch beside it.
func (s *Server) branchAt(w http.ResponseWriter, r *http.Request) {
var body struct {
Turn *int `json:"turn"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Turn == nil || *body.Turn < 0 {
missingField(w, "turn")
return
}
path, err := s.ctl().BranchAt(*body.Turn, body.Name)
if err != nil {
refuseBranchAt(w, err)
return
}
writeJSON(w, struct {
Path string `json:"path"`
}{Path: path})
}
func refuseBranchAt(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, control.ErrTurnRunning):
busy(w, "conversation_branch.turn_running", err.Error(), nil)
case errors.Is(err, control.ErrBranchUnavailable):
refuse(w, http.StatusConflict, "conversation_branch.unavailable", err.Error(), nil)
default:
refuse(w, http.StatusInternalServerError, "conversation_branch.failed", err.Error(), nil)
}
}
+63
View File
@@ -0,0 +1,63 @@
package serve
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/session/control"
)
func branchAtCode(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
var reason Reason
if err := json.NewDecoder(rec.Body).Decode(&reason); err != nil {
t.Fatalf("decode refusal: %v", err)
}
return reason.Code
}
// What a frontend has to tell apart: not now, not here, and broken.
func TestBranchAtRefusalsCarryAnIdentity(t *testing.T) {
for _, tc := range []struct {
err error
status int
code string
}{
{fmt.Errorf("busy: %w", control.ErrTurnRunning), http.StatusConflict, "conversation_branch.turn_running"},
{fmt.Errorf("no boundary: %w", control.ErrBranchUnavailable), http.StatusConflict, "conversation_branch.unavailable"},
{errors.New("disk full"), http.StatusInternalServerError, "conversation_branch.failed"},
} {
rec := httptest.NewRecorder()
refuseBranchAt(rec, tc.err)
if rec.Code != tc.status || branchAtCode(t, rec) != tc.code {
t.Errorf("%v -> %d, want %d %s", tc.err, rec.Code, tc.status, tc.code)
}
}
}
func TestBranchAtNeedsATurn(t *testing.T) {
ctrl := control.New(control.Options{WorkspaceRoot: testenv.TempDir(t), SessionPath: filepath.Join(testenv.TempDir(t), "s.jsonl")})
defer ctrl.Close()
rec := httptest.NewRecorder()
(&Server{ctrl: ctrl}).branchAt(rec, httptest.NewRequest(http.MethodPost, "/branch/at", strings.NewReader(`{"name":"x"}`)))
if rec.Code != http.StatusBadRequest {
t.Fatalf("a request with no turn = %d, want 400", rec.Code)
}
}
func TestBranchAtASessionThatCannotBranchIsRefusedNotBroken(t *testing.T) {
ctrl := control.New(control.Options{WorkspaceRoot: testenv.TempDir(t), SessionPath: filepath.Join(testenv.TempDir(t), "s.jsonl")})
defer ctrl.Close()
rec := httptest.NewRecorder()
(&Server{ctrl: ctrl}).branchAt(rec, httptest.NewRequest(http.MethodPost, "/branch/at", strings.NewReader(`{"turn":1}`)))
if rec.Code != http.StatusConflict || branchAtCode(t, rec) != "conversation_branch.unavailable" {
t.Fatalf("a controller with no conversation = %d, want a branch.unavailable refusal", rec.Code)
}
}
+1
View File
@@ -55,6 +55,7 @@ func (s *Server) routes() http.Handler {
s.registerChangedFilesRoutes(mux)
mux.HandleFunc("POST /new", s.newSession)
mux.HandleFunc("POST /rewind", s.rewind)
mux.HandleFunc("POST /branch/at", s.branchAt)
mux.HandleFunc("POST /rewind/prepare", s.rewindPrepare)
mux.HandleFunc("POST /rewind/commit", s.rewindCommit)
mux.HandleFunc("POST /rewind/undo", s.rewindUndo)
+77
View File
@@ -0,0 +1,77 @@
package control
import (
"errors"
"fmt"
"strings"
"reasonix/internal/contract/provider"
"reasonix/internal/state/sessionstore"
)
// ErrBranchUnavailable is a branch the conversation cannot be cut for: a turn it
// has no live boundary for (a resumed session), or one compaction has moved past.
var ErrBranchUnavailable = errors.New("control: this point cannot be branched")
// seedBranch writes messages into a new session file beside the current one and
// records the current session as its parent. A non-empty name becomes the title
// the sidebar shows, so the branch never reads as a second copy of its source.
func (c *Controller) seedBranch(src []provider.Message, name string) (*sessionstore.Session, string, error) {
name = strings.TrimSpace(name)
branched := append([]provider.Message(nil), src...)
sess := sessionstore.NewSession("")
sess.Messages = branched
newPath := sessionstore.NewSessionPath(c.sessionDir, c.label)
if err := sess.SaveIfAbsent(newPath); err != nil {
return nil, "", err
}
preview, turns := sessionstore.SessionPreviewFromMessages(branched)
if err := sessionstore.SaveBranchMeta(newPath, sessionstore.BranchMeta{
Name: name,
ParentID: sessionstore.BranchID(c.SessionPath()),
Preview: preview,
Turns: turns,
SchemaVersion: sessionstore.BranchMetaCountsVersion,
}); err != nil {
return nil, "", err
}
if name != "" {
if err := sessionstore.RenameSession(newPath, name); err != nil {
return nil, "", err
}
}
return sess, newPath, nil
}
// BranchAt copies the conversation as it stood at the end of turn into a new
// session file and leaves this controller on its own session, so a frontend can
// open the branch beside the source. The cut is a live turn boundary, which is
// why a resumed session or a point compaction passed is refused.
func (c *Controller) BranchAt(turn int, name string) (string, error) {
if c.executor == nil || c.sessionDir == "" {
return "", fmt.Errorf("%w: sessions are not persisted here", ErrBranchUnavailable)
}
if err := c.beginRotation(); err != nil {
if errors.Is(err, errTurnRunningRotation) {
return "", fmt.Errorf("cannot branch while a turn is running: %w", ErrTurnRunning)
}
return "", err
}
defer c.endRotation()
if err := c.Snapshot(); err != nil {
return "", err
}
src := c.executor.Session().Snapshot()
end, ok := c.checkpoints.endOfTurn(turn, len(src))
if !ok {
return "", fmt.Errorf("%w: turn %d has no live boundary", ErrBranchUnavailable, turn)
}
if end > len(src) {
return "", fmt.Errorf("%w: the conversation was compacted past turn %d", ErrBranchUnavailable, turn)
}
_, path, err := c.seedBranch(src[:end], name)
if err != nil {
return "", err
}
return path, nil
}
+113
View File
@@ -0,0 +1,113 @@
package control
import (
"errors"
"path/filepath"
"slices"
"testing"
"reasonix/internal/state/sessionstore"
)
func orderedTurns(t *testing.T, c *Controller) []int {
t.Helper()
c.checkpoints.mu.Lock()
defer c.checkpoints.mu.Unlock()
turns := make([]int, 0, len(c.checkpoints.bound))
for turn := range c.checkpoints.bound {
turns = append(turns, turn)
}
slices.Sort(turns)
return turns
}
func savedMessages(t *testing.T, path string) int {
t.Helper()
loaded, err := sessionstore.LoadSession(path)
if err != nil {
t.Fatalf("load %s: %v", path, err)
}
return len(loaded.Messages)
}
func TestBranchAtKeepsTheChosenTurnAndNothingAfterIt(t *testing.T) {
c, ag, _ := runTwoTurns(t)
turns := orderedTurns(t, c)
if len(turns) != 2 {
t.Fatalf("turns = %v, want two", turns)
}
parent := c.SessionPath()
secondStart, _ := c.checkpoints.boundary(turns[1])
total := len(ag.Session().Messages)
path, err := c.BranchAt(turns[0], "first answer, another way")
if err != nil {
t.Fatalf("BranchAt: %v", err)
}
if path == "" || path == parent {
t.Fatalf("branch path = %q, want a new session beside %q", path, parent)
}
if got := savedMessages(t, path); got != secondStart {
t.Fatalf("branch holds %d messages, want %d: the first turn whole and none of the second", got, secondStart)
}
if c.SessionPath() != parent || len(ag.Session().Messages) != total {
t.Fatalf("the source conversation moved: path %q messages %d, want %q and %d", c.SessionPath(), len(ag.Session().Messages), parent, total)
}
meta, ok, err := sessionstore.LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("branch meta ok=%v err=%v", ok, err)
}
if meta.ParentID != sessionstore.BranchID(parent) || meta.CustomTitle != "first answer, another way" {
t.Fatalf("branch meta = %+v, want the source as parent and the given title", meta)
}
}
func TestBranchAtTheLastTurnTakesTheWholeConversation(t *testing.T) {
c, ag, _ := runTwoTurns(t)
turns := orderedTurns(t, c)
path, err := c.BranchAt(turns[len(turns)-1], "")
if err != nil {
t.Fatalf("BranchAt: %v", err)
}
if got := savedMessages(t, path); got != len(ag.Session().Messages) {
t.Fatalf("branch holds %d messages, want the whole conversation (%d)", got, len(ag.Session().Messages))
}
}
func TestBranchAtAnUnknownTurnIsRefusedWithAnIdentity(t *testing.T) {
c, _, _ := runTwoTurns(t)
if _, err := c.BranchAt(99, "x"); !errors.Is(err, ErrBranchUnavailable) {
t.Fatalf("BranchAt(99) = %v, want ErrBranchUnavailable", err)
}
}
func TestABranchIsListedBesideItsSourceUnderItsOwnTitle(t *testing.T) {
c, _, _ := runTwoTurns(t)
if err := c.Snapshot(); err != nil {
t.Fatal(err)
}
turns := orderedTurns(t, c)
path, err := c.BranchAt(turns[0], "the other way")
if err != nil {
t.Fatalf("BranchAt: %v", err)
}
listed, err := sessionstore.ListSessions(filepath.Dir(path))
if err != nil {
t.Fatal(err)
}
var source, branch *sessionstore.SessionInfo
for i := range listed {
switch listed[i].Path {
case path:
branch = &listed[i]
case c.SessionPath():
source = &listed[i]
}
}
if source == nil || branch == nil {
t.Fatalf("listing holds %d sessions; source found=%v branch found=%v", len(listed), source != nil, branch != nil)
}
if branch.Recovered || branch.CustomTitle != "the other way" || source.CustomTitle == branch.CustomTitle {
t.Fatalf("branch = %+v, want an ordinary session with its own title (source %+v)", branch, source)
}
}
+18
View File
@@ -204,6 +204,24 @@ func (m *checkpointManager) boundary(turn int) (int, bool) {
return b, ok
}
// endOfTurn returns where turn's messages stop: the start of the next recorded
// turn, or tip when turn is the latest. ok is false for a turn with no boundary.
func (m *checkpointManager) endOfTurn(turn, tip int) (int, bool) {
m.mu.Lock()
defer m.mu.Unlock()
start, ok := m.bound[turn]
if !ok {
return 0, false
}
end := tip
for _, b := range m.bound {
if b > start && b < end {
end = b
}
}
return end, true
}
// list returns the checkpoint metadata (nil when disabled).
func (m *checkpointManager) list() []checkpoint.Meta {
m.mu.Lock()
+1
View File
@@ -159,6 +159,7 @@ type SessionHistory interface {
PrepareFileRevert(path string) (checkpoint.RewindPlan, error)
CommitFileRevert(planID string, resolution checkpoint.ConflictResolution) (checkpoint.RewindResult, error)
Branch(name string) (string, error)
BranchAt(turn int, name string) (string, error)
Branches() ([]sessionstore.BranchInfo, error)
BranchTreeText() string
SwitchBranch(ref string) (sessionstore.BranchInfo, error)
+2 -20
View File
@@ -9,7 +9,6 @@ import (
"strings"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/ext/extension"
"reasonix/internal/ext/extension/dispatch"
"reasonix/internal/platform/browser"
@@ -254,25 +253,8 @@ func (c *Controller) Branch(name string) (string, error) {
if err := c.Snapshot(); err != nil {
return "", c.rewindFail(err)
}
parentPath := c.SessionPath()
parentID := sessionstore.BranchID(parentPath)
src := c.executor.Session().Snapshot()
branched := append([]provider.Message(nil), src...)
sess := sessionstore.NewSession("")
sess.Messages = branched
newPath := sessionstore.NewSessionPath(c.sessionDir, c.label)
if err := sess.SaveIfAbsent(newPath); err != nil {
return "", c.rewindFail(err)
}
branchPreview, branchTurns := sessionstore.SessionPreviewFromMessages(branched)
if err := sessionstore.SaveBranchMeta(newPath, sessionstore.BranchMeta{
Name: strings.TrimSpace(name),
ParentID: parentID,
Preview: branchPreview,
Turns: branchTurns,
SchemaVersion: sessionstore.BranchMetaCountsVersion,
}); err != nil {
sess, newPath, err := c.seedBranch(c.executor.Session().Snapshot(), name)
if err != nil {
return "", c.rewindFail(err)
}
// See snapshotMu: the swap must not interleave with an in-flight save.