fix(preset): keep a blank Session that already runs the selected preset

The Creator entry names cordis in its session.create request, and its chip may
still select cordis when the Session list updates before the new projection
arrives. select recomposed and logged unconditionally, so that race appended a
second agent-preset/selected record and announced tools/change again. select
now leaves a blank Session that records the preset and runs its current
revision unchanged; a replaced definition still recomposes and logs. The
Creator Web scenario pins a single selection record.
This commit is contained in:
Yichen Jiang committed 2026-10-09 11:29:26 +08:00
1 parent 3c1e357132
commit 5087220bea
9 files changed
+63 -10

No files matched your search

+3 -2
View File
@@ -470,8 +470,9 @@ it.each([false, true])('starts Creator from Plugins with Coding Tools=%s without
expect(scaffold.ctx.agentPresets.composedPreset(creator.ctx)).toBe('cordis')
expect(scaffold.ctx.tools.schemas(creator).map(tool => tool.name))
.toEqual(expect.arrayContaining(['cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager']))
expect(creator.session.snapshotEvents().some(event => event.type === 'agent-preset/selected'
&& event.data.agentPreset === 'cordis')).toBe(true)
// The chip may also select Creator once the list updates; the registry records the switch only once.
expect(creator.session.snapshotEvents().filter(event => event.type === 'agent-preset/selected'
&& event.data.agentPreset === 'cordis')).toHaveLength(1)
expect(creator.session.snapshotEvents().some(event => event.type === 'user/message' || event.type === 'turn/start')).toBe(false)
expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: developerTools })
expect(scaffold.ctx.agentPresets.defaultId).toBe('standard')
+3 -1
View File
@@ -557,7 +557,9 @@ serviceFor<K extends string & keyof Context>(agent: { ctx: Context }, name: K):
*/
async recompose(ctx: Context, id: string): Promise<AgentPreset>
/** Select a preset before a session starts its first turn.
/** Select a preset before a session starts its first turn. A Session whose
* log records the preset and whose Agent runs its current revision keeps its
* composition; nothing is logged or announced.
* @param agent Target Agent.
* @param agentPreset Requested identity.
* @returns Committed preset identity.
+3 -1
View File
@@ -567,7 +567,9 @@ serviceFor<K extends string & keyof Context>(agent: { ctx: Context }, name: K):
*/
async recompose(ctx: Context, id: string): Promise<AgentPreset>
/** Select a preset before a session starts its first turn.
/** Select a preset before a session starts its first turn. A Session whose
* log records the preset and whose Agent runs its current revision keeps its
* composition; nothing is logged or announced.
* @param agent Target Agent.
* @param agentPreset Requested identity.
* @returns Committed preset identity.
@@ -203,7 +203,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: '@Remote(\'select\') async select(agent: Agent, agentPreset: string): Promise<string>',
description: 'Select a preset before a session starts its first turn.',
description: 'Select a preset before a session starts its first turn. A Session whose log records the preset and whose Agent runs its current revision keeps its composition; nothing is logged or announced.',
parameters: [{ name: 'agent', description: 'Target Agent.' }, { name: 'agentPreset', description: 'Requested identity.' }],
returns: 'Committed preset identity.',
},
@@ -21,8 +21,8 @@
en: cd28d9b4f46c46a8
zh: 4921d853f98469fe
/deepseek-ai-dsh-agent-preset-registry/understand-the-implementation:
en: 1d479971c0adc694
zh: abc84b7220c9b5d5
en: 7df811c1347b9a94
zh: 141a7feb5ca597fe
/deepseek-ai-dsh-agent-preset-registry/further-exploration:
en: bbae8d79844bc481
zh: 4302107fd1f460e6
@@ -57,7 +57,7 @@ Each declaration eagerly creates a registry-owned scope and an in-memory Loader
A preset service provider and its consumers must share one `cordis:group` isolation realm. Entry-local isolation does not propagate through the Agent registration scope: ordinary Context lookup outside the group, including `agent.ctx`, still selects the Host realm. `agentPresets.serviceFor()` explicitly inspects the retained composition; it does not change ordinary service lookup.
Activation auditing checks imports, missing services and globally leaked services. Import failures, activation failures and leaks reject the mount. A row waiting for a Host service stays mounted, and every read and binding re-audits it after the Host Loader tree settles, so startup order does not decide the outcome. Failure prevents new bindings to that definition. Session logs retain the preset ID and blank-session selections; recovery after restart uses the current definition of that ID and rejects a missing definition.
Activation auditing checks imports, missing services and globally leaked services. Import failures, activation failures and leaks reject the mount. A row waiting for a Host service stays mounted, and every read and binding re-audits it after the Host Loader tree settles, so startup order does not decide the outcome. Failure prevents new bindings to that definition. Session logs retain the preset ID and blank-session selections; recovery after restart uses the current definition of that ID and rejects a missing definition. `select` leaves a blank Session that records the requested preset and already runs its current revision unchanged, without another selection record or `tools/change`.
`inspectCompositions()` reads all revisions retained by this registry; passing an Agent context selects its exact revision, or returns an empty list when it has no binding here. Each `AgentPresetInspection` contains a preset ID, detached active-module references with their resolution bases, and leaked-service names. Inspectors query the runtime service rather than a module-local mount table; the results contain no Loader trees or fibers. [composition-inventory.ts](src/composition-inventory.ts) defines these records.
@@ -57,7 +57,7 @@ Web 内置定义来自 `dsh-web-app` bundle。定义使用普通插件行;注
preset 的服务提供方及其消费方必须共享同一个 `cordis:group` 隔离 realm。条目局部隔离不会沿 Agent 注册 scope 传播:组外的普通 Context 查找(包括 `agent.ctx`)仍选择 Host realm。`agentPresets.serviceFor()` 显式检查保留的组合,不改变普通服务查找。
激活审计检查导入失败、缺失服务和向全局泄漏的服务。导入失败、激活失败和泄漏会拒绝挂载。等待 Host 服务的行保持挂载,每次读取和绑定都在 Host Loader 树结算后重新审计,因此启动顺序不决定结果。失败只禁用该定义的新绑定。会话日志保存 preset ID 和空白会话的切换记录;重启恢复使用该 ID 的当前定义,缺失时拒绝恢复。
激活审计检查导入失败、缺失服务和向全局泄漏的服务。导入失败、激活失败和泄漏会拒绝挂载。等待 Host 服务的行保持挂载,每次读取和绑定都在 Host Loader 树结算后重新审计,因此启动顺序不决定结果。失败只禁用该定义的新绑定。会话日志保存 preset ID 和空白会话的切换记录;重启恢复使用该 ID 的当前定义,缺失时拒绝恢复。若空白会话已记录所请求的 preset 且正运行其当前版本,`select` 不做任何改动,也不追加切换记录或触发 `tools/change`。
`inspectCompositions()` 读取本注册表仍保留的全部代际;传入 Agent 上下文时只选择它绑定的精确代际,在此没有绑定时返回空列表。每个 `AgentPresetInspection` 包含 preset ID、纯数据形式的活动模块引用及解析基址,以及泄漏的服务名。检查器查询运行中的服务,而不是模块级挂载表;结果不包含 Loader 树或 fiber。这些记录定义在 [composition-inventory.ts](src/composition-inventory.ts) 中。
@@ -338,7 +338,9 @@ export class AgentPresetRegistry extends TypertRemoteService {
return preset
}
/** Select a preset before a session starts its first turn.
/** Select a preset before a session starts its first turn. A Session whose
* log records the preset and whose Agent runs its current revision keeps its
* composition; nothing is logged or announced.
* @param agent Target Agent.
* @param agentPreset Requested identity.
* @returns Committed preset identity.
@@ -350,6 +352,9 @@ export class AgentPresetRegistry extends TypertRemoteService {
if (boundary !== undefined && (boundary.openTurnStartSeq !== null || boundary.lastTurn > 0)) {
throw new RemoteError('agent-preset/locked', 'This session has already started', { sessionId: agent.id, agentPreset })
}
const current = this.definitions.get(agentPreset)?.generation
if (current !== undefined && this.generationFor(agent.ctx) === current
&& this.owner.sessionProjections.stateOf(agent.session, 'agentPreset') === agentPreset) return agentPreset
const preset = await this.recompose(agent.ctx, agentPreset)
agent.session.append('agent-preset/selected', { agentPreset: preset.id })
return preset.id
@@ -298,6 +298,49 @@ it('retains a newly acquired generation when a previous activation is replaced',
expect(ctx.agentPresets.inspectCompositions()).toHaveLength(0)
})
it('keeps a blank Session that already runs the current revision of the selected preset without logging', async () => {
const ctx = await setup()
await declare(ctx, contribution('standard'))
await declare(ctx, contribution('minimal'))
const agent = await agentOn(ctx, 'repeated')
expect(await ctx.agentPresets.select(agent, 'minimal')).toBe('minimal')
let notified = 0
ctx.on('tools/change', () => { notified++ })
expect(await ctx.agentPresets.select(agent, 'minimal')).toBe('minimal')
expect(agent.session.snapshotEvents().filter(event => event.type === 'agent-preset/selected')).toHaveLength(1)
expect(notified).toBe(0)
})
it('logs the default preset a blank Session runs once it is selected without a recorded preset', async () => {
const ctx = await setup()
await declare(ctx, contribution('standard'))
const agent = await agentOn(ctx, 'unrecorded')
expect(ctx.sessionProjections.stateOf(agent.session, 'agentPreset')).toBeNull()
expect(await ctx.agentPresets.select(agent, 'standard')).toBe('standard')
expect(ctx.sessionProjections.stateOf(agent.session, 'agentPreset')).toBe('standard')
})
it('recomposes and logs a repeated selection once the preset definition was replaced', async () => {
const ctx = await setup()
await declare(ctx, contribution('standard'))
const replaced = await declare(ctx, contribution('minimal'))
const agent = await agentOn(ctx, 'redefined')
await ctx.agentPresets.select(agent, 'minimal')
await replaced.dispose()
await declare(ctx, contribution('minimal'))
let notified = 0
ctx.on('tools/change', () => { notified++ })
expect(await ctx.agentPresets.select(agent, 'minimal')).toBe('minimal')
expect(agent.session.snapshotEvents().filter(event => event.type === 'agent-preset/selected')).toHaveLength(2)
expect(notified).toBeGreaterThan(0)
})
it('does not lose the durable selection when a tools observer rejects notification', async () => {
const ctx = await setup()
await declare(ctx, contribution('standard'))