diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 648a3f870e..ea7ee009d5 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -150,9 +150,18 @@ async function livePreset(baseUrl: string): Promise { }), }) 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. */ diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 4199ec7226..60a91df206 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -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:^", diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx index 06448f27a1..b40453e94a 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx @@ -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(() => { diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index d40b14ae00..28a50c034f 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -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() diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index 64e71fefb7..b16e803ac5 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -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, /** 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): 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 { 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 | undefined, +): string | undefined { + const value = session?.projectionValues?.agentPreset + return typeof value === 'string' ? value : undefined +} diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index e93570355e..f0215a0341 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -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 + byId: Record }) { 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 + byId: Record } = { 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 + byId: Record } = { 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, + }) + }) +}) diff --git a/packages/client/ui-agent-preset/tests/components.client.spec.tsx b/packages/client/ui-agent-preset/tests/components.client.spec.tsx index 29ecc33eb5..0339e43341 100644 --- a/packages/client/ui-agent-preset/tests/components.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/components.client.spec.tsx @@ -71,7 +71,7 @@ function renderSeat(state: Partial = {}) { } function renderLabel( - summary: { blank: boolean; agentPreset?: string } | undefined, + summary: { blank: boolean; projectionValues?: { agentPreset?: string | null } } | undefined, roster: Partial = {}, ) { // 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. diff --git a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts index 3bb310a97e..9d7baa26d1 100644 --- a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts @@ -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 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') diff --git a/packages/client/ui-agent-preset/tsconfig.json b/packages/client/ui-agent-preset/tsconfig.json index 0488aba5ee..3aa26af84e 100644 --- a/packages/client/ui-agent-preset/tsconfig.json +++ b/packages/client/ui-agent-preset/tsconfig.json @@ -50,6 +50,9 @@ { "path": "../../api/session-controller/tsconfig.client.json" }, + { + "path": "../../preset/agent-presets" + }, { "path": "../../core/session" } diff --git a/packages/client/ui-commands/src/client/directory.ts b/packages/client/ui-commands/src/client/directory.ts index 04d5eebb3b..10e3df8eab 100644 --- a/packages/client/ui-commands/src/client/directory.ts +++ b/packages/client/ui-commands/src/client/directory.ts @@ -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. diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index 3ae9b5d324..bef9f0c3bc 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/packages/client/ui-commands/src/client/service.ts @@ -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() }) } diff --git a/packages/client/ui-commands/tests/directory.client.spec.ts b/packages/client/ui-commands/tests/directory.client.spec.ts index 3f0b1b91df..29fc328fb2 100644 --- a/packages/client/ui-commands/tests/directory.client.spec.ts +++ b/packages/client/ui-commands/tests/directory.client.spec.ts @@ -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() diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index ffd2946b80..8aa633f158 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -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() 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() diff --git a/packages/client/ui-input-trigger/src/client/controller.ts b/packages/client/ui-input-trigger/src/client/controller.ts index 9a477a91e6..46279cf4fc 100644 --- a/packages/client/ui-input-trigger/src/client/controller.ts +++ b/packages/client/ui-input-trigger/src/client/controller.ts @@ -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. */ diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index b936e64c5f..ce68807800 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -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() diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index a4f3fed8d6..1ec6f36a14 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -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 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index 3e92f02518..1c943f0d56 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -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. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 4600589749..6f348be040 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -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 运行时类型即可让会话派生状态失效。只读头部会让切换过的会话按创建时的组装重建,从而重放新工具集无法执行的历史——这正是「仅空白可切」那道锁要防的危险。 diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index a20ab0a87c..15db5b3afd 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -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:^", diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 8f44e2003c..83516d5c4d 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -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 diff --git a/packages/preset/agent-presets/src/session.ts b/packages/preset/agent-presets/src/session.ts index ae3edada27..61df969967 100644 --- a/packages/preset/agent-presets/src/session.ts +++ b/packages/preset/agent-presets/src/session.ts @@ -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> diff --git a/packages/preset/agent-presets/src/types.ts b/packages/preset/agent-presets/src/types.ts index 77803355a1..a1e04c3d2f 100644 --- a/packages/preset/agent-presets/src/types.ts +++ b/packages/preset/agent-presets/src/types.ts @@ -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 { /** diff --git a/packages/preset/agent-presets/tests/session.spec.ts b/packages/preset/agent-presets/tests/session.spec.ts index d87c4d1937..6b6da3d540 100644 --- a/packages/preset/agent-presets/tests/session.spec.ts +++ b/packages/preset/agent-presets/tests/session.spec.ts @@ -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') }) }) diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts index 1c549f7874..9dc9d78d3c 100644 --- a/packages/preset/agent-presets/tests/settings.spec.ts +++ b/packages/preset/agent-presets/tests/settings.spec.ts @@ -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) diff --git a/packages/preset/agent-presets/tsconfig.json b/packages/preset/agent-presets/tsconfig.json index b40cf776ad..f42a158941 100644 --- a/packages/preset/agent-presets/tsconfig.json +++ b/packages/preset/agent-presets/tsconfig.json @@ -27,6 +27,9 @@ { "path": "../../core/session" }, + { + "path": "../../session/session-projection" + }, { "path": "../../core/system-prompt" },