Skip to content

RFC: Chats as a DAG #353

Description

@connor4312

Summary

This RFC evolves a chat’s retained history from a destructive linear timeline into a host-managed history graph. A client can move a chat’s current history head to an earlier turn and restore the workspace files to that turn atomically. Hosts that retain branches let the client continue from that point without deleting alternate futures; linear hosts prune those futures when the client continues.

The proposal retains activeTurn's existing streaming semantics. It gains parent metadata so a continued turn can be recorded in the history graph. The new concept is a completed-history head, named headTurnId.

Motivation

Today, chat/truncated removes turns after a selected point. A client that wants corresponding workspace rollback must independently restore files. This has several problems:

  • transcript and workspace can diverge;
  • file restoration is not atomic with history mutation;
  • clients cannot leverage host-native mechanisms such as APFS snapshots, Git worktrees, reflinks, or filesystem journals;
  • discarded work cannot be revisited or compared;
  • truncation and branching are conflated;
  • forks and side chats may reference stale pointers.

A conversation is more naturally a graph:

root ── T1 ── T2 ── T3 ── T4   (old head)
                  \
                   T5 ── T6    (new head after checkout at T2 and two followups)

The host owns workspace restoration and, when supported, graph persistence.

Goals

  • Preserve ChatState.activeTurn and its existing streaming semantics.
  • Make restoration of workspace files host-brokered and atomic with selection of conversation history.
  • Let capable hosts preserve abandoned branches until host retention policy prunes them.
  • Support host-specific restoration implementations without exposing storage details.
  • Provide clear concurrency and external-change behavior.

Non-goals

  • Reversing external side effects such as remote API calls, deployments, or sent messages.
  • Defining a general merge algorithm for conversations or workspaces.
  • Requiring every host to expose all retained graph nodes to every client.
  • Replacing chat forks; cross-chat forks remain useful for concurrent or isolated work.

Terminology

Term Meaning
Turn node A completed turn in the host-retained conversation graph.
Head The completed turn selected as the end of the chat’s active history path.
Active turn The existing activeTurn: an in-progress, streaming turn.
Checkout Atomically selecting a graph head and restoring workspace files to that turn.
Selected path The root-to-head sequence identified by following parentTurnId from headTurnId.

State model

ChatState.activeTurn remains unchanged.

interface ChatState {
  // Existing fields
  /**
   * Loaded completed turns, keyed by turn ID.
   *
   * A host initially includes the selected path and may load other retained
   * turn nodes on demand.
   */
  turns: Record<string, Turn>;

  activeTurn?: ActiveTurn;

  /**
   * ID of the completed turn at the end of the selected path.
   * Omitted when the selected path is the root/baseline with no completed turns.
   */
  headTurnId?: string;
}

turns is an ID-keyed collection of loaded retained turn nodes. A host SHOULD include retained branches that are relevant to its initial history view, but it need not eagerly load every descendant of a branch point. Clients render the selected linear transcript by walking parentTurnId backward from headTurnId.

The host MAY only include a subset of turns and expose more through the graph-aware fetchTurns extension described in Turn loading.

Each completed turn gains optional graph metadata:

interface Turn {
  // Existing fields
  id: string;

  /**
   * Parent completed turn in the retained graph.
   * Omitted for a root-level turn.
   */
  parentTurnId?: string;
}

An active turn records the same parentage until it becomes completed:

interface ActiveTurn {
  // Existing fields

  /**
   * Completed turn selected when this turn started. Omitted when the turn
   * starts from the root/baseline.
   */
  parentTurnId?: string;
}

Capability negotiation

A host advertises checkout support through agent capabilities:

interface AgentCapabilities {
  /** The host supports checkout and workspace restoration. */
  history?: HistoryCapability;
}

interface HistoryCapability {
  /**
   * The host preserves alternate futures after a user continues from an
   * earlier checked-out turn. When absent or `false`, the host uses linear
   * history: it prunes those futures after accepting the new turn.
   */
  retainsBranches?: boolean;
}

Clients MUST NOT invoke checkout unless history is advertised.

Checkout command

interface CheckoutChatHistoryParams extends BaseParams {
  /** Chat channel to update. */
  channel: URI;

  /**
   * Completed turn that becomes the new history head. Omit to select the
   * chat's root/baseline state.
   */
  turnId?: string;

  /**
   * Optional optimistic-concurrency guard. The host rejects with Conflict when
   * it no longer matches the current ChatState.headTurnId. Pass `null` to
   * guard the root/baseline head; omit this field to make no assertion.
   */
  expectedHeadTurnId?: string | null;

  /**
   * Permit checkout to overwrite or delete local file changes that would not
   * exist at the selected turn. Clients SHOULD set this only after presenting
   * the message from `CheckoutWouldDiscardChangesErrorData` to the user.
   */
  force?: boolean;
}

interface CheckoutChatHistoryResult {}

checkoutChatHistory is a request, not a client-dispatchable action. Its successful response is the synchronization boundary: once it resolves, the host has committed both selected history and restored workspace state.

Destructive-change error

The protocol adds AhpErrorCodes.CheckoutWouldDiscardChanges (-32012). Its JSON-RPC error.data is CheckoutWouldDiscardChangesErrorData:

interface CheckoutWouldDiscardChangesErrorData {
  /**
   * User-facing explanation of the changes that checkout would overwrite or
   * delete.
   */
  message: StringOrMarkdown;
}

Because JSON-RPC requires error.message to be a string, the top-level message is the fixed summary "Checkout would discard changes"; the structured, user-facing message is carried in error.data.message.

When checkout would overwrite or delete file changes and force is absent or false, the host MUST return this error without changing the chat or workspace. A client that receives it SHOULD present data.message and, after user confirmation, retry the same request with force: true. A host MUST perform the checkout when force is true, subject to normal validation and operational failures.

Checkout semantics

On receiving checkoutChatHistory, the host MUST:

  1. validate that the target turn belongs to the chat and is retained;
  2. validate expectedHeadTurnId, when supplied;
  3. reject with TurnInProgress if an activeTurn exists;
  4. acquire exclusive access to the workspace files it will restore;
  5. detect whether restoration would overwrite or delete file changes;
  6. return CheckoutWouldDiscardChanges unless force is true when such changes exist;
  7. restore workspace files and backend conversation state to the selected turn;
  8. commit the selected head;
  9. load any missing nodes on the new selected path with chat/turnsLoaded, then publish the authoritative updated chat state and related resource state;
  10. return CheckoutChatHistoryResult.

If any step fails, the host MUST preserve the pre-request history head and workspace state. It MUST NOT report success or publish a partially applied history transition.

Hosts MUST return Conflict when a workspace lease cannot be acquired or when expectedHeadTurnId is stale. A host MAY return another applicable AHP error for operational failures that prevent restoration even with force: true.

Actions

A successful checkout emits:

interface ChatHistoryCheckedOutAction {
  type: 'chat/historyCheckedOut';
  headTurnId?: string;
}

This action updates the durable selected-head state on every subscribed client. No separate history-replacement action is needed: checkout loads any missing selected-path nodes first, and headTurnId plus parentTurnId identifies the selected path.

The protocol version introducing history checkout removes chat/truncated.
Linear hosts advertise history with retainsBranches absent or false and
use checkout followed by chat/turnsPruned for the destructive continuation
behavior.

Continuing after checkout

When a host accepts chat/turnStarted, it binds the new turn to the current
headTurnId. The host MUST assign this parent; a client MUST NOT choose it.
The server's authoritative chat/turnStarted action therefore adds:

interface ChatTurnStartedAction {
  // Existing fields

  /**
   * Completed turn selected when this turn started. Omitted when the turn
   * starts from the root/baseline.
   */
  parentTurnId?: string;
}

When the turn completes, the host persists its parentTurnId on the completed
Turn, upserts that turn into ChatState.turns, and advances headTurnId to
the completed turn's ID.

If history.retainsBranches is absent or false, the host MUST remove
alternate futures that became unreachable when it accepted the new turn. It does so
by dispatching chat/turnsPruned after the authoritative chat/turnStarted
action:

interface ChatTurnsPrunedAction {
  type: 'chat/turnsPruned';

  /** IDs of completed turns removed from `ChatState.turns`. */
  turnIds: string[];
}

The host MUST include every removed descendant in turnIds, MUST NOT remove
the selected path or the active turn, and MUST dispatch this action in server
order so every subscribed client converges. A host with
history.retainsBranches: true MAY also dispatch chat/turnsPruned later
when its retention policy prunes a branch.

Turn loading

No branch-discovery request is needed. Graph-aware clients use the existing fetchTurns command to page the direct children of a known turn. Passing parentTurnId: null requests root-level turns; passing an ID requests the direct children of that turn. This lets a client expand a branch point on demand without loading a long, unrelated run of descendant turns.

interface FetchTurnsParams extends BaseParams {
  channel: URI;

  /**
   * Opaque cursor from a previous `fetchTurns` result. The cursor encodes the
   * requested parent; callers MUST NOT combine it with `parentTurnId`.
   */
  cursor?: string;

  /**
   * When supplied, load a page of this turn's direct children. `null` selects
   * root-level turns. A caller starts a child-page request with this field,
   * then follows the returned cursor until it is absent.
   */
  parentTurnId?: string | null;
}

interface FetchTurnsResult {
  /**
   * Opaque cursor for the next page of the requested children, if one remains.
   * Omitted when every direct child of the requested parent has been loaded.
   */
  nextCursor?: string;
}

The command remains action-backed: before responding, the host dispatches chat/turnsLoaded with the loaded nodes:

interface ChatTurnsLoadedAction {
  type: 'chat/turnsLoaded';
  /** Turn nodes to upsert into `ChatState.turns`, keyed by turn ID. */
  turns: Record<string, Turn>;
}

The reducer upserts the supplied nodes into ChatState.turns. FetchTurnsResult.nextCursor continues the same child-page request, so clients can page one branch point independently of other branches. Clients retain a returned cursor only for as long as they need to continue that page; after reconnecting, they can begin a new request for the relevant parent.

Retention

Hosts MAY prune retained branches according to policy. A host MUST NOT expose a retained turn as eligible for checkout unless it can restore the workspace files to that turn. A host MUST NOT prune a turn that is referenced by a fork.

Hosts SHOULD prune a node and its unreferenced descendants together, unless they can preserve a valid re-rooted representation.

Metadata

Metadata

Assignees

Labels

feature-requestRequest for new features or functionalityunder-discussionIssue is under discussion for relevance, priority, approach

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions