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
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).
- 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.
- 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
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)
set -e
cd "$ROOT_DIR"
go test ./pkg/aichat/ -count=1
command: database store specs (turnless seed message, SetSessionMetadataOnce write-once)
set -e
cd "$ROOT_DIR"
go test ./pkg/database/ -count=1
command: captain build gate
set -e
cd "$ROOT_DIR"
make build
command: captain lint gate
set -e
cd "$ROOT_DIR"
make lint
command: clicky-ui tests and dist rebuild
set -e
cd "$ROOT_DIR/../clicky-ui/packages/ui"
pnpm test
pnpm build
command: captain webapp type-checks against rebuilt clicky-ui dist
set -e
cd "$ROOT_DIR/pkg/cli/webapp"
pnpm build
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
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-seedmarker part + framed text part). Provenance via session metadataforkedFrom; forks stay roots (noparent_session_id— the thread list filtersRootsOnlyand costs roll up through the parent hierarchy).ThreadStore.SetRuntime(write-once, mirrorsSetProviderSession) binds Name+Backend after the first real turn's user message persists;enforceThreadRuntimerejects mismatches with 409 pointing at fork. Reasoning effort and temperature stay adjustable.lockedprop on RuntimeBar (combo + segments + AdvancedChatConfig),ChatgainsonMessageCountChange(non-seed count) /onSessionHydrated/runtimeLocked; global model preference must not be poisoned by a locked thread.Seed-as-message rationale (verified): codex folds
Prompt.Systeminto every turn's input text, so a system-prompt seed would re-enter the codex transcript every turn; a persisted message flows throughagentPromptonce (turn 1) and the provider session retains it. API mode replays it via normal client message replay, andenforceRuntimeSettingscounts it natively.Acceptance Criteria
POST /api/chat/sessions/{id}/forkreturns 201 with a new root thread titled "Fork of " (TitleSourceDerived),forkedFromset, seed persisted as first message with NULL turn_id; 400 on seed-only/empty source, 404 unknown, 409 while the source has an active turnProviderSessionID, usage totals, or the model lock; fork costs do not roll into the source sessionPrompt.Useronly (resumed turns do not re-send it); API backends receive it as a user message with thedata-fork-seedpart skippedmake lintandmake buildpass in captain;pnpm testandpnpm buildpass in clicky-uipackages/ui; captain webapppnpm buildtype-checks against the rebuilt distVerification
timeout: 45m
Structural seams
Test and build gates
command: aichat package specs (fork endpoint, model lock, seed canonicalization; includes DB integration suites)
command: database store specs (turnless seed message, SetSessionMetadataOnce write-once)
command: captain build gate
command: captain lint gate
command: clicky-ui tests and dist rebuild
command: captain webapp type-checks against rebuilt clicky-ui dist