imccyu 1 mēnesi atpakaļ
vecāks
revīzija
57fb5b488e
29 mainītis faili ar 330 papildinājumiem un 49 dzēšanām
  1. 45 0
      packages/client/connection/src/client/fixture.ts
  2. 2 0
      packages/client/connection/tests/fake-api.ts
  3. 8 0
      packages/client/runtime/src/client/contract/sessions.ts
  4. 30 0
      packages/client/runtime/src/client/sessions/manager.ts
  5. 32 0
      packages/client/runtime/src/client/sessions/service.ts
  6. 2 0
      packages/client/runtime/tests/fake-api.ts
  7. 12 1
      packages/client/test-runtime/src/sessions.ts
  8. 7 0
      packages/client/ui-conversation/src/client/apply.ts
  9. 2 2
      packages/client/ui-conversation/src/client/chat/ChatView.tsx
  10. 8 5
      packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx
  11. 4 1
      packages/client/ui-conversation/src/client/chat/MessageItem.tsx
  12. 2 0
      packages/client/ui-conversation/src/client/contract/slots.ts
  13. 3 1
      packages/client/ui-conversation/tests/chat-view.spec.tsx
  14. 28 6
      packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx
  15. 2 0
      packages/client/ui-workspace/src/client/contract/slots.ts
  16. 7 0
      packages/client/ui-workspace/src/client/index.ts
  17. 10 6
      packages/client/ui-workspace/src/client/rows/Rows.tsx
  18. 27 27
      packages/client/ui-workspace/tests/rows.spec.tsx
  19. 1 0
      packages/client/ui-workspace/tests/workspace-browser.spec.tsx
  20. 60 0
      packages/host/apiproxy/src/api-proxy.ts
  21. 1 0
      packages/host/apiproxy/src/api/rpc-map.ts
  22. 1 0
      packages/host/apiproxy/src/api/rpc.schema.ts
  23. 1 0
      packages/host/apiproxy/src/api/rpc.ts
  24. 11 0
      packages/host/apiproxy/src/api/sessions.schema.ts
  25. 14 0
      packages/host/apiproxy/src/api/sessions.ts
  26. 4 0
      packages/host/apiproxy/src/fetch/client.ts
  27. 2 0
      packages/host/apiproxy/src/fetch/handler.ts
  28. 1 0
      packages/host/apiproxy/tests/client-handler.spec.ts
  29. 3 0
      packages/host/apiproxy/tests/fetch-carrier.spec.ts

+ 45 - 0
packages/client/connection/src/client/fixture.ts

@@ -1042,6 +1042,50 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
         const appended = logOf(sessionId).at(-1) as SessionEvent
         return ok(request, { title: normalized, seq: appended.seq })
       },
+      fork: (request) => {
+        const { sessionId, atSeq } = request.payload
+        const source = summaryOf(sessionId)
+        if (source === undefined) {
+          return err(request, {
+            code: 'session-not-found',
+            message: `no session ${sessionId}`,
+            details: { sessionId },
+          })
+        }
+        const log = logs.get(sessionId) ?? []
+        // Host-parallel boundary: first turn/end at or after atSeq, falling
+        // back to the last completed turn; no completed turn = fork-unavailable.
+        const boundary = (atSeq === undefined ? undefined : log.find(e => e.type === 'turn/end' && e.seq >= atSeq))
+          ?? log.findLast(e => e.type === 'turn/end')
+        if (boundary === undefined) {
+          return err(request, {
+            code: 'fork-unavailable',
+            message: `session ${sessionId} has no completed turn`,
+            details: { sessionId },
+          })
+        }
+        let cut = boundary.seq + 1
+        while (cut < log.length && log[cut]?.type !== 'turn/start') cut++
+        const child: SessionSummary = {
+          sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: false,
+          parentSessionId: sessionId,
+          ...source.cwd === undefined ? {} : { cwd: source.cwd },
+        }
+        logs.set(child.sessionId, log.slice(0, cut))
+        sessions.push(child)
+        emitHost({
+          type: 'host/session-added', sessionId: child.sessionId, blank: false,
+          parentSessionId: sessionId,
+          ...source.cwd === undefined ? {} : { cwd: source.cwd },
+        })
+        const workspace = workspaces.find(w => w.sessionIds.includes(sessionId))
+        if (workspace !== undefined) {
+          workspace.sessionIds = [child.sessionId, ...workspace.sessionIds]
+          workspace.updatedAt = new Date().toISOString()
+          emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } })
+        }
+        return ok(request, { sessionId: child.sessionId })
+      },
       history: async (request) => {
         const log = logs.get(request.payload.sessionId) ?? []
         // Snapshot at request time, deliver after the transit delay (mirrors a real host under latency).
@@ -1591,6 +1635,7 @@ export class FixtureApiClient extends AbstractApiClient {
       case 'session.models': return this.api.sessions.models(request)
       case 'session.selectModel': return this.api.sessions.selectModel(request)
       case 'session.rename': return this.api.sessions.rename(request)
+      case 'session.fork': return this.api.sessions.fork(request)
       case 'session.prompt': return this.api.sessions.prompt(request)
       case 'session.updateQueue': return this.api.sessions.updateQueue(request)
       case 'session.cancel': return this.api.sessions.cancel(request)

+ 2 - 0
packages/client/connection/tests/fake-api.ts

@@ -46,6 +46,7 @@ export class FakeApiClient implements IApiClient {
   onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
   onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
   onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
+  onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
   onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
   => Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
     () => Promise.resolve(ok({
@@ -99,6 +100,7 @@ export class FakeApiClient implements IApiClient {
     selectModel: (payload: ModelTarget & { sessionId: SessionId }) =>
       this.record('session.selectModel', payload, this.onSelectModel(payload)),
     rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
+    fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
     prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
     updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
     cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),

+ 8 - 0
packages/client/runtime/src/client/contract/sessions.ts

@@ -29,6 +29,14 @@ export interface ISessions {
   open(id: SessionId): void
   /** Clear the current selection into the no-session view state. */
   clear(): void
+  /**
+   * Fork a session from a completed-turn prefix of the source; on resolution
+   * the child is in the list store and `open()` can target it.
+   * @param opts - source session id and the optional event seq anchoring the
+   *   cut (the boundary is the first turn/end at or after it).
+   * @returns the child session id.
+   */
+  fork(opts: { sessionId: SessionId; atSeq?: number }): Promise<SessionId>
   /**
    * Register a per-session standard-props provider (hooks become `use<Name>`
    * selector hooks on the render side; props spread verbatim).

+ 30 - 0
packages/client/runtime/src/client/sessions/manager.ts

@@ -289,6 +289,36 @@ export class SessionManager {
     }
   }
 
+  /**
+   * Contract session.fork; on success merge the child into summaries
+   * immediately (same synchronous-addressability guarantee as create). The
+   * child carries the source's history, so it is never blank; lineage rides
+   * parentSessionId so the list nests it under its source.
+   * @param opts - source session and the optional seq anchoring the cut.
+   * @returns the fork result (the child session id).
+   */
+  async fork(
+    opts: { sessionId: SessionId; atSeq?: number },
+  ): Promise<RpcResult<{ sessionId: SessionId }>> {
+    try {
+      const source = this.summaries.find(s => s.sessionId === opts.sessionId)
+      const { result } = await this.api.sessions.fork({
+        sessionId: opts.sessionId,
+        ...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq },
+      })
+      if (result.ok) {
+        this.recordMutation({ kind: 'upsert', summary: {
+          sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: false,
+          parentSessionId: opts.sessionId,
+          ...(source?.cwd !== undefined ? { cwd: source.cwd } : {}),
+        } })
+      }
+      return result
+    } catch (error) {
+      return transportError(error)
+    }
+  }
+
   /**
    * Insert-or-enrich a locally synthesized summary: a new id prepends; an
    * existing entry only gains fields it lacks (the session-added frame and the

+ 32 - 0
packages/client/runtime/src/client/sessions/service.ts

@@ -81,6 +81,22 @@ export class SessionCreateError extends Error {
   }
 }
 
+/** Structured session-fork failure. */
+export class SessionForkError extends Error {
+  override readonly name = 'SessionForkError'
+
+  /**
+   * @param rpcError - Host business or folded transport error.
+   * @param sourceSessionId - the session the fork was cut from.
+   */
+  constructor(
+    readonly rpcError: RpcError,
+    readonly sourceSessionId: SessionId,
+  ) {
+    super(`session fork failed: ${rpcError.code}: ${rpcError.message}`)
+  }
+}
+
 /** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
 export interface SessionBinding {
   readonly sessionId: SessionId
@@ -317,6 +333,22 @@ export class SessionsService implements ISessions {
     return result.value.sessionId
   }
 
+  /**
+   * Fork a session from a completed-turn prefix of the source (same
+   * synchronous-addressability guarantee as {@link SessionsService.create}:
+   * on resolution the child is in the list store and open() can target it).
+   * @param opts - source session id and the optional event seq anchoring the
+   *   cut (the boundary is the first turn/end at or after it).
+   * @returns the child session id.
+   * @throws {SessionForkError} with the source id.
+   */
+  async fork(opts: { sessionId: SessionId; atSeq?: number }): Promise<SessionId> {
+    const result = await this.manager.fork(opts)
+    if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
+    this.projectList()
+    return result.value.sessionId
+  }
+
   /**
    * Resolve an Agent-scoped context view (use-and-discard).
    * @param id - session id (the agent identity — 1:1 same axis).

+ 2 - 0
packages/client/runtime/tests/fake-api.ts

@@ -64,6 +64,7 @@ export class FakeApiClient implements IApiClient {
   onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
   readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
   onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
+  onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
   onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
   => Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
     () => Promise.resolve(ok({ events: [], hasMore: false }))
@@ -118,6 +119,7 @@ export class FakeApiClient implements IApiClient {
     selectModel: (payload: { provider: string; model: string }) =>
       this.record('session.selectModel', payload, this.onSelectModel(payload)),
     rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
+    fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
     prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
     updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
     cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),

+ 12 - 1
packages/client/test-runtime/src/sessions.ts

@@ -169,7 +169,7 @@ export class TestSessions implements ISessions {
   private readonly channel: SessionProvideChannel
 
   /** Calls observed on the service-level face (open/clear), newest last. */
-  readonly calls: { method: 'open' | 'clear'; args: unknown[] }[] = []
+  readonly calls: { method: 'open' | 'clear' | 'fork'; args: unknown[] }[] = []
 
   /**
    * @param stabilize - the owning runtime's act wrapper.
@@ -392,6 +392,17 @@ export class TestSessions implements ISessions {
     this.list.update((draft) => { draft.current = undefined })
   }
 
+  /**
+   * Recorded fork stub: no child materializes (benches asserting the full
+   * fork flow drive the production service; this face only proves the call).
+   * @param opts - source session id and optional cut anchor.
+   * @returns the source id (no child record is created).
+   */
+  fork(opts: { sessionId: SessionId; atSeq?: number }): Promise<SessionId> {
+    this.calls.push({ method: 'fork', args: [opts] })
+    return Promise.resolve(opts.sessionId)
+  }
+
   /**
    * The session face of a fixture (typed view for assertions; fixture
    * behavior methods are grafted onto it).

+ 7 - 0
packages/client/ui-conversation/src/client/apply.ts

@@ -262,6 +262,13 @@ export function apply(ctx: Context): void {
           })
         },
         loadOlder: () => { void scoped.loadOlder() },
+        forkAt: (seq) => {
+          sessions.fork({ sessionId, atSeq: seq })
+            .then((childId) => { sessions.open(childId) })
+            .catch(() => {
+              // Fork failure keeps the source view untouched (composer-stop posture).
+            })
+        },
       }
     },
   }, ChatView)

+ 2 - 2
packages/client/ui-conversation/src/client/chat/ChatView.tsx

@@ -230,7 +230,7 @@ function StreamingTail({ useSession, onGrow }: {
  * The chat view slot entry: pure component over the composed props (tool rows
  * render through the declared keyed hole's renderSlot share).
  */
-export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) {
+export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, forkAt }: ChatViewSlotProps) {
   const nodes = useSession(s => s.nodes)
   // Workspace root off the session list row: path summaries display relative to it.
   const cwd = useSessions(s => s.byId[sessionId]?.cwd)
@@ -385,7 +385,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
     }
     /* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
     if (node.kind === 'tool-result') return null
-    return <MessageItem key={item.key} node={node} />
+    return <MessageItem key={item.key} node={node} onFork={forkAt} />
   }
 
   return (

+ 8 - 5
packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx

@@ -1,5 +1,6 @@
-// Shared IconActions chrome for user and assistant messages: copy / branch
-// live (branch still a stub), date-aware clock, optional edit stub.
+// Shared IconActions chrome for user and assistant messages: copy live,
+// branch wired through onBranch (stub without it), date-aware clock,
+// optional edit stub.
 
 import { useCallback } from 'react'
 import {
@@ -18,17 +19,19 @@ export interface MessageIconActionsProps {
   clock: 'start' | 'end'
   /** When true, append the stub edit control (user bubble). */
   edit?: boolean | undefined
+  /** Fork the session at this message; absent leaves the branch control a stub. */
+  onBranch?: (() => void) | undefined
   /** Parent layout class composed onto the actions row. */
   className?: string | undefined
 }
 
 /**
  * Copy / branch (/ clock) IconActions row shared by user and assistant chrome.
- * @param props - Copy text, event time, clock side, optional edit, className.
+ * @param props - Copy text, event time, clock side, optional edit, branch callback, className.
  * @returns The actions row element.
  */
 export function MessageIconActions({
-  text, time, clock, edit, className,
+  text, time, clock, edit, onBranch, className,
 }: MessageIconActionsProps) {
   const day = useCalendarDay()
   const onCopy = useCallback(() => {
@@ -48,7 +51,7 @@ export function MessageIconActions({
         </button>
       </Tooltip>
       <Tooltip label="在新对话中分支" side="bottom">
-        <button type="button" className={css.action} aria-label="在新对话中分支">
+        <button type="button" className={css.action} aria-label="在新对话中分支" onClick={onBranch}>
           <IconBranchOutline16 />
         </button>
       </Tooltip>

+ 4 - 1
packages/client/ui-conversation/src/client/chat/MessageItem.tsx

@@ -16,6 +16,8 @@ import css from './MessageItem.module.css'
 
 export interface MessageItemProps {
   node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode
+  /** Fork the session through the turn containing this message (user-bubble branch action). */
+  onFork?: (seq: number) => void
 }
 
 function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } {
@@ -61,7 +63,7 @@ function projectUserText(text: string): ReactNode {
   return <>{parts}</>
 }
 
-export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
+export const MessageItem = memo(function MessageItem({ node, onFork }: MessageItemProps) {
   switch (node.kind) {
     case 'user': {
       const { text, rest } = contentText(node.content)
@@ -76,6 +78,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
             time={node.time}
             clock="start"
             edit
+            onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
             className={css.actions}
           />
         </div>

+ 2 - 0
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -419,6 +419,8 @@ export interface ChatViewInjected {
    */
   openFile: (path: string) => void
   loadOlder: () => void
+  /** Fork the session through the turn containing the message at `seq`, then open the child. */
+  forkAt: (seq: number) => void
 }
 
 /** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */

+ 3 - 1
packages/client/ui-conversation/tests/chat-view.spec.tsx

@@ -94,6 +94,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
   const openDetails = vi.fn<(t: SelectionTarget) => void>()
   const openFile = vi.fn<(path: string) => void>()
   const loadOlder = vi.fn()
+  const forkAt = vi.fn()
   // Selection rides the REAL chat store (same construction path as
   // production; the view reads it through the PropsStore useStore share).
   // renderSlot stub renders the render-site fallback (an empty keyed ledger:
@@ -120,9 +121,10 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
     openDetails,
     openFile,
     loadOlder,
+    forkAt,
   }
   const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
-  return { set, ChatView, props, openDetails, openFile, loadOlder, setSelection }
+  return { set, ChatView, props, openDetails, openFile, loadOlder, forkAt, setSelection }
 }
 
 describe('chat-flow derivation', () => {

+ 28 - 6
packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx

@@ -83,7 +83,7 @@ interface DragState {
 
 type SessionTreeProps = Pick<
   WorkspaceBrowserProps,
-  'useSessions' | 'startSession' | 'open' | 'insertSessionBefore'
+  'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore'
 > & {
   workspaces: readonly WorkspaceView[]
   /** Live search filter owned by the browser root (the query outlives the tree). */
@@ -98,7 +98,7 @@ type SessionTreeProps = Pick<
 
 /** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
 function SessionTree({
-  useSessions, startSession, open, workspaces, query,
+  useSessions, startSession, open, forkSession, workspaces, query,
   onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore,
 }: SessionTreeProps) {
   const list = useSessions(s => s)
@@ -115,6 +115,24 @@ function SessionTree({
     if (current === undefined || currentGroup === undefined) return
     setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
   }, [current, currentGroup])
+  // The selected session must be visible: unfold its ancestor chain (fork
+  // lands the child under a possibly folded parent row).
+  const currentAncestors = useMemo(() => {
+    const chain: string[] = []
+    let cursor = current === undefined ? undefined : list.byId[current]?.parentId
+    while (cursor !== undefined && !chain.includes(cursor)) {
+      chain.push(cursor)
+      cursor = list.byId[cursor]?.parentId
+    }
+    return chain
+  }, [current, list])
+  useEffect(() => {
+    if (currentAncestors.length === 0) return
+    setExpandedSessions((l) => {
+      const missing = currentAncestors.filter(id => !l.includes(id))
+      return missing.length === 0 ? l : [...l, ...missing]
+    })
+  }, [currentAncestors])
   const groups = useMemo(
     () => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
     [list, workspaces, expandedProjects, expandedSessions, query],
@@ -195,6 +213,7 @@ function SessionTree({
                   now={now}
                   onOpen={open}
                   onRename={onSessionRename}
+                  onFork={forkSession}
                   onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
                   drag={dragProps}
                 />
@@ -209,7 +228,7 @@ function SessionTree({
 }
 
 /** The flat "In one list" body: every session a top-level row, newest-first. */
-function FlatList({ useSessions, open, onSessionRename, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'onSessionRename' | 'query'>) {
+function FlatList({ useSessions, open, forkSession, onSessionRename, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'query'>) {
   const list = useSessions(s => s)
   const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
   const now = Date.now()
@@ -228,6 +247,7 @@ function FlatList({ useSessions, open, onSessionRename, query }: Pick<SessionTre
             now={now}
             onOpen={open}
             onRename={onSessionRename}
+            onFork={forkSession}
             /* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */
             onToggle={() => {}}
             flat
@@ -254,6 +274,7 @@ export function WorkspaceBrowser({
   startSession,
   open,
   renameSession,
+  forkSession,
   renameWorkspace,
   deleteWorkspace,
   insertSessionBefore,
@@ -460,13 +481,14 @@ export function WorkspaceBrowser({
 
       {/* Always-mounted seat keeps the region's flex slot while the list
           itself is wide-only. */}
-      <div className={css.listArea}>
-        {wide && (groupBy === 'flat'
-          ? <FlatList useSessions={useSessions} open={open} onSessionRename={onSessionRename} query={query} />
+        <div className={css.listArea}>
+          {wide && (groupBy === 'flat'
+          ? <FlatList useSessions={useSessions} open={open} forkSession={forkSession} onSessionRename={onSessionRename} query={query} />
           : (
             <SessionTree
               useSessions={useSessions}
               onSessionRename={onSessionRename}
+              forkSession={forkSession}
               workspaces={workspaces}
               startSession={startSession}
               open={open}

+ 2 - 0
packages/client/ui-workspace/src/client/contract/slots.ts

@@ -95,6 +95,8 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
   open: (sessionId: SessionId) => void
   /** Rename a Session (explicit user title; resolves on host acceptance). */
   renameSession: (sessionId: SessionId, title: string) => Promise<void>
+  /** Fork a Session at its last completed turn and open the child. */
+  forkSession: (sessionId: SessionId) => void
   /** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
   renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
   /** Delete only a Host Workspace registration; directory and Session logs remain. */

+ 7 - 0
packages/client/ui-workspace/src/client/index.ts

@@ -59,6 +59,13 @@ export function apply(ctx: ClientContext): void {
       const result = await session.rename(title)
       if (!result.ok) throw new Error(result.error.message)
     },
+    forkSession: (sessionId) => {
+      ctx.sessions.fork({ sessionId })
+        .then((childId) => { ctx.sessions.open(childId) })
+        .catch(() => {
+          // Fork failure keeps the list untouched (composer-stop posture).
+        })
+    },
     renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
     deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
     insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {

+ 10 - 6
packages/client/ui-workspace/src/client/rows/Rows.tsx

@@ -2,8 +2,8 @@
  * Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
  * all data and callbacks arrive via props. Hover swaps (folder->chevron,
  * time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
- * except workspace Rename/Delete and session Rename; the session and workspace
- * hover cards are suppressed while a menu is open.
+ * except workspace Rename/Delete and session Rename/Fork; the session and
+ * workspace hover cards are suppressed while a menu is open.
  */
 import { useState } from 'react'
 import clsx from 'clsx'
@@ -184,7 +184,7 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
   return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
 }
 
-export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, onToggle, drag, flat = false }: {
+export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, onFork, onToggle, drag, flat = false }: {
   node: SessionNode
   depth: number
   currentId: string | undefined
@@ -192,6 +192,8 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
   onOpen: (id: SessionNode['id']) => void
   /** Open the browser-owned session rename dialog (row menu action). */
   onRename: (id: SessionNode['id'], currentTitle: string) => void
+  /** Fork a session at its last completed turn (row menu action). */
+  onFork: (id: SessionNode['id']) => void
   onToggle: (id: SessionNode['id']) => void
   /** Present only on draggable rows (workspace-group roots outside search). */
   drag?: RowDragProps | undefined
@@ -259,9 +261,10 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
           open={menuOpen}
           onClose={() => { setMenuOpen(false) }}
           items={SESSION_MENU_ITEMS}
-          onSelect={(id) => {
-            setMenuOpen(false)
-            if (id === 'rename') onRename(node.id, row.title) // fork/delete stay visual-only.
+            onSelect={(id) => {
+              setMenuOpen(false)
+              if (id === 'rename') onRename(node.id, row.title)
+              if (id === 'fork') onFork(node.id) // delete stays visual-only.
           }}
           portal
           closeOnPointerLeave
@@ -295,6 +298,7 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
           now={now}
           onOpen={onOpen}
           onRename={onRename}
+          onFork={onFork}
           onToggle={onToggle}
         />
       ))}

+ 27 - 27
packages/client/ui-workspace/tests/rows.spec.tsx

@@ -67,9 +67,9 @@ describe('workspace browser rows', () => {
     }
     const onOpen = vi.fn()
     const onToggle = vi.fn()
-    const view = render(
-      <SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen}
-        onRename={vi.fn()} onToggle={onToggle} />,
+      const view = render(
+        <SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen}
+          onRename={vi.fn()} onFork={vi.fn()} onToggle={onToggle} />,
     )
 
     const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
@@ -88,9 +88,9 @@ describe('workspace browser rows', () => {
 
     view.rerender(
       <SessionNodeItem
-        node={{ ...parent, children: [], expanded: false, running: false }}
-        depth={1} currentId={undefined} now={0} onOpen={onOpen}
-        onRename={vi.fn()} onToggle={onToggle}
+          node={{ ...parent, children: [], expanded: false, running: false }}
+          depth={1} currentId={undefined} now={0} onOpen={onOpen}
+          onRename={vi.fn()} onFork={vi.fn()} onToggle={onToggle}
       />,
     )
     expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
@@ -162,9 +162,9 @@ describe('workspace browser rows', () => {
     const node: SessionNode = {
       id: sid('s1'), title: 'One', children: [], hasChildren: false,
       expanded: false, running: false, updatedAt: 0,
-    }
-    render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen}
-      onRename={onRename} onToggle={vi.fn()} />)
+      }
+      render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen}
+        onRename={onRename} onFork={vi.fn()} onToggle={vi.fn()} />)
     fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
     expect(onOpen).not.toHaveBeenCalled()
     expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/)
@@ -189,9 +189,9 @@ describe('workspace browser rows', () => {
     const node: SessionNode = {
       id: sid('p'), title: 'Parent', children: [], hasChildren: true,
       expanded: false, running: false, updatedAt: 0,
-    }
-    render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
-      onRename={vi.fn()} onToggle={vi.fn()} flat />)
+      }
+      render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
+        onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} flat />)
     expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull()
   })
 
@@ -201,9 +201,9 @@ describe('workspace browser rows', () => {
       const node: SessionNode = {
         id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
         expanded: false, running: true, updatedAt: 0,
-      }
-      render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()}
-        onRename={vi.fn()} onToggle={vi.fn()} />)
+        }
+        render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()}
+          onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} />)
       const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
       fireEvent.pointerEnter(wrapper)
       act(() => { vi.advanceTimersByTime(500) })
@@ -228,9 +228,9 @@ describe('workspace browser rows', () => {
       const node: SessionNode = {
         id: sid('s1'), title: 'Quiet', children: [], hasChildren: false,
         expanded: false, running: false, updatedAt: 0,
-      }
-      render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
-        onRename={vi.fn()} onToggle={vi.fn()} />)
+        }
+        render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
+          onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} />)
       fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
       act(() => { vi.advanceTimersByTime(500) })
       expect(screen.getByText('Idle')).toBeTruthy()
@@ -246,9 +246,9 @@ describe('workspace browser rows', () => {
       expanded: false, running: false, updatedAt: 0,
     }
     const inactive = dragProps()
-    const { rerender } = render(
-      <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
-        onRename={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
+      const { rerender } = render(
+        <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
+          onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
     )
     const row = screen.getByRole('treeitem')
     stubRect(row)
@@ -264,9 +264,9 @@ describe('workspace browser rows', () => {
     expect(inactive.end).toHaveBeenCalledOnce()
 
     const active = dragProps({ active: true, marker: 'before' })
-    rerender(
-      <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
-        onRename={vi.fn()} onToggle={vi.fn()} drag={active} />,
+      rerender(
+        <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
+          onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} drag={active} />,
     )
     stubRect(screen.getByRole('treeitem'))
     // Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
@@ -278,9 +278,9 @@ describe('workspace browser rows', () => {
     expect(active.drop).toHaveBeenCalledWith('after')
 
     const after = dragProps({ active: true, marker: 'after' })
-    rerender(
-      <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
-        onRename={vi.fn()} onToggle={vi.fn()} drag={after} />,
+      rerender(
+        <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
+          onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} drag={after} />,
     )
     expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
   })

+ 1 - 0
packages/client/ui-workspace/tests/workspace-browser.spec.tsx

@@ -56,6 +56,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
     startSession: vi.fn(),
     open: vi.fn(),
     renameSession: vi.fn(async () => {}),
+    forkSession: vi.fn(),
     renameWorkspace: vi.fn(async () => {}),
     deleteWorkspace: vi.fn(async () => {}),
     insertSessionBefore: vi.fn(async () => {}),

+ 60 - 0
packages/host/apiproxy/src/api-proxy.ts

@@ -1148,6 +1148,66 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
         }
       },
 
+      async fork(request) {
+        const { sessionId, atSeq } = request.payload
+        const found = await agentFor(sessionId)
+        if ('error' in found) return err(request, found.error)
+        const source = found.agent.session
+        const events = source.events
+        // Boundary: the first turn/end at or after atSeq (fork includes that
+        // whole turn); an overshooting atSeq or an omitted one falls back to
+        // the last completed turn.
+        const boundary = (atSeq === undefined ? undefined : events.find(e => e.type === 'turn/end' && e.seq >= atSeq))
+          ?? events.findLast(e => e.type === 'turn/end')
+        if (boundary === undefined) {
+          return err(request, {
+            code: 'fork-unavailable',
+            message: `session "${sessionId}" has no completed turn to fork from`,
+            details: { sessionId },
+          })
+        }
+        // Extend the cut through trailing out-of-band appends (session/title,
+        // injections) up to the next turn/start: they are standalone events, so
+        // the seed stays balanced, and the child inherits a title generated
+        // right after the boundary turn.
+        let cut = boundary.seq + 1
+        while (cut < events.length && events[cut]?.type !== 'turn/start') cut++
+        const childId = `session-${randomUUID()}` as SessionId
+        try {
+          await ctx.agents.create({
+            sessionId: childId,
+            seed: events.slice(0, cut),
+            meta: {
+              ...source.header.cwd === undefined ? {} : { cwd: source.header.cwd },
+              parentSession: source.id,
+              seedLength: cut,
+            },
+            agentOptions,
+          })
+        } catch (error: unknown) {
+          return err(request, {
+            code: 'internal',
+            message: `failed to fork session "${sessionId}": ${String(error)}`,
+            details: {},
+          })
+        }
+        // Keep the child in the source's Workspace so the list nests it under
+        // its parent; the child is already published if the attach fails.
+        const workspace = ctx.workspace.list().find(w => w.sessionIds.includes(source.id))
+        if (workspace !== undefined) {
+          try {
+            await workspace.attachSession(childId)
+          } catch (error: unknown) {
+            return err(request, {
+              code: 'workspace-attach-failed',
+              message: `session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`,
+              details: { sessionId: childId, workspaceId: workspace.id },
+            })
+          }
+        }
+        return ok(request, { sessionId: childId })
+      },
+
       async prompt(request) {
         const { sessionId, mode, content } = request.payload
         const found = await agentFor(sessionId)

+ 1 - 0
packages/host/apiproxy/src/api/rpc-map.ts

@@ -24,6 +24,7 @@ export interface RpcMethodMap {
   'session.models': SessionsApi['models']
   'session.selectModel': SessionsApi['selectModel']
   'session.rename': SessionsApi['rename']
+  'session.fork': SessionsApi['fork']
   'session.prompt': SessionsApi['prompt']
   'session.updateQueue': SessionsApi['updateQueue']
   'session.cancel': SessionsApi['cancel']

+ 1 - 0
packages/host/apiproxy/src/api/rpc.schema.ts

@@ -51,6 +51,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
   z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
   z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
   z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
+  z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
   z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
 ]) as unknown as z.ZodType<RpcError>
 

+ 1 - 0
packages/host/apiproxy/src/api/rpc.ts

@@ -51,6 +51,7 @@ export interface RpcErrorDetailsMap {
   /** A leading-/ prompt named no registered command; the message names the token. */
   'unknown-command': {}
   'title-invalid': { sessionId: SessionId }
+  'fork-unavailable': { sessionId: SessionId }
   'internal': {}
 }
 

+ 11 - 0
packages/host/apiproxy/src/api/sessions.schema.ts

@@ -89,6 +89,17 @@ export const sessionRenameValueSchema = z.object({
   seq: z.number().int().nonnegative(),
 }) satisfies z.ZodType<Wire<ResponseValue<'session.rename'>>>
 
+/** session.fork request payload (atSeq anchors the completed-turn cut). */
+export const sessionForkRequestSchema = z.object({
+  sessionId: sessionIdSchema,
+  atSeq: z.number().int().nonnegative().optional(),
+}) satisfies z.ZodType<Wire<RequestPayload<'session.fork'>>>
+
+/** session.fork response value (the child session id). */
+export const sessionForkValueSchema = z.object({
+  sessionId: sessionIdSchema,
+}) satisfies z.ZodType<Wire<ResponseValue<'session.fork'>>>
+
 /** session.history request payload (beforeSeq/maxMessages page backwards from the window tail). */
 export const sessionHistoryRequestSchema = z.object({
   sessionId: sessionIdSchema,

+ 14 - 0
packages/host/apiproxy/src/api/sessions.ts

@@ -238,6 +238,20 @@ export interface SessionsApi {
    * one — carried for future rendering; the state change is the feedback). A usage/state error is an
    * RPC error with code command-error; an unrecognized name is an RPC error with code unknown-command.
    */
+  /**
+   * Forks a new session from a completed-turn prefix of the source. `atSeq`
+   * anchors the cut: the boundary is the first `turn/end` at or after it
+   * (a message's fork button passes the message seq, so the fork includes
+   * that whole turn); a boundary past the log end, or an omitted `atSeq`,
+   * falls back to the source's last completed turn. A source with no
+   * completed turn fails with `fork-unavailable`. The child inherits the
+   * source cwd (and its workspace attachment) and records
+   * `parentSessionId` lineage; the seed prefix carries the source title.
+   */
+  fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>):
+  Promise<RpcResponse<{ sessionId: SessionId }>>
+
+  /** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */
   prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
   Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
 

+ 4 - 0
packages/host/apiproxy/src/fetch/client.ts

@@ -20,6 +20,7 @@ import {
 import {
   sessionCancelValueSchema,
   sessionCreateValueSchema,
+  sessionForkValueSchema,
   sessionHistoryValueSchema,
   sessionListValueSchema,
   sessionModelsValueSchema,
@@ -69,6 +70,7 @@ export interface IApiClient {
     models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.models'>>>
     selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.selectModel'>>>
     rename(payload: RequestPayload<'session.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.rename'>>>
+    fork(payload: RequestPayload<'session.fork'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.fork'>>>
     prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
     updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.updateQueue'>>>
     cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
@@ -121,6 +123,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
   'session.models': sessionModelsValueSchema,
   'session.selectModel': sessionSelectModelValueSchema,
   'session.rename': sessionRenameValueSchema,
+  'session.fork': sessionForkValueSchema,
   'session.prompt': sessionPromptValueSchema,
   'session.updateQueue': sessionUpdateQueueValueSchema,
   'session.cancel': sessionCancelValueSchema,
@@ -334,6 +337,7 @@ export abstract class AbstractApiClient implements IApiClient {
     models: (payload, signal) => this.callUnary('session.models', payload, signal),
     selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal),
     rename: (payload, signal) => this.callUnary('session.rename', payload, signal),
+    fork: (payload, signal) => this.callUnary('session.fork', payload, signal),
     prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
     updateQueue: (payload, signal) => this.callUnary('session.updateQueue', payload, signal),
     cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),

+ 2 - 0
packages/host/apiproxy/src/fetch/handler.ts

@@ -17,6 +17,7 @@ import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts'
 import {
   sessionCancelRequestSchema,
   sessionCreateRequestSchema,
+  sessionForkRequestSchema,
   sessionHistoryRequestSchema,
   sessionListRequestSchema,
   sessionModelsRequestSchema,
@@ -71,6 +72,7 @@ const UNARY_ROUTES: UnaryRoutes = {
   'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) },
   'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) },
   'session.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) },
+  'session.fork': { schema: sessionForkRequestSchema, invoke: (api, r) => api.sessions.fork(r) },
   'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
   'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) },
   'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },

+ 1 - 0
packages/host/apiproxy/tests/client-handler.spec.ts

@@ -47,6 +47,7 @@ function scriptedApi(overrides: {
         selected: { provider: r.payload.provider, model: r.payload.model },
       }),
       rename: r => ok(r, { title: 'renamed', seq: 0 }),
+      fork: r => ok(r, { sessionId: sid('s-fork') }),
       prompt: r => ok(r, { accepted: true as const }),
       updateQueue: r => ok(r, { accepted: true as const }),
       cancel: r => ok(r, { accepted: true as const }),

+ 3 - 0
packages/host/apiproxy/tests/fetch-carrier.spec.ts

@@ -70,6 +70,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
       async rename(request) {
         return { rpcId: request.rpcId, result: { ok: true, value: { title: request.payload.title, seq: 0 } } }
       },
+      async fork(request) {
+        return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-fork' as never } } }
+      },
       async prompt(request) {
         return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
       },