Merge branch 'stack/agent-profiles-4-settings' into stack/agent-profiles-5-web-ui

This commit is contained in:
Yichen Jiang
2026-08-07 15:04:30 +08:00
2 changed files with 50 additions and 24 deletions

View File

@@ -17,9 +17,4 @@ export function apply(ctx, config) {
order: 10, order: 10,
text: `section for ${config.tool}`, text: `section for ${config.tool}`,
})) }))
// Reconfiguring a live row runs the Loader's `internal/update` waterfall,
// which persists the owning tree. That is the trigger reaching the preset
// tree's `write` while the subtree is still mounted; tearing the agent down
// instead stops earlier, in the loader's own "tree is being disposed" case.
globalThis.__RECONFIGURE__ = tool => ctx.fiber.update({ ...config, tool })
} }

View File

@@ -218,29 +218,60 @@ describe('a roster with nothing in it', () => {
}) })
}) })
describe('attributing a service to a subtree', () => { describe('the preset file is an input, never a persistence target', () => {
it('never writes the preset file back, however the subtree changes', async () => { it('survives a row that disposes itself, which makes the Loader persist a tree', async () => {
delete (globalThis as { __RECONFIGURE__?: unknown }).__RECONFIGURE__ // The preset lives in a temp root, not under `fixtures/`: without the
const handle = await ctx.agents.create({ // `write()` override the Loader REWRITES the composition it read, so a
sessionId: SessionId('sess-write'), // committed fixture would be mutated by the very run that proves the bug
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), // and every later run would compare against the damaged file and pass.
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-write-'))
const dir = join(root, 'self-disposing')
await mkdir(dir)
const path = join(dir, COMPOSITION_FILE)
const composition = [
'- id: tool-kept',
` name: ${join(FIXTURES, 'plugins', 'contribute.js')}`,
' config:',
' tool: kept',
'- id: goes-away',
` name: ${join(FIXTURES, 'plugins', 'self-dispose.js')}`,
'',
].join('\n')
await writeFile(path, composition)
const scoped = new Context()
scoped.baseUrl = pathToFileURL(FIXTURES).href + '/'
await scoped.plugin(Loader)
scoped.loader.builtins.include = Include
await scoped.plugin(LlmService)
await scoped.plugin(SessionStore)
await scoped.plugin(SystemPrompt, { persona: '' })
await scoped.plugin(ToolRegistry)
await scoped.plugin(AgentRegistry)
await scoped.plugin(AgentLoop, { agents: [] })
await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }] })
await scoped.agents.create({
sessionId: SessionId('sess-self-dispose'),
setup: async (agentCtx: Context) => void await scoped.agentPresets.mount(agentCtx),
}) })
const file = join(FIXTURES, 'system', 'standard', 'agent.cordis.yml') await (globalThis as { __SELF_DISPOSED__?: Promise<unknown> }).__SELF_DISPOSED__
const before = await readFile(file, 'utf8') // Slack past the deterministic signal above, not a race the number has to
// win. The write rides the Loader's fiber-unload listener, which stamps
// `disabled: true` and calls `write()` in the same synchronous step; once
// the self-dispose has settled, a regression has already written. Polling
// would not help — the assertion is an ABSENCE, and no amount of waiting
// proves one — so the wait only has to clear settlement.
await new Promise(resolve => setTimeout(resolve, 50))
// The inherited `write()` persists the whole tree whenever the Loader // Inherited, `EntryTree.write()` persists the dying tree — stamping
// decides a row's config moved, so one row reconfiguring itself would // `disabled: true` onto the row and, in the shipped case, truncating the
// rewrite the shipped composition — here, with the row's new tool name. // composition every session shares.
const reconfigure = (globalThis as { __RECONFIGURE__?: (tool: string) => Promise<void> }).__RECONFIGURE__ expect(await readFile(path, 'utf8')).toBe(composition)
expect(reconfigure).toBeTypeOf('function')
await reconfigure!('rewritten')
expect(toolNames(ctx, handle.agent)).toContain('rewritten')
expect(await readFile(file, 'utf8')).toBe(before)
await handle.dispose()
}) })
})
describe('attributing a service to a subtree', () => {
it('attributes nothing to a subtree that is already torn down', async () => { it('attributes nothing to a subtree that is already torn down', async () => {
const handle = await ctx.agents.create({ const handle = await ctx.agents.create({
sessionId: SessionId('sess-torn'), sessionId: SessionId('sess-torn'),