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

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