fix(web): record which preset a session actually runs

The creation header names the preset a session STARTED with and is frozen,
which is correct — it is a creation fact. Switching is legal only while a
session is blank, and that looked like enough: no history exists yet.

It is not, because the switch's effect outlives the blank window. The user
switches, then sends the first message; every turn from there runs under the
new composition while the header still names the old one. The session is
then locked around a misrecorded preset, and resume reads the header to
rebuild it — composing one preset's tools over a history another produced,
which is exactly the replay the blank-only lock exists to prevent, reached
by another route. A picker showed `standard` for a session running
`core-web`.

A switch is now an `agent-preset/selected` event appended after the swap
commits, and `resolveSessionPreset()` (last selection, else the header) is
what every reconstruction reads: the summary, resume, the conflict guard,
and the fork introduced one layer down.
This commit is contained in:
Yichen Jiang
2026-08-05 15:46:44 +08:00
parent a2ab09003f
commit 98fbe0ee94
18 changed files with 192 additions and 44 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md
README.md: 5e785b747209d4c0cedaebbd3a90ba1b46dcd1c6
README.zh.md: 4c40d7b7bfabb83dba2859251ad189a2646170c6
README.md: b60d89b6dcda97a7570680072195231885fd0d45
README.zh.md: f2485663ada031a9e8fa5ce3a06327e6cbc1de10

View File

@@ -21,6 +21,12 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal
The agent factory's `setup(agentCtx)` hook is the one supported call site. Only there is the composition installed while the agent is still unpublished, so a rejected mount rolls the whole creation back rather than leaving a half-composed session. The subtree is owned by `agentCtx`'s fiber, so it unwinds with the agent and the caller receives no disposer.
### Which preset a session runs
The creation header names the preset a session STARTED with; `resolveSessionPreset(session)` names the one it RUNS. They differ whenever a blank session switched, so every reconstruction path — the summary a picker reads, a resume, a fork — resolves rather than reading the header.
The header stays frozen because it is a creation fact. A switch is an `agent-preset/selected` session event appended after the swap commits, which is what the model-visible ⟺ logged rule requires: the preset decides the tool schemas and prompt sections the model sees, so it has to be reconstructable from the log. Reading the header alone would rebuild a switched session under the composition it was created with, replaying history the new tool set cannot act on — the exact hazard the blank-only lock exists to prevent.
## Config
| Field | Default | Meaning |

View File

@@ -21,6 +21,12 @@
agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有在那里,组装是在 agent 尚未发布时装入的,因此挂载被拒绝会让整次创建回滚,而不会留下一个组装到一半的会话。子树归 `agentCtx` 的 fiber 所有,随 agent 一起卸载,调用方无需持有 disposer。
### 会话实际运行的是哪个 preset
创建头部记录的是会话**以什么开始**`resolveSessionPreset(session)` 给出的才是它**实际运行的**。空白会话一旦切换过两者就不同因此所有重建路径——选择器读取的摘要、resume、fork——都走解析而非直接读头部。
头部保持冻结,因为它是创建期事实。切换以 `agent-preset/selected` 会话事件记录,在替换提交之后追加;这正是 model-visible ⟺ logged 规则的要求preset 决定模型看到的工具 schema 与提示词段落,因此必须能从日志重建。只读头部会让切换过的会话按创建时的组装重建,从而重放新工具集无法执行的历史——这正是「仅空白可切」那道锁要防的危险。
## 配置
| 字段 | 默认值 | 含义 |

View File

@@ -30,6 +30,7 @@
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},

View File

@@ -37,6 +37,7 @@ export {
inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent,
unmountPresetFor, type PresetMount,
} from './mount.ts'
export { resolveSessionPreset, type PresetBearingSession } from './session.ts'
export { PresetMountError, UnknownPresetError } from './types.ts'
export type { AgentPreset, Config, PresetRoot, PresetTrust } from './types.ts'

View File

@@ -0,0 +1,54 @@
/**
* The session-log record of which preset a session actually runs.
*
* The creation header names the preset a session STARTED with, and it is
* deep-frozen because that is a creation fact. A session may still change
* preset while it is blank, and the effect of that change outlives the blank
* window: the first turn — and every turn after it — runs under the newly
* mounted composition. Recording the change is what keeps the log honest, and
* it is required outright by the repo's model-visible ⟺ logged rule, since the
* preset decides the tool schemas and prompt sections the model sees.
*
* Reconstruction reads {@link resolveSessionPreset}, never the header alone.
* @module @deepseek-ai/dsh-agent-presets/session
*/
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* The session's agent preset was chosen after creation, while the session
* was still blank. Log-only: it records the composition later turns ran
* under, so a resumed or forked session rebuilds the same one instead of
* the header's creation-time value.
*/
'agent-preset/selected': { agentPreset: string }
}
}
/** The minimum a caller must supply to resolve a session's preset. */
export interface PresetBearingSession {
/** The session's creation header. */
readonly header: SessionHeader
/** The session's event log, oldest first. */
readonly events: readonly SessionEvent[]
}
/**
* The preset a session actually runs, newest selection winning.
*
* The header supplies the creation-time value; every later selection is a
* logged event, so the last one is the answer. Reading the header alone
* rebuilds a switched session under the composition it was created with, not
* the one its history was produced under.
* @param session - the session's header and event log.
* @returns the preset id, or `undefined` when the deployment composes none.
*/
export function resolveSessionPreset(session: PresetBearingSession): string | undefined {
for (let index = session.events.length - 1; index >= 0; index -= 1) {
const event = session.events[index]
if (event?.type === 'agent-preset/selected') return event.data.agentPreset
}
return session.header.agentPreset
}

View File

@@ -21,6 +21,9 @@
{
"path": "../../core/scope"
},
{
"path": "../../core/session"
},
{
"path": "../../settings/settings"
},