fix(agent-presets): project selection and refresh client catalogs
This commit is contained in:
1 parent
822d735356
commit
b8dfa8b892
25 files changed
+333
-168
No files matched your search
@@ -150,9 +150,18 @@ async function livePreset(baseUrl: string): Promise<string | undefined> {
|
||||
}),
|
||||
})
|
||||
const body = await response.json() as {
|
||||
result: { value?: { items: { sessionId: string; agentPreset?: string }[] } }
|
||||
result: {
|
||||
value?: {
|
||||
items: {
|
||||
sessionId: string
|
||||
projections?: { values: { agentPreset?: string | null } }
|
||||
}[]
|
||||
}
|
||||
}
|
||||
}
|
||||
return body.result.value?.items.find(item => item.sessionId !== SEED_ID)?.agentPreset
|
||||
const preset = body.result.value?.items.find(item => item.sessionId !== SEED_ID)
|
||||
?.projections?.values.agentPreset
|
||||
return typeof preset === 'string' ? preset : undefined
|
||||
}
|
||||
|
||||
/** Every option label the trigger menu currently lists. */
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
@@ -66,6 +67,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:^",
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-cli
|
||||
import { IconAgentPresetOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the header actions).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets/types'
|
||||
import type { AgentPresetSettingsState } from './settings-store.ts'
|
||||
import { presetDisplayText } from './locales.ts'
|
||||
import css from './AgentPresetLabel.module.css'
|
||||
@@ -42,7 +43,10 @@ export type AgentPresetLabelProps =
|
||||
export function AgentPresetLabel({
|
||||
sessionId, useSessions, useAgentPresets, load, t,
|
||||
}: AgentPresetLabelProps) {
|
||||
const preset = useSessions(state => state.byId[sessionId]?.agentPreset)
|
||||
const preset = useSessions((state) => {
|
||||
const value = state.byId[sessionId]?.projectionValues?.agentPreset
|
||||
return typeof value === 'string' ? value : undefined
|
||||
})
|
||||
const options = useAgentPresets(state => state.options)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -34,7 +34,6 @@ import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx'
|
||||
import { AgentPresetSection } from './AgentPresetSection.tsx'
|
||||
import type { AgentPresetSectionInjected } from './AgentPresetSection.tsx'
|
||||
import { AgentPresetSeatController } from './seat-store.ts'
|
||||
import type { SeatSessionSummary } from './seat-store.ts'
|
||||
import { AgentPresetSectionController } from './section-store.ts'
|
||||
import { en, zh } from './locales.ts'
|
||||
import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts'
|
||||
@@ -43,7 +42,7 @@ export type { AgentPresetLabelInjected, AgentPresetLabelProps } from './AgentPre
|
||||
export type { AgentPresetRowInjected, AgentPresetRowProps } from './AgentPresetRow.tsx'
|
||||
export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx'
|
||||
export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx'
|
||||
export type { AgentPresetSeatState, SeatSessionSummary } from './seat-store.ts'
|
||||
export type { AgentPresetSeatState } from './seat-store.ts'
|
||||
export {
|
||||
draftBlocker, type AgentPresetSectionState, type CopyDraft, type PresetRow, type PresetView,
|
||||
} from './section-store.ts'
|
||||
@@ -106,18 +105,9 @@ export function apply(ctx: ClientContext): void {
|
||||
// staged choice belongs to the flow rather than to any one session.
|
||||
ctx.inject(['slots', 'conversation', 'sessions', 'uiWorkspace'], (scope: ClientContext) => {
|
||||
const api = (scope.get('connection') as ConnectionHandle).api
|
||||
const seat = new AgentPresetSeatController(api, (): SeatSessionSummary | undefined => {
|
||||
const seat = new AgentPresetSeatController(api, () => {
|
||||
const state = scope.sessions.list.getSnapshot()
|
||||
const summary = state.current === undefined ? undefined : state.byId[state.current]
|
||||
return summary === undefined
|
||||
? undefined
|
||||
: {
|
||||
id: summary.id,
|
||||
blank: summary.blank,
|
||||
...summary.agentPreset === undefined ? {} : { agentPreset: summary.agentPreset },
|
||||
}
|
||||
}, (sessionId, agentPreset) => {
|
||||
scope.sessions.noteAgentPreset(sessionId as never, agentPreset)
|
||||
return state.current === undefined ? undefined : state.byId[state.current]
|
||||
})
|
||||
|
||||
const seatInjected = (): AgentPresetSeatInjected => ({
|
||||
@@ -146,11 +136,6 @@ export function apply(ctx: ClientContext): void {
|
||||
if (ns !== AGENT_PRESET_SETTINGS_NS) return
|
||||
void seat.load()
|
||||
})
|
||||
// Every tab folds the committed preset into the shared session row; the
|
||||
// initiating tab may already have applied the RPC echo, which is idempotent.
|
||||
const presetSelected = scope.remote.$on('agent-preset/selected', (sessionId, agentPreset) => {
|
||||
scope.sessions.noteAgentPreset(sessionId, agentPreset)
|
||||
})
|
||||
// Authoring writes a FILE, not a setting, so nothing on the wire
|
||||
// announces it — without this the screen that starts the next session
|
||||
// keeps offering the roster as it stood when the chip first loaded, and
|
||||
@@ -183,7 +168,6 @@ export function apply(ctx: ClientContext): void {
|
||||
return () => {
|
||||
stop()
|
||||
settingsMoved()
|
||||
presetSelected()
|
||||
rosterReaders.delete(readRoster)
|
||||
creatorDraft = undefined
|
||||
chip()
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
*/
|
||||
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets/types'
|
||||
import { messageOf, presetOptions } from './settings-store.ts'
|
||||
import type { AgentPresetOption } from './settings-store.ts'
|
||||
|
||||
@@ -37,16 +38,6 @@ const INITIAL: AgentPresetSeatState = {
|
||||
options: [], current: '', error: null, busy: false, introduce: false,
|
||||
}
|
||||
|
||||
/** One session's identity and whether it has started. */
|
||||
export interface SeatSessionSummary {
|
||||
/** The session the chip would apply its staged choice to. */
|
||||
id: SessionId
|
||||
/** False once a turn has run — applying is refused from then on. */
|
||||
blank: boolean
|
||||
/** The preset the session already runs, when the summary reports one. */
|
||||
agentPreset?: string
|
||||
}
|
||||
|
||||
/** Stages the next session's preset and applies it when one appears. */
|
||||
export class AgentPresetSeatController {
|
||||
/** Chip snapshot the renderer subscribes to. */
|
||||
@@ -64,13 +55,10 @@ export class AgentPresetSeatController {
|
||||
constructor(
|
||||
private readonly api: Pick<IApiClient, 'agentPresets'>,
|
||||
/** The session the hero is about to hand over to, when there is one. */
|
||||
private readonly currentSession: () => SeatSessionSummary | undefined,
|
||||
/**
|
||||
* Publish an applied switch into the session list, so the header label
|
||||
* moves with the composition instead of waiting for the next full list
|
||||
* refresh. Optional: a harness that renders no list omits it.
|
||||
*/
|
||||
private readonly onApplied?: (sessionId: string, agentPreset: string) => void,
|
||||
private readonly currentSession: () => Pick<
|
||||
SessionSummary,
|
||||
'id' | 'blank' | 'projectionValues'
|
||||
> | undefined,
|
||||
) {}
|
||||
|
||||
private set(patch: Partial<AgentPresetSeatState>): void {
|
||||
@@ -90,6 +78,7 @@ export class AgentPresetSeatController {
|
||||
}
|
||||
const { presets } = response.result.value
|
||||
this.fallback = presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? ''
|
||||
const session = this.currentSession()
|
||||
this.set({
|
||||
options: presetOptions(presets),
|
||||
// Staged pick first, then the composition the current session
|
||||
@@ -98,7 +87,7 @@ export class AgentPresetSeatController {
|
||||
// an applied stage was consumed — the chip mounts (and loads) only
|
||||
// once the flow's session is current, so the reply can arrive after
|
||||
// apply() already composed it.
|
||||
current: this.staged ?? this.currentSession()?.agentPreset ?? this.fallback,
|
||||
current: this.staged ?? (session === undefined ? this.fallback : presetOf(session) ?? ''),
|
||||
error: null,
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -150,10 +139,15 @@ export class AgentPresetSeatController {
|
||||
async apply(): Promise<void> {
|
||||
const staged = this.staged
|
||||
const session = this.currentSession()
|
||||
if (staged === undefined || session === undefined) return
|
||||
if (staged === undefined) {
|
||||
const current = session === undefined ? this.fallback : presetOf(session) ?? ''
|
||||
if (current !== this.store.getSnapshot().current) this.set({ current })
|
||||
return
|
||||
}
|
||||
if (session === undefined) return
|
||||
// A started session's history was produced under its own composition; the
|
||||
// host refuses the swap, so the stage is no longer meaningful.
|
||||
if (!session.blank || session.agentPreset === staged) {
|
||||
if (!session.blank || presetOf(session) === staged) {
|
||||
this.staged = undefined
|
||||
return
|
||||
}
|
||||
@@ -162,15 +156,29 @@ export class AgentPresetSeatController {
|
||||
const response = await this.api.agentPresets.select({ sessionId: session.id, agentPreset: staged })
|
||||
this.staged = undefined
|
||||
if (!response.result.ok) {
|
||||
this.set({ busy: false, error: response.result.error.message, current: this.fallback })
|
||||
this.set({
|
||||
busy: false,
|
||||
error: response.result.error.message,
|
||||
current: presetOf(session) ?? '',
|
||||
})
|
||||
return
|
||||
}
|
||||
// Consumed: the next new session opens on the deployment default again.
|
||||
this.set({ busy: false, current: response.result.value.agentPreset })
|
||||
this.onApplied?.(session.id, response.result.value.agentPreset)
|
||||
} catch (error) {
|
||||
this.staged = undefined
|
||||
this.set({ busy: false, error: messageOf(error), current: this.fallback })
|
||||
this.set({
|
||||
busy: false,
|
||||
error: messageOf(error),
|
||||
current: presetOf(session) ?? '',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function presetOf(
|
||||
session: Pick<SessionSummary, 'projectionValues'> | undefined,
|
||||
): string | undefined {
|
||||
const value = session?.projectionValues?.agentPreset
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client'
|
||||
import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx'
|
||||
@@ -21,6 +22,7 @@ import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx'
|
||||
import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSection.tsx'
|
||||
import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx'
|
||||
import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx'
|
||||
import { AgentPresetSeatController } from '../src/client/seat-store.ts'
|
||||
|
||||
// These specs assert the shipped Chinese copy. The lane has no jsdom `window`,
|
||||
// so browser-language detection never runs and a fresh LocaleRuntime opens on
|
||||
@@ -155,7 +157,11 @@ function uiWorkspaceDouble() {
|
||||
/** A sessions double whose list can be moved and whose changes are pushed. */
|
||||
function sessionsDouble(state: {
|
||||
current?: string
|
||||
byId: Record<string, { id: string; blank: boolean; agentPreset?: string }>
|
||||
byId: Record<string, {
|
||||
id: string
|
||||
blank: boolean
|
||||
projectionValues?: { agentPreset?: string | null }
|
||||
}>
|
||||
}) {
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
@@ -166,12 +172,6 @@ function sessionsDouble(state: {
|
||||
return () => listeners.delete(fn)
|
||||
},
|
||||
},
|
||||
noteAgentPreset: (sessionId: string, agentPreset: string) => {
|
||||
const summary = state.byId[sessionId]
|
||||
if (summary === undefined || summary.agentPreset === agentPreset) return
|
||||
summary.agentPreset = agentPreset
|
||||
for (const fn of listeners) fn()
|
||||
},
|
||||
/** Push a list change the way the runtime's store does. */
|
||||
notify: () => { for (const fn of listeners) fn() },
|
||||
}
|
||||
@@ -354,24 +354,6 @@ describe('ui-agent-preset apply', () => {
|
||||
conversation()
|
||||
})
|
||||
|
||||
it('folds a remote preset commit into the shared session row', async () => {
|
||||
const { ctx, slots, remote } = await bench()
|
||||
declareRoot(slots)
|
||||
declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
const state = {
|
||||
current: 's1',
|
||||
byId: { s1: { id: 's1', blank: true, agentPreset: 'standard' } },
|
||||
}
|
||||
ctx.provide('sessions', sessionsDouble(state) as never)
|
||||
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
|
||||
|
||||
remote.emit('agent-preset/selected', ['s1', 'minimal'])
|
||||
|
||||
expect(state.byId.s1.agentPreset).toBe('minimal')
|
||||
})
|
||||
|
||||
it('offers a just-authored preset on the new-session chip', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
@@ -409,7 +391,11 @@ describe('ui-agent-preset apply', () => {
|
||||
ctx.provide('conversation', {} as never)
|
||||
const state: {
|
||||
current?: string
|
||||
byId: Record<string, { id: string; blank: boolean; agentPreset?: string }>
|
||||
byId: Record<string, {
|
||||
id: string
|
||||
blank: boolean
|
||||
projectionValues?: { agentPreset?: string | null }
|
||||
}>
|
||||
} = { byId: {} }
|
||||
const sessions = sessionsDouble(state)
|
||||
ctx.provide('sessions', sessions as never)
|
||||
@@ -424,7 +410,9 @@ describe('ui-agent-preset apply', () => {
|
||||
expect(calls).not.toContain('select:minimal')
|
||||
|
||||
state.current = 's1'
|
||||
state.byId['s1'] = { id: 's1', blank: true, agentPreset: 'standard' }
|
||||
state.byId['s1'] = {
|
||||
id: 's1', blank: true, projectionValues: { agentPreset: 'standard' },
|
||||
}
|
||||
sessions.notify()
|
||||
|
||||
// Connecting a workspace produced the session; the stage reaches it there.
|
||||
@@ -461,7 +449,9 @@ describe('ui-agent-preset apply', () => {
|
||||
ctx.provide('conversation', {} as never)
|
||||
const state = {
|
||||
current: 's1',
|
||||
byId: { s1: { id: 's1', blank: true, agentPreset: 'standard' } },
|
||||
byId: {
|
||||
s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'standard' } },
|
||||
},
|
||||
}
|
||||
const sessions = sessionsDouble(state)
|
||||
ctx.provide('sessions', sessions as never)
|
||||
@@ -543,7 +533,11 @@ describe('ui-agent-preset apply', () => {
|
||||
ctx.provide('conversation', {} as never)
|
||||
const state: {
|
||||
current?: string
|
||||
byId: Record<string, { id: string; blank: boolean; agentPreset?: string }>
|
||||
byId: Record<string, {
|
||||
id: string
|
||||
blank: boolean
|
||||
projectionValues?: { agentPreset?: string | null }
|
||||
}>
|
||||
} = { byId: {} }
|
||||
const sessions = sessionsDouble(state)
|
||||
ctx.provide('sessions', sessions as never)
|
||||
@@ -562,7 +556,9 @@ describe('ui-agent-preset apply', () => {
|
||||
// The chip mounts with the flow's session, so its roster load can land
|
||||
// AFTER the stage was consumed; the session's own composition is what
|
||||
// the display must keep — not the deployment default.
|
||||
state.byId['s1'] = { id: 's1', blank: true, agentPreset: 'cordis' }
|
||||
state.byId['s1'] = {
|
||||
id: 's1', blank: true, projectionValues: { agentPreset: 'cordis' },
|
||||
}
|
||||
await seat.load()
|
||||
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
|
||||
@@ -581,3 +577,48 @@ describe('ui-agent-preset apply', () => {
|
||||
expect(section.startCreatorDraft).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentPresetSeatController reconciliation', () => {
|
||||
it('uses the deployment default without a Session and clears it for an uncomposed Session', async () => {
|
||||
const state: { current?: { id: SessionId; blank: boolean } } = {}
|
||||
const controller = new AgentPresetSeatController({
|
||||
agentPresets: {
|
||||
list: () => Promise.resolve(ROSTER_ONE),
|
||||
},
|
||||
} as never, () => state.current)
|
||||
|
||||
await controller.load()
|
||||
await controller.apply()
|
||||
expect(controller.store.getSnapshot().current).toBe('standard')
|
||||
|
||||
state.current = { id: SessionId('uncomposed'), blank: true }
|
||||
await controller.apply()
|
||||
expect(controller.store.getSnapshot().current).toBe('')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'RPC rejection',
|
||||
select: () => Promise.resolve({
|
||||
rpcId: 'r',
|
||||
result: { ok: false as const, error: { code: 'failed', message: 'selection rejected', details: {} } },
|
||||
}),
|
||||
message: 'selection rejected',
|
||||
},
|
||||
{
|
||||
name: 'transport failure',
|
||||
select: () => Promise.reject(new Error('transport failed')),
|
||||
message: 'transport failed',
|
||||
},
|
||||
])('restores an empty current value after $name for an uncomposed Session', async ({ select, message }) => {
|
||||
const controller = new AgentPresetSeatController({
|
||||
agentPresets: { select },
|
||||
} as never, () => ({ id: SessionId('uncomposed'), blank: true }))
|
||||
|
||||
await controller.select('minimal')
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
busy: false, current: '', error: message,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -71,7 +71,7 @@ function renderSeat(state: Partial<AgentPresetSeatState> = {}) {
|
||||
}
|
||||
|
||||
function renderLabel(
|
||||
summary: { blank: boolean; agentPreset?: string } | undefined,
|
||||
summary: { blank: boolean; projectionValues?: { agentPreset?: string | null } } | undefined,
|
||||
roster: Partial<AgentPresetSettingsState> = {},
|
||||
) {
|
||||
// The chip and the label read the same roster, metadata included.
|
||||
@@ -367,7 +367,10 @@ describe('the chip introduce cue', () => {
|
||||
|
||||
describe('the session-header label', () => {
|
||||
it('names the preset the session runs, and never offers a switch', async () => {
|
||||
const { load } = renderLabel({ blank: false, agentPreset: 'standard' })
|
||||
const { load } = renderLabel({
|
||||
blank: false,
|
||||
projectionValues: { agentPreset: 'standard' },
|
||||
})
|
||||
|
||||
await waitFor(() => { expect(load).toHaveBeenCalledTimes(1) })
|
||||
// A control here would promise a switch the host refuses outright.
|
||||
@@ -376,13 +379,16 @@ describe('the session-header label', () => {
|
||||
})
|
||||
|
||||
it('falls back to the id, and to the generic hint, when metadata is absent', () => {
|
||||
renderLabel({ blank: true, agentPreset: 'mine' })
|
||||
renderLabel({ blank: true, projectionValues: { agentPreset: 'mine' } })
|
||||
|
||||
expect(screen.getByTitle(en.headerHint).textContent).toBe('mine')
|
||||
})
|
||||
|
||||
it('shows the id until the roster resolves it', () => {
|
||||
renderLabel({ blank: false, agentPreset: 'standard' }, { options: [] })
|
||||
renderLabel({
|
||||
blank: false,
|
||||
projectionValues: { agentPreset: 'standard' },
|
||||
}, { options: [] })
|
||||
|
||||
// The session's own summary is the authority on which preset it runs; the
|
||||
// roster only supplies the display name, and its arrival is a later frame.
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf,
|
||||
} from '../src/client/settings-store.ts'
|
||||
@@ -17,7 +19,8 @@ function derivedController(api: IApiClient) {
|
||||
return new AgentPresetSettingsController(api, new SettingsDescribeMirror(api))
|
||||
}
|
||||
import { AgentPresetSeatController } from '../src/client/seat-store.ts'
|
||||
import type { SeatSessionSummary } from '../src/client/seat-store.ts'
|
||||
|
||||
type SeatSession = Pick<SessionSummary, 'id' | 'blank' | 'projectionValues'>
|
||||
|
||||
interface Recorded { ns: string; patch: unknown }
|
||||
|
||||
@@ -245,7 +248,7 @@ describe('the new-session chip controller', () => {
|
||||
/** A chip over a current session the test can move. */
|
||||
function chip(
|
||||
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
|
||||
current: { id: string; blank: boolean; agentPreset?: string } | undefined,
|
||||
current: SeatSession | undefined | (() => SeatSession | undefined),
|
||||
options: { writes?: Recorded[]; failSelect?: string; failList?: string; throwOn?: 'list' | 'select' } = {},
|
||||
): AgentPresetSeatController {
|
||||
const api = {
|
||||
@@ -265,7 +268,10 @@ describe('the new-session chip controller', () => {
|
||||
},
|
||||
},
|
||||
} as unknown as IApiClient
|
||||
return new AgentPresetSeatController(api, () => current as SeatSessionSummary | undefined)
|
||||
return new AgentPresetSeatController(
|
||||
api,
|
||||
typeof current === 'function' ? current : () => current,
|
||||
)
|
||||
}
|
||||
|
||||
const ROSTER: { id: string; trust: 'system' | 'user'; isDefault: boolean }[] = [
|
||||
@@ -331,9 +337,32 @@ describe('the new-session chip controller', () => {
|
||||
expect(controller.store.getSnapshot().current).toBe('minimal')
|
||||
})
|
||||
|
||||
it('replaces the default display when an existing blank session arrives after roster load', async () => {
|
||||
const state: { current?: SeatSession } = {}
|
||||
const controller = chip([
|
||||
{ id: 'standard', trust: 'system', isDefault: false },
|
||||
{ id: 'minimal', trust: 'system', isDefault: true },
|
||||
], () => state.current)
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot().current).toBe('minimal')
|
||||
|
||||
state.current = {
|
||||
id: 's1' as SessionId,
|
||||
blank: true,
|
||||
projectionValues: { agentPreset: 'standard' },
|
||||
}
|
||||
await controller.apply()
|
||||
|
||||
expect(controller.store.getSnapshot().current).toBe('standard')
|
||||
})
|
||||
|
||||
it('applies the stage to the blank session the flow lands on', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const current = { id: 's1', blank: true, agentPreset: 'standard' }
|
||||
const current = {
|
||||
id: 's1' as SessionId,
|
||||
blank: true,
|
||||
projectionValues: { agentPreset: 'standard' },
|
||||
}
|
||||
const controller = chip(ROSTER, current, { writes })
|
||||
await controller.load()
|
||||
await controller.select('minimal')
|
||||
@@ -344,7 +373,11 @@ describe('the new-session chip controller', () => {
|
||||
|
||||
it('spends the stage exactly once', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { writes })
|
||||
const controller = chip(ROSTER, {
|
||||
id: 's1' as SessionId,
|
||||
blank: true,
|
||||
projectionValues: { agentPreset: 'standard' },
|
||||
}, { writes })
|
||||
await controller.load()
|
||||
await controller.select('minimal')
|
||||
|
||||
@@ -358,7 +391,11 @@ describe('the new-session chip controller', () => {
|
||||
|
||||
it('drops the stage against a session that already started', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = chip(ROSTER, { id: 's1', blank: false, agentPreset: 'standard' }, { writes })
|
||||
const controller = chip(ROSTER, {
|
||||
id: 's1' as SessionId,
|
||||
blank: false,
|
||||
projectionValues: { agentPreset: 'standard' },
|
||||
}, { writes })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
@@ -369,7 +406,11 @@ describe('the new-session chip controller', () => {
|
||||
|
||||
it('drops the stage when the session already runs it', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'minimal' }, { writes })
|
||||
const controller = chip(ROSTER, {
|
||||
id: 's1' as SessionId,
|
||||
blank: true,
|
||||
projectionValues: { agentPreset: 'minimal' },
|
||||
}, { writes })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
@@ -379,7 +420,14 @@ describe('the new-session chip controller', () => {
|
||||
|
||||
it('falls back to the default when the host refuses the switch', async () => {
|
||||
const controller = chip(
|
||||
ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { failSelect: 'already started' })
|
||||
ROSTER,
|
||||
{
|
||||
id: 's1' as SessionId,
|
||||
blank: true,
|
||||
projectionValues: { agentPreset: 'standard' },
|
||||
},
|
||||
{ failSelect: 'already started' },
|
||||
)
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
@@ -391,7 +439,14 @@ describe('the new-session chip controller', () => {
|
||||
|
||||
it('falls back to the default when the switch never reaches the host', async () => {
|
||||
const controller = chip(
|
||||
ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { throwOn: 'select' })
|
||||
ROSTER,
|
||||
{
|
||||
id: 's1' as SessionId,
|
||||
blank: true,
|
||||
projectionValues: { agentPreset: 'standard' },
|
||||
},
|
||||
{ throwOn: 'select' },
|
||||
)
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
@@ -402,7 +457,11 @@ describe('the new-session chip controller', () => {
|
||||
|
||||
it('ignores a pick while a switch is in flight', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { writes })
|
||||
const controller = chip(ROSTER, {
|
||||
id: 's1' as SessionId,
|
||||
blank: true,
|
||||
projectionValues: { agentPreset: 'standard' },
|
||||
}, { writes })
|
||||
await controller.load()
|
||||
|
||||
const first = controller.select('minimal')
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
{
|
||||
"path": "../../api/session-controller/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../preset/agent-presets"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
}
|
||||
|
||||
@@ -62,6 +62,18 @@ export class CommandDirectory {
|
||||
for (const key of this.entries.keys()) void this.refresh(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop one Session's obsolete composition-specific snapshot and prewarm its replacement.
|
||||
* @param sessionId - Session whose effective command composition changed.
|
||||
*/
|
||||
resetSession(sessionId: SessionId): void {
|
||||
const entry = this.entry(sessionId)
|
||||
entry.state = 'cold'
|
||||
entry.commands = []
|
||||
entry.lastError = undefined
|
||||
void this.refresh(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard reset on reconnect: every entry drops its snapshot (the agent world
|
||||
* may have changed shape across the generation) and prewarms.
|
||||
|
||||
@@ -156,10 +156,9 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
|
||||
}), 'command: slash source')
|
||||
ctx.remote.$on('commands/change', () => { this.directory.invalidateAll() })
|
||||
// A preset switch changes which commands one session's agent resolves and
|
||||
// registers nothing globally, so the registry-wide signal above never
|
||||
// fires for it: repull that key alone, soft, so the old snapshot serves
|
||||
// the menu until the new one lands.
|
||||
ctx.remote.$on('agent-preset/selected', (sessionId) => { void this.directory.refresh(sessionId) })
|
||||
// registers nothing globally. Drop that key's old composition before
|
||||
// prewarming so a newly opened menu waits for the replacement catalog.
|
||||
ctx.remote.$on('agent-preset/selected', (sessionId) => { this.directory.resetSession(sessionId) })
|
||||
ctx.on('connection/reset', () => { this.directory.resetConnected() })
|
||||
}
|
||||
|
||||
|
||||
@@ -187,6 +187,30 @@ describe('resetConnected (reconnect hard)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetSession (preset-change hard)', () => {
|
||||
it('drops and prewarms only the changed Session', async () => {
|
||||
const { dir, pull, countOf } = bench()
|
||||
const first = dir.refresh(S1)
|
||||
const second = dir.refresh(S2)
|
||||
pull(S1, 0).resolve(CMDS)
|
||||
pull(S2, 0).resolve(S2_CMDS)
|
||||
await Promise.all([first, second])
|
||||
|
||||
dir.resetSession(S1)
|
||||
expect(dir.status(S1)).toBe('pending')
|
||||
expect(dir.resolve(S1, 'plan')).toBeUndefined()
|
||||
expect(dir.status(S2)).toBe('ready')
|
||||
expect(dir.resolve(S2, 'attach')).toBeDefined()
|
||||
expect(countOf(S1)).toBe(2)
|
||||
expect(countOf(S2)).toBe(1)
|
||||
|
||||
pull(S1, 1).resolve([{ name: 'fresh', description: 'new composition' }])
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(dir.resolve(S1, 'fresh')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('warm', () => {
|
||||
it('launches a pull from cold, again after failure, and never over pending/ready', async () => {
|
||||
const { dir, pull, countOf } = bench()
|
||||
|
||||
@@ -766,7 +766,7 @@ describe('directory invalidation events', () => {
|
||||
expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('agent-preset/selected repulls the recomposed session and leaves the others served', async () => {
|
||||
it('agent-preset/selected drops and repulls the recomposed session while leaving others served', async () => {
|
||||
const rounds = new Map<SessionId, number>()
|
||||
const { source, warm, remote } = await bench({
|
||||
commands: (payload) => {
|
||||
@@ -784,6 +784,8 @@ describe('directory invalidation events', () => {
|
||||
// A preset switch changes which commands one session's agent resolves;
|
||||
// every other session keeps the catalog its own composition serves.
|
||||
remote.emit('agent-preset/selected', [sid('s1'), 'minimal'])
|
||||
expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
|
||||
expect(source.matchSpace!(proj('s2'), '/goal')).not.toBeUndefined()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
|
||||
expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
|
||||
|
||||
@@ -364,7 +364,17 @@ export class InputTriggerController {
|
||||
/** Wire one source's lexicon invalidation channel into refresh (hookless or roll-less sources never notify). */
|
||||
private watchLexicon(source: InputTriggerSource, projection: ClientSessionContext): void {
|
||||
if (source.lexicon === undefined || source.subscribeLexicon === undefined) return
|
||||
this.lexiconOffs.set(source, source.subscribeLexicon(projection, () => { this.refreshLexicon() }))
|
||||
this.lexiconOffs.set(source, source.subscribeLexicon(projection, () => {
|
||||
this.refreshLexicon()
|
||||
const hit = this.hit
|
||||
if (hit === null || !this.menu.getSnapshot().open || hit.trigger !== source.trigger) return
|
||||
// Let every source process the same invalidation before rebuilding the
|
||||
// open menu, so one source cannot contribute its previous catalog.
|
||||
void Promise.resolve().then(() => {
|
||||
if (this.disposed || this.hit !== hit || !this.menu.getSnapshot().open) return
|
||||
this.fetchCandidates(hit, this.deps.roster.sources(hit.trigger))
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/** Launch the candidate fetch for one hit generation, superseding the previous one. */
|
||||
|
||||
@@ -623,13 +623,13 @@ describe('lexicon', () => {
|
||||
expect(rolls.has('@')).toBe(false)
|
||||
})
|
||||
|
||||
it('a source lexicon notification republishes the aggregated store', () => {
|
||||
let roll: readonly string[] | undefined = undefined
|
||||
it('a source lexicon notification republishes the roll and refreshes an open menu', async () => {
|
||||
let roll: readonly string[] | undefined = ['old']
|
||||
let notify: (() => void) | undefined
|
||||
const source: InputTriggerSource = {
|
||||
trigger: '/',
|
||||
name: 'skill',
|
||||
candidates: () => Promise.resolve([]),
|
||||
candidates: () => Promise.resolve((roll ?? []).map(name => ({ name }))),
|
||||
onPick: () => undefined,
|
||||
lexicon: () => roll,
|
||||
subscribeLexicon: (_session, listener) => {
|
||||
@@ -638,12 +638,18 @@ describe('lexicon', () => {
|
||||
},
|
||||
}
|
||||
const { controller } = controllerBench([source])
|
||||
expect(controller.lexicon.getSnapshot().size).toBe(0)
|
||||
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['old'])
|
||||
controller.track('/', 1, { tier: 'plain' }, 1)
|
||||
await tick()
|
||||
expect(controller.menu.getSnapshot().groups[0]?.items).toEqual([{ name: 'old' }])
|
||||
const seen: number[] = []
|
||||
controller.lexicon.subscribe(() => { seen.push(controller.lexicon.getSnapshot().size) })
|
||||
roll = ['commit-helper']
|
||||
notify?.()
|
||||
await tick()
|
||||
await tick()
|
||||
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['commit-helper'])
|
||||
expect(controller.menu.getSnapshot().groups[0]?.items).toEqual([{ name: 'commit-helper' }])
|
||||
expect(seen).toEqual([1])
|
||||
controller.dispose()
|
||||
expect(notify).toBeUndefined()
|
||||
|
||||
@@ -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: 3e92f02518c5a36412c9448a26d32958c217f79c
|
||||
README.zh.md: 4600589749a063df924f9c961cc449506ba2af9f
|
||||
README.md: 1c943f0d56a720fbb3225371ed8161bfeb07b677
|
||||
README.zh.md: 6f348be04092eee87da74490dbcf73831e53215e
|
||||
@@ -40,7 +40,7 @@ The child records the joined id on its own durable header ([`dsh-subagent`](../.
|
||||
|
||||
### 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 creation header names the preset a session STARTED with; the `agentPreset` Session projection 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 — consumes that projection rather than reading the header or folding the log independently.
|
||||
|
||||
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. The service re-emits that committed fact as the non-scoped cordis event `agent-preset/selected(sessionId, agentPreset)` declared by the client-safe `./types` export, allowing remote consumers to invalidate session-derived state without importing Host runtime types. 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.
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ subagent 的子 agent 通过 `composeFrom()` 加入其父方的常驻组装,
|
||||
|
||||
### 会话实际运行的是哪个 preset
|
||||
|
||||
创建头部记录的是会话**以什么开始**,`resolveSessionPreset(session)` 给出的才是它**实际运行的**。空白会话一旦切换过,两者就不同,因此所有重建路径——选择器读取的摘要、resume、fork——都走解析,而非直接读头部。
|
||||
创建头部记录的是会话**以什么开始**,`agentPreset` Session projection 记录的才是它**实际运行的**。空白会话一旦切换过,两者就不同,因此所有重建路径——选择器读取的摘要、resume、fork——都消费该 projection,而非直接读头部或各自重新归约日志。
|
||||
|
||||
头部保持冻结,因为它是创建期事实。切换以 `agent-preset/selected` 会话事件记录,在替换提交之后追加;这正是 model-visible ⟺ logged 规则的要求:preset 决定模型看到的工具 schema 与提示词段落,因此必须能从日志重建。服务会把这项已提交事实重新发为不带 scope 的 cordis 事件 `agent-preset/selected(sessionId, agentPreset)`,其声明位于 client-safe 的 `./types` 出口,使远端消费方无需导入 Host 运行时类型即可让会话派生状态失效。只读头部会让切换过的会话按创建时的组装重建,从而重放新工具集无法执行的历史——这正是「仅空白可切」那道锁要防的危险。
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"@deepseek-ai/dsh-home-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
@@ -53,7 +54,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"js-yaml": "^4.1.0",
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
"@deepseek-ai/schemastery": "workspace:^",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-include": "workspace:^",
|
||||
@@ -66,6 +68,7 @@
|
||||
"@deepseek-ai/dsh-home-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings-file": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
|
||||
@@ -27,6 +27,7 @@ import z from '@deepseek-ai/schemastery'
|
||||
import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope'
|
||||
// Type-only: resolves the `agent/created` lifecycle event this service watches.
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
// Type-only: resolves the registry notification emitted after scope reparenting.
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings'
|
||||
@@ -36,7 +37,8 @@ import { copyComposition, deleteComposition, readComposition } from './authoring
|
||||
import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts'
|
||||
import { PresetExistsError } from './authoring.ts'
|
||||
import { PresetMountError, UnknownPresetError, type AgentPreset, type Config, type PresetRoot } from './preset.ts'
|
||||
import type {} from './types.ts'
|
||||
import { agentPresetProjectionDefinition } from './session.ts'
|
||||
export type * from './types.ts'
|
||||
|
||||
/** Settings namespace carrying the user's chosen default preset. */
|
||||
export const SETTINGS_NAMESPACE = 'agent-presets'
|
||||
@@ -64,7 +66,7 @@ export {
|
||||
copyComposition, deleteComposition, InvalidPresetIdError, PresetExistsError,
|
||||
PresetNotWritableError, readComposition, writableRoot,
|
||||
} from './authoring.ts'
|
||||
export { resolveSessionPreset, type PresetBearingSession } from './session.ts'
|
||||
export { agentPresetProjectionDefinition } from './session.ts'
|
||||
export { PresetMountError, UnknownPresetError } from './preset.ts'
|
||||
export type { AgentPreset, Config, PresetRoot, PresetTrust } from './preset.ts'
|
||||
|
||||
@@ -158,6 +160,10 @@ export class AgentPresets extends Service {
|
||||
}, 'agentPresets.settings()')
|
||||
})
|
||||
|
||||
ctx.inject(['sessionProjections'], (projectionCtx) => {
|
||||
projectionCtx.sessionProjections.register(agentPresetProjectionDefinition)
|
||||
})
|
||||
|
||||
// Advisory, not fatal: a synchronous `agent/created` listener that throws
|
||||
// VETOES publication, and this service must not, because composing an agent
|
||||
// outside the roster is legal — `recompose` binds exactly such a bare agent
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
* 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.
|
||||
* Reconstruction reads the `agentPreset` Session projection, never the header
|
||||
* alone.
|
||||
* @module @deepseek-ai/dsh-agent-presets/session
|
||||
*/
|
||||
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
import { z } from 'zod'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session/types' {
|
||||
interface SessionEventMap {
|
||||
@@ -27,28 +29,16 @@ declare module '@deepseek-ai/dsh-session/types' {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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[]
|
||||
}
|
||||
const agentPresetSchema = z.union([z.string(), z.null()])
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
/** Current Session preset, initialized from its header and advanced by selection events. */
|
||||
export const agentPresetProjectionDefinition = {
|
||||
key: 'agentPreset',
|
||||
stateSchema: agentPresetSchema,
|
||||
init: header => header.agentPreset ?? null,
|
||||
apply: (state, event) => event.type === 'agent-preset/selected'
|
||||
? event.data.agentPreset
|
||||
: state,
|
||||
wire: { viewSchema: agentPresetSchema, view: state => state },
|
||||
stateVersion: 1,
|
||||
} satisfies ProjectionDefinition<'agentPreset', string | null>
|
||||
@@ -1,6 +1,16 @@
|
||||
/** Client-safe event declarations owned by the agent-preset domain. */
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionStateMap {
|
||||
agentPreset: string | null
|
||||
}
|
||||
interface SessionProjectionMap {
|
||||
/** Preset the Session runs, or null when the deployment composes none. */
|
||||
agentPreset: string | null
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
/**
|
||||
* Which preset a session ran is a question about its LOG, not its header: the
|
||||
* header records the creation-time choice, and a switch made during the blank
|
||||
* window is an event. Every reconstruction — the list row, the header label,
|
||||
* resume, fork — goes through this resolver, so a resolver that read the header
|
||||
* alone would rebuild a switched session under a composition its own history
|
||||
* contradicts.
|
||||
*/
|
||||
/** The Session projection that records which preset a Session runs. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { resolveSessionPreset } from '../src/session.ts'
|
||||
import { agentPresetProjectionDefinition } from '../src/session.ts'
|
||||
|
||||
/** A header carrying the creation-time preset, if any. */
|
||||
function header(agentPreset?: string): SessionHeader {
|
||||
@@ -28,35 +21,24 @@ function selected(agentPreset: string, seq: number): SessionEvent {
|
||||
return { type: 'agent-preset/selected', seq, time: seq, data: { agentPreset } }
|
||||
}
|
||||
|
||||
describe('resolving which preset a session ran', () => {
|
||||
it('reads the creation-time value when nothing was switched', () => {
|
||||
expect(resolveSessionPreset({ header: header('standard'), events: [] })).toBe('standard')
|
||||
describe('agent preset selection projection', () => {
|
||||
it('starts from the creation header, including no configured preset', () => {
|
||||
expect(agentPresetProjectionDefinition.init(header('standard'))).toBe('standard')
|
||||
expect(agentPresetProjectionDefinition.init(header())).toBeNull()
|
||||
})
|
||||
|
||||
it('prefers a logged switch over the header', () => {
|
||||
// The switch's effect outlives the blank window it was made in: the turns
|
||||
// that follow run under the newer composition.
|
||||
expect(resolveSessionPreset({ header: header('standard'), events: [selected('minimal', 0)] }))
|
||||
.toBe('minimal')
|
||||
})
|
||||
it('starts from the header and keeps the latest selected preset', () => {
|
||||
const definition = agentPresetProjectionDefinition
|
||||
let state = definition.init(header('standard'))
|
||||
expect(state).toBe('standard')
|
||||
|
||||
it('takes the last switch when a session was moved twice', () => {
|
||||
expect(resolveSessionPreset({
|
||||
header: header('standard'),
|
||||
events: [selected('minimal', 0), selected('cordis', 1)],
|
||||
})).toBe('cordis')
|
||||
})
|
||||
state = definition.apply(state, selected('minimal', 0))
|
||||
state = definition.apply(state, {
|
||||
type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } },
|
||||
})
|
||||
state = definition.apply(state, selected('cordis', 2))
|
||||
|
||||
it('finds a switch behind later events', () => {
|
||||
const later = { type: 'turn/end', seq: 2, time: 2, data: { turn: 1 } } as SessionEvent
|
||||
|
||||
expect(resolveSessionPreset({ header: header(), events: [selected('minimal', 0), later] }))
|
||||
.toBe('minimal')
|
||||
})
|
||||
|
||||
it('reports none when the deployment composes no presets', () => {
|
||||
// A valid deployment: every session shares the host composition, and no
|
||||
// surface should invent a preset name for it.
|
||||
expect(resolveSessionPreset({ header: header(), events: [] })).toBeUndefined()
|
||||
expect(definition.wire.view(state)).toBe('cordis')
|
||||
expect(definition.stateSchema.parse(state)).toBe('cordis')
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import LlmRuntime from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
@@ -43,6 +44,7 @@ async function harness(
|
||||
ctx.loader.builtins.include = Include
|
||||
await ctx.plugin(LlmRuntime)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user