Merge branch 'worktree-session-modes-rfc' (master: sandbox stack #169, skills #109, prompt snapshots #254)
The stack rebases onto a moved master through its base branch. Beyond mechanical unions (both branches' demo scripts, example rows, service roles, tool lists, acp deps, doc budgets — each side fit alone, the union needs the higher ceilings), three semantic reconciliations: - The ACP bridge now carries BOTH per-session surfaces: the sandbox stack's config options + approval answerer and this branch's session modes; session/new and session/load advertise modes AND configOptions side by side. - The feature matrix supersedes the sandbox stance per the RFC's second-lander rule: session/set_mode and current_mode_update flip to shipped-by-dsh-mode, config-option rows stay as #169 wrote them, and §6 records both landed features under the picker-to-modes / knobs-to-config-options division. - The snapshot pin grammar (#254: one header snapshot + declared deltas + a Markdown prompt golden) gains a symmetric declaration for what a delta cannot express: expectedHeaderSnapshots — a plan-mode flip resorts the canonical tool list, so its widening lands as a second full snapshot, now its own Markdown section. The pin-less-class and model-turn-only-pin amendments carry over; new fixtures cover the extended writer paths, and the plan-acp-agent scenarios re-recorded under the merged composition (the app now bundles the skill tool) with the suite's refresh mode wired through.
This commit is contained in:
@@ -92,7 +92,7 @@ async function makeConsumer(welcome: string, disabledBrokenEntry = false): Promi
|
||||
' name: \'@deepseek-ai/dsh-stdio-agent\'',
|
||||
' config:',
|
||||
' model: mock-echo',
|
||||
' systemPrompt: \'demo\'',
|
||||
' persona: \'demo\'',
|
||||
` welcome: '${welcome}'`,
|
||||
...disabledBrokenEntry
|
||||
? ['- id: off', ' name: \'./src/does-not-exist.ts\'', ' disabled: true']
|
||||
@@ -111,7 +111,7 @@ function runBuiltBin(cwd: string, configArg: string, line: string): Promise<{ st
|
||||
const child = spawn(process.execPath, ['--expose-internals', stdioBin, configArg], {
|
||||
cwd,
|
||||
// Mock model: never calls the network, so no key needed.
|
||||
env: { ...process.env },
|
||||
env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
let stdout = ''
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mkdtemp } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import * as stdioAgent from '../src/index.ts'
|
||||
|
||||
@@ -30,9 +34,47 @@ async function mount(config: stdioAgent.Config): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promise<NonNullable<stdioAgent.Config['skills']>> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-agent-skills-'))
|
||||
return {
|
||||
local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') },
|
||||
...catalogDescriptionMaxLength !== undefined ? { tool: { catalogDescriptionMaxLength } } : {},
|
||||
}
|
||||
}
|
||||
|
||||
async function composePrefix(ctx: Context): Promise<Message[]> {
|
||||
const empty: Message[] = []
|
||||
return await ctx.waterfall(
|
||||
'agent/session-prefix', { session: { header: { cwd: '/tmp' } } } as never,
|
||||
empty, new AbortController().signal, () => Promise.resolve(empty),
|
||||
)
|
||||
}
|
||||
|
||||
async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
|
||||
const oldDshHome = process.env.DSH_HOME
|
||||
const oldAgentsHome = process.env.DSH_AGENTS_HOME
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-agent-default-skills-'))
|
||||
process.env.DSH_HOME = join(home, '.dsh')
|
||||
process.env.DSH_AGENTS_HOME = join(home, '.agents')
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
if (oldDshHome === undefined) {
|
||||
delete process.env.DSH_HOME
|
||||
} else {
|
||||
process.env.DSH_HOME = oldDshHome
|
||||
}
|
||||
if (oldAgentsHome === undefined) {
|
||||
delete process.env.DSH_AGENTS_HOME
|
||||
} else {
|
||||
process.env.DSH_AGENTS_HOME = oldAgentsHome
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('dsh-stdio-agent app', () => {
|
||||
it('composes the spine + front-door cluster and pre-creates the main agent', async () => {
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec' })
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec', skills: await isolatedSkillsConfig() })
|
||||
// The spine services (brought up by the agent-core bundle) are all present.
|
||||
expect(ctx.get('agents')).toBeDefined()
|
||||
expect(ctx.get('agentLoop')).toBeDefined()
|
||||
@@ -40,7 +82,9 @@ describe('dsh-stdio-agent app', () => {
|
||||
expect(ctx.get('userInteraction')).toBeDefined()
|
||||
expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined()
|
||||
// The pre-created `main` agent the UI drives.
|
||||
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
|
||||
const agent = ctx.get('agents')?.get(AgentId('main'))
|
||||
expect(agent).toBeDefined()
|
||||
expect(agent?.session.header.cwd).toBe(process.cwd())
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -51,13 +95,24 @@ describe('dsh-stdio-agent app', () => {
|
||||
// schema-bypassing direct-mount caller.
|
||||
const ctx = new Context()
|
||||
// No persona: covers the omitted-persona forwarding branch too.
|
||||
stdioAgent.apply(ctx, { model: 'mock' })
|
||||
stdioAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() })
|
||||
await new Promise(resolve => setTimeout(resolve, 80))
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('uses default skill config when apply is called directly without skills', async () => {
|
||||
await withIsolatedSkillHomes(async () => {
|
||||
const ctx = new Context()
|
||||
stdioAgent.apply(ctx, { model: 'mock' })
|
||||
await new Promise(resolve => setTimeout(resolve, 80))
|
||||
expect(ctx.skills).toBeDefined()
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards resumeSessionId onto the pre-created agent when set', async () => {
|
||||
// A resume id defers agent creation until persistence loads; with no backing
|
||||
// session the resume is contained + logged, so no `main` agent registers —
|
||||
@@ -67,11 +122,19 @@ describe('dsh-stdio-agent app', () => {
|
||||
persona: 'hi',
|
||||
persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume',
|
||||
resumeSessionId: 'no-such-session',
|
||||
skills: await isolatedSkillsConfig(),
|
||||
})
|
||||
expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards skill config into agent-core', async () => {
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6) })
|
||||
ctx.skills.register({ name: 'stdio-skill', description: 'Stdio skill', source: 'runtime', content: 'body' })
|
||||
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `stdio-skill`: Std...')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('exposes its name and Config schema', () => {
|
||||
expect(stdioAgent.name).toBe('stdio-agent')
|
||||
expect(stdioAgent.Config).toBeDefined()
|
||||
@@ -94,7 +157,7 @@ describe('dsh-stdio-agent app', () => {
|
||||
})
|
||||
}
|
||||
const assembly = await ctx.get('systemPrompt')!.assemble()
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'ask_user_question'])
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'ask_user_question', 'skill'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user