Skip to content

aichat: fork sessions + lock model after first turn #59

Description

@moshloop

aichat: fork sessions + lock model after first turn

The FAB chat lets users switch models freely mid-conversation: the model is a per-request body field (ChatRequest.Model/Runtime), never persisted on the thread, so every turn can silently run on a different backend — corrupting provider-session continuity and cost attribution.

This change makes the model identity (Name + Backend) immutable once a session has its first real message, and introduces fork as the sanctioned way to continue a conversation on a different model.

Scope

  1. POST /api/chat/sessions/{id}/fork — creates a new root session whose first message is the flattened source transcript, persisted as a turnless seed message (data-fork-seed marker part + framed text part). Provenance via session metadata forkedFrom; forks stay roots (no parent_session_id — the thread list filters RootsOnly and costs roll up through the parent hierarchy).
  2. Model lock — ThreadStore.SetRuntime (write-once, mirrors SetProviderSession) binds Name+Backend after the first real turn's user message persists; enforceThreadRuntime rejects mismatches with 409 pointing at fork. Reasoning effort and temperature stay adjustable.
  3. clicky-ui — fork button in the ChatWindow header (opens fork in a new panel), collapsed "forked from" chip for the seed message, locked prop on RuntimeBar (combo + segments + AdvancedChatConfig), Chat gains onMessageCountChange (non-seed count) / onSessionHydrated / runtimeLocked; global model preference must not be poisoned by a locked thread.

Seed-as-message rationale (verified): codex folds Prompt.System into every turn's input text, so a system-prompt seed would re-enter the codex transcript every turn; a persisted message flows through agentPrompt once (turn 1) and the provider session retains it. API mode replays it via normal client message replay, and enforceRuntimeSettings counts it natively.

Acceptance Criteria

  • POST /api/chat/sessions/{id}/fork returns 201 with a new root thread titled "Fork of " (TitleSourceDerived), forkedFrom set, seed persisted as first message with NULL turn_id; 400 on seed-only/empty source, 404 unknown, 409 while the source has an active turn
  • Fork does not inherit ProviderSessionID, usage totals, or the model lock; fork costs do not roll into the source session
  • First real turn binds model Name+Backend (seed alone does not bind); a later turn with a different model gets 409 whose message names both models and says to fork; same model with different effort/temperature succeeds
  • Agent backends receive the seed text in turn 1 Prompt.User only (resumed turns do not re-send it); API backends receive it as a user message with the data-fork-seed part skipped
  • ChatWindow header shows a fork button (disabled until the thread has real messages) that opens the fork in a new panel; the seed renders as a collapsed chip, not a user bubble; the picker locks once the thread has real messages and shows why; the fork's picker starts unlocked
  • make lint and make build pass in captain; pnpm test and pnpm build pass in clicky-ui packages/ui; captain webapp pnpm build type-checks against the rebuilt dist

Verification


timeout: 45m

Structural seams

Name Command Exit Code CEL Validation
Fork route registered rg -Fn "sessions/{id}/fork" $ROOT_DIR/pkg/aichat 0 stdout.contains("threads_http.go")
ThreadStore has write-once SetRuntime rg -n "SetRuntime" $ROOT_DIR/pkg/aichat/threads.go 0 stdout.contains("SetRuntime")
Lock rejection points at fork rg -n "fork the session" $ROOT_DIR/pkg/aichat 0 stdout.contains("lock")
Seed persisted as data-fork-seed message part rg -n "data-fork-seed" $ROOT_DIR/pkg/aichat 0 stdout != ""
Seed transcript is not stored in session metadata rg -n "aichatForkSeed" $ROOT_DIR/pkg/aichat $ROOT_DIR/pkg/database 1 stdout == ""
Forks stay list-visible roots (no parent_session_id) rg -n "ParentSessionID" $ROOT_DIR/pkg/aichat/database_threads.go 1 stdout == ""
ChatWindow header wires forkChatSession rg -n "forkChatSession" $ROOT_DIR/../clicky-ui/packages/ui/src/data/ai/ChatWindow.tsx 0 stdout != ""
RuntimeBar exposes locked prop rg -n "locked" $ROOT_DIR/../clicky-ui/packages/ui/src/data/runtime/RuntimeBar.tsx 0 stdout.contains("locked")
Seed chip rendering exists in chat UI rg -rn "data-fork-seed" $ROOT_DIR/../clicky-ui/packages/ui/src/data/chat 0 stdout != ""

Test and build gates

command: aichat package specs (fork endpoint, model lock, seed canonicalization; includes DB integration suites)

timeout: 900
set -e
cd "$ROOT_DIR"
go test ./pkg/aichat/ -count=1
  • cel: exitCode == 0

command: database store specs (turnless seed message, SetSessionMetadataOnce write-once)

timeout: 900
set -e
cd "$ROOT_DIR"
go test ./pkg/database/ -count=1
  • cel: exitCode == 0

command: captain build gate

timeout: 900
set -e
cd "$ROOT_DIR"
make build
  • cel: exitCode == 0

command: captain lint gate

timeout: 900
set -e
cd "$ROOT_DIR"
make lint
  • cel: exitCode == 0

command: clicky-ui tests and dist rebuild

timeout: 900
set -e
cd "$ROOT_DIR/../clicky-ui/packages/ui"
pnpm test
pnpm build
  • cel: exitCode == 0

command: captain webapp type-checks against rebuilt clicky-ui dist

timeout: 900
set -e
cd "$ROOT_DIR/pkg/cli/webapp"
pnpm build
  • cel: exitCode == 0

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions