fix(web): resume the preset the log records, and serialize the switch

Six review findings on the select surface, all reachable from the wire:

**Resume read the header, not the log.** The switch was recorded as
`agent-preset/selected` and every projection resolved from it, but `agentFor`
still composed from `inspected.meta.agentPreset` — the value written once at
creation. A blank session that switched and then ran turns came back after a
restart under the ORIGINAL preset, restoring that history under the tool set it
was not produced with, which is the mismatch this feature exists to prevent.
`inspected` already carries the events.

**Cold summaries dropped the preset entirely.** `summarizeCold` hand-copied
three header fields and omitted the fourth, so a restored session reported no
preset and the picker showed the deployment default. It now uses the same
projection the attached path does.

**`select` had no gate.** Two concurrent selects both passed the blank check;
the second `unmountPresetFor` then found no record, because the first had
already removed it, and both mounts installed into one agent layer. Selects on
one session now queue, and the blank check is re-read inside the queue. This is
not turn admission — a `session.prompt` racing a switch is the agent loop's to
reserve — but it closes the select-versus-select tear-down.

**A same-id restore was skipped.** The roster is a live directory, so "the same
inputs that worked a moment ago" does not hold: a changed file is exactly how a
same-id reselect fails, and skipping the restore left the agent with no
composition at all.

**`writable` was dead state**, initialized true and never set, so the row could
never disable. It now carries `settings.describe`'s bit — a browser that may
not write settings sees the current default and no control, rather than one
whose write answers `settings-not-exposed`.

**`list` was documented as id-ordered.** It is root-precedence order with each
root's own presets sorted, first root to supply an id winning.
This commit is contained in:
Yichen Jiang
2026-08-07 11:45:10 +08:00
parent c4e76384f4
commit d099a24cb1
6 changed files with 159 additions and 44 deletions

View File

@@ -14,7 +14,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { RpcId, type RpcRequest } from '../src/api/rpc.ts'
import { UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
import { resolveSessionPreset, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
import { GoalId } from '@deepseek-ai/dsh-goal'
import { createApiProxy } from '../src/api-proxy.ts'
import { describe, expect, it } from 'vitest'
@@ -280,6 +280,46 @@ describe('agentPreset.select', () => {
expect(response.result.value.agentPreset).toBe('core-web')
})
it('records the switch in the log, and the list reads it back', async () => {
const { api, ctx } = await harness(['standard', 'core-web'])
await api.sessions.create(request({ sessionId: SessionId('sel-log'), agentPreset: 'standard' }))
await api.agentPresets.select(
request({ sessionId: SessionId('sel-log'), agentPreset: 'core-web' }))
// The header is written once at creation, so the switch lives in the log —
// this is what a restart replays and what every projection resolves from.
// Asserting only the RPC's echo would miss a switch that never persisted.
const session = ctx.sessions.get(SessionId('sel-log'))
if (session === undefined) throw new Error('unreachable')
expect(session.header.agentPreset).toBe('standard')
expect(resolveSessionPreset(session)).toBe('core-web')
const listed = await api.sessions.list(request({}))
if (!listed.result.ok) throw new Error('unreachable')
expect(listed.result.value.items.find(item => item.sessionId === 'sel-log')?.agentPreset)
.toBe('core-web')
})
it('serializes two concurrent selects on one session', async () => {
const { api, ctx } = await harness(['standard', 'core-web'])
await api.sessions.create(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' }))
// Both pass the blank check; unserialized, the second unmount finds no
// record because the first already removed it, and two compositions end up
// in one agent layer. The client's busy flag is not enforcement.
const [first, second] = await Promise.all([
api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'core-web' })),
api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })),
])
expect(first.result.ok).toBe(true)
expect(second.result.ok).toBe(true)
const session = ctx.sessions.get(SessionId('sel-race'))
if (session === undefined) throw new Error('unreachable')
// One winner, and the log agrees with it: the last committed switch.
expect(resolveSessionPreset(session)).toBe('standard')
})
it('refuses once the conversation has started', async () => {
const { api, ctx } = await harness(['standard', 'core-web'])
await api.sessions.create(request({ sessionId: SessionId('sel-2'), agentPreset: 'standard' }))