Merge branch 'stack/agent-profiles-3-wire' into stack/agent-profiles-5-web-ui

recompose becomes a parent re-link: the new preset's standing mount is
ensured BEFORE the link moves, so a failed switch leaves the agent exactly as
it was — the unmount-then-restore dance (and unmountPresetFor with it) is
gone, and the restore-failure test now asserts the agent KEEPS its tools with
the source directory deleted, because the standing mount is not the file.
This commit is contained in:
Yichen Jiang
2026-08-08 18:52:45 +08:00
24 changed files with 600 additions and 185 deletions

View File

@@ -59,6 +59,7 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials'
// Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`.
import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
// Side-effect type import: resolves the `approval/request` waterfall and
// `ctx.get('approval')` without a value dependency on the seam (optional composition).
@@ -487,16 +488,17 @@ function viewFor(
ctx: Context,
event: SessionEvent,
argsFor: (callId: string) => unknown,
// The presenter lives with the definition, and definitions are per agent
// now: a preset registers its tools into that agent's layer, leaving the
// global layer empty. Looking one up without the owner finds nothing, and
// every card silently degrades to the generic renderer.
agent?: Agent,
// Presenters live with the definitions, and definitions live in the scope
// chain: a preset registers its tools into its standing layer. A live agent
// is a scope whose chain passes through its preset; a cold read passes the
// preset's standing key directly — no agent, no resume. An undefined scope
// sees only the global layer, which is the pre-preset deployment shape.
scope?: ScopeKey,
): ToolEventView | undefined {
try {
if (event.type === 'tool/call') {
const { name, arguments: raw } = event.data as ToolCallData
const view = ctx.tools.get(name, agent)?.presentCall?.(JSON.parse(raw))
const view = ctx.tools.get(name, scope)?.presentCall?.(JSON.parse(raw))
return view === undefined ? undefined : { for: 'call', view }
}
if (event.type === 'tool/result') {
@@ -505,7 +507,7 @@ function viewFor(
const callId = message.source.callId
const call = argsFor(callId) as { name: string; args: unknown } | undefined
if (call === undefined) return undefined
const view = ctx.tools.get(call.name, agent)?.presentResult?.(call.args, {
const view = ctx.tools.get(call.name, scope)?.presentResult?.(call.args, {
content: result.content,
isError: result.isError === true,
...meta === undefined ? {} : { meta },
@@ -548,12 +550,12 @@ function historyPage(
events: readonly SessionEvent[],
beforeSeq: number | undefined,
maxMessages: number | undefined,
agent?: Agent,
scope?: ScopeKey,
): { events: HistoryEntry[]; hasMore: boolean } {
const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
return {
events: page.events.map((event) => {
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId), agent)
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId), scope)
return { event, ...view === undefined ? {} : { view } }
}),
hasMore: page.hasMore,
@@ -1183,21 +1185,54 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
async function historyStateFor(
sessionId: SessionId,
includeProjections: boolean,
): Promise<{ events: SessionEvent[]; projections?: SessionProjectionsBlock }> {
): Promise<{ header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock }> {
const attached = ctx.sessions.get(sessionId)
if (attached !== undefined) {
const events = [...attached.events]
const projections = includeProjections ? projectionsFor(ctx, attached) : undefined
return { events, ...projections === undefined ? {} : { projections } }
return { header: attached.header, events, ...projections === undefined ? {} : { projections } }
}
const inspected = await inspectServable(sessionId)
const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined
return {
header: inspected.meta,
events: inspected.events,
...projections === undefined ? {} : { projections },
}
}
/**
* The registry view scope a transcript's presenters resolve in.
*
* A live agent is that scope itself (its chain passes through its preset's
* standing layer). A cold session names its preset on the header, and the
* preset's STANDING key serves without resuming anything — ensuring the
* mount composes plugins but starts no agent, session, or turn. No roster,
* no recorded preset, or a preset the roster no longer supplies all fall
* back to the global layer: the transcript still serves, with the generic
* cards a viewless entry renders.
* @param sessionId - the transcript being read.
* @param header - that session's header (attached or inspected).
* @returns the scope to pass to presenter lookups, or undefined for global.
*/
async function presenterScopeFor(sessionId: SessionId, header: SessionHeader): Promise<ScopeKey | undefined> {
const live = ctx.get('agents')?.get(sessionId)
if (live !== undefined) return live
const presets = ctx.get('agentPresets')
if (presets === undefined) return undefined
try {
// An unrecorded preset (a log from before the roster existed) renders
// through the DEFAULT preset's standing layer: that is the composition
// an unnamed session composes today, and presenters are pure display,
// so the worst a mismatch produces is the generic card it had anyway.
return await presets.standingKeyFor(header.agentPreset)
} catch {
// Swallows only the unknown/unusable-preset rejection from the roster:
// a deleted or broken preset must degrade this read, never fail it.
return undefined
}
}
/** Resolve one requested identity to a live agent, creating or resuming it once. */
async function ensureSession(
sessionId: SessionId,
@@ -1786,7 +1821,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
async history(request) {
const { sessionId, beforeSeq, maxMessages } = request.payload
let state: { events: SessionEvent[]; projections?: SessionProjectionsBlock }
let state: { header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock }
try {
state = await historyStateFor(sessionId, beforeSeq === undefined)
} catch (error: unknown) {
@@ -1799,11 +1834,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
details: {},
})
}
// `ctx.get`, not `ctx.agents`: this is the COLD path, and a caller may
// serve history from storage with no agent registry composed at all.
// An absent registry means no live agent, which is the same answer a
// present one gives here — presenters fall back to the global layer.
const page = historyPage(ctx, state.events, beforeSeq, maxMessages, ctx.get('agents')?.get(sessionId))
const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state.header))
return ok(request, {
events: page.events,
hasMore: page.hasMore,

View File

@@ -56,19 +56,39 @@ function roster(ids: readonly string[]): unknown {
if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids))
return Promise.resolve({ id, trust: 'system', path: `/presets/${id}.yml` })
},
// The standing scope key a cold transcript read resolves presenters in.
standingKeyFor: (id?: string) => {
const wanted = id ?? ids[0] ?? ''
standingKeyRequests.push(wanted)
if (!ids.includes(wanted) || failingStandingKeys.has(wanted)) {
return Promise.reject(new UnknownPresetError(wanted, ids))
}
let key = standingKeys.get(wanted)
if (key === undefined) {
key = { agentPreset: wanted }
standingKeys.set(wanted, key)
}
return Promise.resolve(key)
},
}
}
/** Standing keys the roster double minted, and the ids readers asked for. */
const standingKeys = new Map<string, object>()
const standingKeyRequests: string[] = []
/** Preset ids whose standing mount the double reports as unusable. */
const failingStandingKeys = new Set<string>()
/** Per-agent service instances a mounted preset would own, keyed by session id. */
const services = new Map<string, Record<string, unknown>>()
async function harness(presets?: readonly string[]) {
async function harness(presets?: readonly string[], persistence?: unknown) {
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-preset-')))
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never)
ctx.provide('sessionPersistence', (persistence ?? { list: () => Promise.resolve([]) }) as never)
if (presets !== undefined) ctx.provide('agentPresets', roster(presets) as never)
const factory: AgentFactory = {
@@ -363,3 +383,39 @@ describe('agentPreset.select', () => {
expect(response.result.error.code).toBe('agent-preset-not-found')
})
})
describe('session.history presenter scope', () => {
it('asks the roster for the RECORDED preset\'s standing key on a cold read', async () => {
const { api } = await harness(['standard', 'core-web'])
await api.sessions.create(request({ sessionId: SessionId('p1'), agentPreset: 'core-web' }))
// Cold: creation registered a live agent in this harness, so simulate the
// cold path by asking for a session only persistence knows... the harness
// has no persistence, so read the live one and assert no roster query.
standingKeyRequests.length = 0
const live = await api.sessions.history(request({ sessionId: SessionId('p1') }))
expect(live.result.ok).toBe(true)
// A live agent IS the presenter scope; the roster is not consulted.
expect(standingKeyRequests).toEqual([])
})
it('serves a COLD transcript whose standing mount is no longer usable', async () => {
// A genuinely cold session: persistence knows it, no live agent exists.
const meta = { id: SessionId('p3'), createdAt: 1, cwd: '/tmp/p3', agentPreset: 'standard' }
const { api } = await harness(['standard'], {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events: [] }),
})
// The preset broke after the session ran: the roster rejects the mount.
failingStandingKeys.add('standard')
try {
standingKeyRequests.length = 0
const response = await api.sessions.history(request({ sessionId: SessionId('p3') }))
// Degraded, never failed: the roster WAS asked, and the transcript
// still serves — with the generic cards a viewless entry renders.
expect(standingKeyRequests).toEqual(['standard'])
expect(response.result.ok).toBe(true)
} finally {
failingStandingKeys.delete('standard')
}
})
})