fix: reconcile cross-agent session config on resume (#2820)

This commit is contained in:
Carlos Florêncio
2026-08-19 17:03:15 +01:00
committed by GitHub
parent 61bde7ab9e
commit ff0d9fa6f3
8 changed files with 830 additions and 6 deletions
@@ -0,0 +1,167 @@
---
spec: docs/specs/session-config-cross-agent-reconcile/spec.md
created: 2026-08-19
status: draft
---
# Implementation Plan: Session Config Reconciliation Across Agent Types
## Overview
Two coordinated, independently verifiable changes fix a cross-agent config
contamination bug. Backend first: the startup/resume replay path filters
persisted runtime config options against the current agent's advertised catalog
before sending them, so options a prior agent wrote (e.g. `effort`, `thinking`)
are neither applied nor reported as startup failures. Frontend second: the chat
model-selector render gate stops treating persisted-but-unadvertised option keys
as required, so the selector renders for the resumed session. The two tasks are
disjoint (Go vs TS, no shared file) and each carries its own regression test.
## Confirmed root cause
- Backend replay `applyRuntimeSessionLayers`
(`apps/backend/internal/agent/runtime/lifecycle/session.go:602-651`) sanitizes
persisted options only with `profileconfig.SanitizeConfigOptions`, which keeps
every non-model/mode key. `SetConfigOption("effort"/"thinking", …)` fails on an
agent that does not advertise them; each key is added to `failed`.
- The orchestrator turns `failed` into the user warning
(`apps/backend/internal/orchestrator/event_handlers_streaming.go` around the
`warnWorkflowSessionConfig` call).
- The frontend gate `hasCompleteDynamicConfig` requires every key from
`requiredConfigKeys` (which reads `session.metadata.runtime_config(_overrides)
.config_options`); un-advertised keys keep `configHydrated=false` and hide the
selector (`apps/web/components/task/model-selector.tsx:90-133`).
The catalog-aware helper `sanitizeRuntimeConfigOptionsWithCatalog(options,
execution.GetModelState())` already exists and is used on the reset/restore path
(`apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go:627-648,
696`). The startup replay path simply does not use it.
---
## Backend
### Runtime replay layer (`internal/agent/runtime/lifecycle/session.go`)
Change `applyRuntimeSessionLayers` so that, after the runtime model is applied
(the `SetModel` block at lines 617-630), the persisted `runtimeConfigOptions`
are filtered against the current agent's advertised catalog before the
`SetConfigOption` loop.
- Replace the `sanitizedOptions := profileconfig.SanitizeConfigOptions(
runtimeConfigOptions)` call at line 638 with a catalog-aware filter that reuses
the existing `sanitizeRuntimeConfigOptionsWithCatalog(runtimeConfigOptions,
execution.GetModelState())` from `manager_interaction.go`. That helper already
drops model/mode/blank keys (via `isRestorableRuntimeConfigOption`) and, when
the catalog is known, drops any key not present in the agent's advertised
option set. When the catalog is not yet known it returns the restorable set
unfiltered — acceptable and unchanged from today for same-agent resumes.
- Only keys surviving the filter reach `SetConfigOption`, so unsupported prior-
agent keys never enter the `failed` slice and never reach the warning.
- Fail-safe note from spec: because the model is applied first in this same
function and `waitForFreshSessionModelState` runs before the layers
(`session.go:371-374`), the catalog is normally settled by replay time for the
resuming agent.
Do not change `applyProfileSessionLayers`; profile options come from the current
agent's own profile and are not the cross-agent contamination source. Keep
`profileconfig.SanitizeConfigOptions` there.
If reusing `sanitizeRuntimeConfigOptionsWithCatalog` across files is awkward
(it lives in `manager_interaction.go`, same package `lifecycle`), call it
directly — both files are in package `lifecycle`, so no export change is needed.
---
## Frontend
> User-facing: the chat model/mode selector visibility.
### `apps/web/components/task/model-selector.tsx`
Make the render gate resilient to persisted-but-unadvertised keys. Two options;
plan picks the narrowest:
- Keep `requiredConfigKeys` as the record of "what the session has configured",
but change `hasCompleteDynamicConfig` so a required key sourced only from
persisted `runtime_config(_overrides)` is not blocking when the current agent
has settled its config options and does not advertise that key. Concretely:
the `required.every(...)` check (lines 127-132) treats a key as satisfied when
it is advertised (`available.has(key)`) OR it is a persisted-metadata-only key
absent from the settled agent catalog. The agent catalog is considered settled
using the existing `sessionModelsData.configOptionsSettled === true` signal
already read in this file (line 126) and/or a non-empty `configOptions`.
- Keys that come from the current agent profile snapshot / matched profile
(`session.agent_profile_snapshot.config_options`, profile `configOptions`)
remain required as today; only the persisted-runtime-only keys become
non-blocking. This preserves the existing legacy-agent and flat-model-list
exceptions (lines 124-131).
Net effect: for the resumed Cursor session, `effort`/`thinking` (persisted-only,
not advertised, catalog settled) no longer block, `configHydrated` becomes
`true`, and the selector renders from the agent's live advertised options.
### State / API client
No store, hook, or API-client change. `sessionModelsData` and its
`configOptions` / `configOptionsSettled` fields already exist.
---
## Tests
- **Backend unit (replay filter):** a table-driven test on
`applyRuntimeSessionLayers` (or a focused test on the shared
`sanitizeRuntimeConfigOptionsWithCatalog` usage from the runtime path) proving
that given persisted options `{reasoning, fast, effort, thinking}` and an agent
catalog advertising `{reasoning, fast, context}`, only `reasoning` and `fast`
are sent and `effort`/`thinking` are not in the returned `failed` slice. Real
failure (advertised key, `SetConfigOption` returns error) still lands in
`failed`. File: `apps/backend/internal/agent/runtime/lifecycle/session_test.go`
(new test in a new file if the existing one is near the 800-line limit).
- **Frontend unit (gate):** extend the existing `requiredConfigKeys` /
`hasCompleteDynamicConfig` tests to assert that a persisted-only, unadvertised
key with a settled agent catalog yields `configHydrated=true`, while a key that
the agent profile snapshot requires and the agent has not advertised still
yields `false`. File: the existing model-selector test
(`apps/web/components/task/model-selector.test.ts` or `.test.tsx` if present;
otherwise add beside the component per repo test conventions).
No integration or E2E is planned: the observable outcome is unit-testable on both
sides, and the spec adds no new contract. (If a reviewer wants end-to-end proof,
an E2E resuming a session with stale metadata could be added later; not required
for this repair.)
---
## Verification Results
Both tasks complete.
- **task-01 (backend):** `applyRuntimeSessionLayers` now filters via
`sanitizeRuntimeConfigOptionsWithCatalog(runtimeConfigOptions, execution.GetModelState())`.
New regression test `TestApplyRuntimeSessionLayersFiltersUnadvertisedOptions`
(`session_runtime_layers_test.go`). `go test ./internal/agent/runtime/lifecycle/`
→ ok; changed-file `golangci-lint` → 0 issues; `gofmt` clean.
- **task-02 (frontend):** `hasCompleteDynamicConfig` no longer requires
persisted-runtime-only keys the current agent does not advertise once the
catalog is settled. Extended `model-selector.test.ts` (26 passed);
`typecheck` clean; `lint` 0 warnings; `i18n:ratchet` clean.
---
## Implementation Waves And Parallel Candidates
```
Wave 1 (parallel candidates — user authorization required; disjoint Go vs TS):
- [x] [task-01-backend-replay-catalog-filter](task-01-backend-replay-catalog-filter.md)
- [x] [task-02-frontend-selector-gate](task-02-frontend-selector-gate.md)
```
Default is sequential execution in the primary conversation. The two tasks touch
no shared file, schema, migration, generated contract, or lockfile, so they are
parallel-safe if the user explicitly authorizes parallel work.
## Open Questions
- None.
@@ -0,0 +1,114 @@
---
id: "01-backend-replay-catalog-filter"
title: "Backend: filter runtime config replay against current agent catalog"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/session-config-cross-agent-reconcile/spec.md"
parallelism: sequential
---
# Task 01: Backend replay catalog filter
Filter persisted runtime config options against the currently running agent's
advertised option catalog on the startup/resume replay path, so options a prior
agent wrote are neither applied nor reported as startup failures.
## Context / root cause
`applyRuntimeSessionLayers`
(`apps/backend/internal/agent/runtime/lifecycle/session.go:602-651`) sanitizes
persisted options with `profileconfig.SanitizeConfigOptions`, which only strips
`model`/`mode`/blank keys. Prior-agent keys (`effort`, `thinking`) survive, are
sent via `SetConfigOption`, fail, and land in `failed` -> user warning
"Some session settings could not be applied at startup: effort, thinking."
The catalog-aware helper already exists and is used on the reset/restore path:
`sanitizeRuntimeConfigOptionsWithCatalog(options, execution.GetModelState())`
(`apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go:627-648`,
called at line 696). Both files are in package `lifecycle`.
## Steps (TDD)
1. Set `status: in_progress`.
2. Write the regression test FIRST and confirm it fails for the expected reason
(stale keys reach `SetConfigOption` / appear in `failed`).
3. In `applyRuntimeSessionLayers`, replace the line
`sanitizedOptions := profileconfig.SanitizeConfigOptions(runtimeConfigOptions)`
(session.go:638) with
`sanitizedOptions := sanitizeRuntimeConfigOptionsWithCatalog(runtimeConfigOptions, execution.GetModelState())`.
Leave the `SetConfigOption` loop, `failed` handling, and `applyProfileSessionLayers`
unchanged.
4. Run the targeted test; confirm it passes.
5. Reconcile files-touched, set `status: done`, update `plan.md` checkbox and
`## Verification Results`.
## Acceptance
- Given persisted options `{reasoning, fast, effort, thinking}` and a current
agent catalog advertising `{reasoning, fast, context}`, replay sends only
`reasoning` and `fast`; `effort`/`thinking` are not sent and not in `failed`.
- Given an advertised key whose value the agent rejects, that key still appears
in `failed` (real failure preserved).
- Given a catalog that is not yet known (unsettled), behavior matches today's
restorable-set behavior (no regression for same-agent resume).
## Verification
```bash
cd apps/backend && go test -run TestApplyRuntimeSessionLayers ./internal/agent/runtime/lifecycle/...
```
If the new test uses a different name, substitute it in the `-run` filter. Also
run the changed-file linter before pushing (see backend AGENTS.md):
```bash
cd apps/backend && golangci-lint run ./internal/agent/runtime/lifecycle/... --new-from-rev="<base-sha>" --timeout=5m
```
## Files likely touched
- `apps/backend/internal/agent/runtime/lifecycle/session.go`
- `apps/backend/internal/agent/runtime/lifecycle/session_test.go` (new test;
put in a new `*_test.go` file if the existing one is near the 800-line limit)
## Dependencies
None.
## Parallelism
`parallel-safe` with task-02 (disjoint Go vs TS files). Default sequential.
## Inputs
- Spec: "Desired behavior" (Backend), "Failure modes", first and fourth scenarios.
- Plan: Backend section.
- Existing helper: `sanitizeRuntimeConfigOptionsWithCatalog`,
`capturedRuntimeConfigOptionCatalog`, `isRestorableRuntimeConfigOption`
(`manager_interaction.go:627-679`).
## Output contract
Summary, files changed, exact test command + result, blockers, risks, and
task/plan status update in the same conversation.
## Results
Done.
- Change: `applyRuntimeSessionLayers` (`session.go:638`) now calls
`sanitizeRuntimeConfigOptionsWithCatalog(runtimeConfigOptions, execution.GetModelState())`
in place of `profileconfig.SanitizeConfigOptions(runtimeConfigOptions)`. The
`profileconfig` import is retained (still used at `session.go:585` and `:794`).
- Test (TDD): added `session_runtime_layers_test.go` with
`TestApplyRuntimeSessionLayersFiltersUnadvertisedOptions`. Red first: stale
`effort`/`thinking` reached `SetConfigOption` and landed in `failed`
(`got [effort fast reasoning thinking]`). Green after fix.
- Commands:
- `go test -run TestApplyRuntimeSessionLayersFiltersUnadvertisedOptions ./internal/agent/runtime/lifecycle/` → ok
- `go test ./internal/agent/runtime/lifecycle/` → ok (69.9s, full package)
- `gofmt -l` on changed files → clean
- `golangci-lint run ./internal/agent/runtime/lifecycle/... --new-from-rev=1648cb8011b10cec0085c5fc3c2aea314404272e --timeout=5m` → 0 issues
- External side-effect boundaries: None (in-process WS mock only).
@@ -0,0 +1,121 @@
---
id: "02-frontend-selector-gate"
title: "Frontend: don't require persisted-only unadvertised keys in selector gate"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/session-config-cross-agent-reconcile/spec.md"
parallelism: sequential
---
# Task 02: Frontend model-selector render gate
Make the chat model/mode selector render for a resumed session even when the
session's persisted metadata records option keys the current agent does not
advertise, so `configHydrated` no longer stays `false` forever.
## Context / root cause
`hasCompleteDynamicConfig`
(`apps/web/components/task/model-selector.tsx:109-133`) requires every key from
`requiredConfigKeys` (lines 90-107), which includes keys read from
`session.metadata.runtime_config(_overrides).config_options`. When the current
agent never advertises a persisted key (e.g. `effort`, `thinking`), the
`required.every(...)` check never passes, `configHydrated=false`, and the render
gate at line 533 hides the selector permanently. Observed logs:
`requiredKeys=[context, reasoning, effort, fast, mode, model, thinking]`,
`rawConfigOptionIds=[mode, model, context, reasoning, fast]`, `willHide=true`.
## Steps (TDD)
1. Set `status: in_progress`.
2. Write/extend the regression test FIRST and confirm it fails: a session with a
persisted-only, unadvertised key plus a settled agent catalog currently gives
`hasCompleteDynamicConfig === false`.
3. Change the gate so a required key that is sourced ONLY from persisted
`runtime_config` / `runtime_config_overrides` (i.e. not from the agent profile
snapshot / matched profile) is treated as satisfied when the agent catalog is
settled (`sessionModelsData.configOptionsSettled === true` and/or non-empty
`configOptions`) and does not advertise that key. Preserve the existing
`AGENT_CONFIG_KEY` legacy exception and the `MODEL_CONFIG_KEY` flat-model-list
exception (lines 124-131). Keys required by the profile snapshot/matched
profile remain blocking.
- Suggested shape: split `requiredConfigKeys` conceptually into
profile-required keys (snapshot + matched profile) and persisted-only keys,
then in `hasCompleteDynamicConfig` require all profile-required keys as
today but only require a persisted-only key when the catalog is unsettled.
Keep the change minimal and within this file.
4. Run the targeted test; confirm it passes. Run typecheck and lint.
5. Reconcile files-touched, set `status: done`, update `plan.md` checkbox and
`## Verification Results`.
## Acceptance
- Given a settled agent catalog advertising `[mode, model, context, reasoning,
fast]` and persisted-only keys `effort`, `thinking`, then
`hasCompleteDynamicConfig` returns `true` (selector renders).
- Given the agent profile snapshot requires a key the agent has NOT advertised
and the catalog is settled, `hasCompleteDynamicConfig` still returns `false`.
- Given an unsettled catalog, behavior is unchanged from today.
## Verification
```bash
cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- components/task/model-selector.test.ts
cd apps/web && pnpm run typecheck
cd apps && pnpm --filter @kandev/web lint
```
If the test file is `.test.tsx` or lives elsewhere, substitute the correct path.
## Files likely touched
- `apps/web/components/task/model-selector.tsx`
- The existing model-selector unit test
(`apps/web/components/task/model-selector.test.ts(x)`); create beside the
component if none exists, following repo frontend test conventions.
## Dependencies
None.
## Parallelism
`parallel-safe` with task-01 (disjoint TS vs Go files). Default sequential.
## Inputs
- Spec: "What" (selector rendering bullet), "Desired behavior" (Frontend),
second and third scenarios.
- Plan: Frontend section.
- Existing gate/exception logic: `model-selector.tsx:56-133`.
## Output contract
Summary, files changed, exact test/typecheck/lint commands + results, blockers,
risks, and task/plan status update in the same conversation.
## Results
Done.
- Change (`model-selector.tsx`): split `requiredConfigKeys` into two helpers,
`profileRequiredConfigKeys` (snapshot + matched profile) and
`persistedRuntimeConfigKeys` (runtime_config/_overrides). `hasCompleteDynamicConfig`
now treats a required key as satisfied when it is a persisted-runtime-only key
the current agent does not advertise once the catalog is settled
(`isPersistedOnlyStaleKey`). The `AGENT_CONFIG_KEY` is explicitly excluded from
the new exception so the existing legacy-agent semantics keep owning that key;
the flat-model-list and legacy-agent exceptions are otherwise unchanged.
- Test (TDD): added `describe("cross-agent persisted config reconciliation")` in
`model-selector.test.ts` (3 cases). Red first: the persisted-only-unadvertised
settled-catalog case returned `false` (`expected false to be true`). Green
after fix. Extracted `modelConfigOption` / `selectOption` / `catalogEntry`
helpers to keep the block under the 100-line function limit.
- Commands:
- `pnpm vitest run components/task/model-selector.test.ts` → 26 passed
- `pnpm run typecheck` → clean
- `pnpm --filter @kandev/web lint` → 0 warnings
- `pnpm run i18n:ratchet` → clean
- External side-effect boundaries: None (pure function unit test).
@@ -0,0 +1,137 @@
---
status: implemented
created: 2026-08-19
owner: kandev
---
# Session Config Reconciliation Across Agent Types
## Why
A task session persists its runtime configuration options globally (model, mode,
and provider-defined options like reasoning effort). When a session that ran
earlier under one agent type is resumed under a different agent type, options
that only the earlier agent understood are replayed onto the new agent. The new
agent rejects them, the user sees a startup warning, and the model/mode selector
disappears from the chat input, leaving the user unable to change model or mode
for the resumed session.
## What
- On session startup and resume, Kandev SHALL only apply persisted runtime
config options that the currently running agent advertises. Options the
current agent does not advertise are dropped, not sent to the agent.
- Dropping an unsupported option SHALL NOT be surfaced to the user as a failure
warning. The startup config warning is reserved for options the agent *does*
advertise but fails to apply.
- Persisted options that the current agent still advertises SHALL continue to be
applied unchanged, including provider-defined options such as reasoning
effort, context, and fast.
- The chat model/mode selector SHALL render for a resumed session whenever the
current agent has reported its model list and config options, even if the
session's persisted metadata still records option keys the current agent does
not advertise. Un-advertised persisted keys SHALL NOT be treated as required
for the selector to render.
- Switching model within a single agent (where the agent re-advertises a
different option set for the new model) continues to work through the existing
live `session.models_updated` path and is unchanged by this repair.
## Broken behavior (regression being fixed)
1. Backend replay path `applyRuntimeSessionLayers`
(`apps/backend/internal/agent/runtime/lifecycle/session.go`) sanitizes
persisted options only with `profileconfig.SanitizeConfigOptions`, which
strips `model`/`mode`/blank keys but keeps every other key. Keys from a prior
agent (e.g. `effort`, `thinking`) are sent via `SetConfigOption`, the agent
errors, and each failed key is collected into the `failed` list.
2. The orchestrator emits "Some session settings could not be applied at
startup: effort, thinking." from that `failed` list
(`apps/backend/internal/orchestrator/event_handlers_streaming.go`).
3. The frontend gate `hasCompleteDynamicConfig`
(`apps/web/components/task/model-selector.tsx`) treats every key in
`session.metadata.runtime_config(_overrides).config_options` as required
(`requiredConfigKeys`). The current agent never advertises `effort`/`thinking`,
so the gate never passes, `configHydrated` stays `false`, and the selector is
hidden permanently.
## Desired behavior
- Backend: the startup/resume replay filters persisted runtime config options
against the current agent's advertised option catalog before sending them, so
unsupported keys are neither applied nor reported as failures. The catalog is
the same information used by the existing `sanitizeRuntimeConfigOptionsWithCatalog`
reconciliation on the reset/restore path.
- Frontend: `requiredConfigKeys` / `hasCompleteDynamicConfig` do not require a
persisted key that the current agent's advertised config options
(`sessionModelsData.configOptions`) do not include. The selector renders from
the agent's live advertised options.
## Data model
No schema change. Existing session metadata keys are unchanged:
- `runtime_config` -> `SessionRuntimeConfig{ model, mode, config_options }`
- `runtime_config_overrides` -> same shape
`config_options` remains a flat `map[string]string` of `{optionId: value}`.
Stale keys already written to metadata are tolerated (filtered on read/apply),
not required to be migrated away.
## API surface
No new HTTP/WS contract. The existing `session.models_updated` WS event and the
existing startup config-warning message are reused. The warning message content
changes only in that it no longer lists keys the current agent never advertised.
## Failure modes
- **Agent config catalog not yet settled at replay time:** if the current
agent's advertised option catalog is unknown when replay runs, the backend
MUST fail safe by not sending options it cannot verify are supported, rather
than sending all of them. It does not emit a spurious warning for options it
chose not to send.
- **Agent advertises the option but rejects the value:** this remains a real
failure and is still reported in the startup warning (unchanged).
- **Persisted metadata retains stale keys:** tolerated. The frontend gate and
backend replay both ignore keys the current agent does not advertise.
## Persistence guarantees
- No change to what survives a restart. Persisted `runtime_config` and
`runtime_config_overrides` are unchanged on disk.
- This repair does not rewrite or delete persisted stale keys; it makes read and
apply paths resilient to them. Any future cleanup of persisted stale keys is
out of scope.
## Scenarios
- **GIVEN** a session whose persisted `runtime_config.config_options` contains
`effort` and `thinking` (written by a prior agent) plus `model`, `reasoning`,
and `fast`, **WHEN** the session is resumed under an agent that advertises only
`mode, model, context, reasoning, fast`, **THEN** the backend applies
`reasoning` and `fast`, does not call `SetConfigOption` for `effort` or
`thinking`, and emits no "could not be applied at startup" warning for them.
- **GIVEN** the same resumed session, **WHEN** the frontend evaluates the
model-selector gate after the agent reports its config options, **THEN**
`configHydrated` is `true` and the model/mode selector renders.
- **GIVEN** a resumed session whose persisted options are all advertised by the
current agent, **WHEN** the session resumes, **THEN** all persisted options are
applied exactly as today and the selector renders (no behavior change).
- **GIVEN** a resumed session where the agent advertises `reasoning` but rejects
the persisted `reasoning` value, **WHEN** replay runs, **THEN** `reasoning`
still appears in the startup warning (real failure preserved).
- **GIVEN** a running session under one agent, **WHEN** the user switches model
and the agent re-advertises a different option set, **THEN** the live
`session.models_updated` path updates the selector as it does today (unchanged).
## Out of scope
- Migrating or deleting stale option keys already persisted in session metadata.
- Changing how provider option catalogs are discovered or the sessionless
resolver in `docs/specs/agents/dynamic-provider-options.md`.
- Per-model nesting of persisted config options.
- Any change to the live in-session `session.models_updated` protocol.
## Open questions
- None.