Merge branch 'stack/agent-profiles-1-seam' into stack/agent-profiles-3-wire

This commit is contained in:
Yichen Jiang
2026-08-08 22:47:15 +08:00
910 changed files with 17011 additions and 6270 deletions

View File

@@ -228,7 +228,10 @@ describe('skill.list', () => {
// touch (or resume through) the Agent registry.
const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
const value = expectOk(await api.skills.list(request({ sessionId: session.id })))
expect(value.skills).toEqual([{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' }])
expect(value.skills).toEqual([
{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', modelInvocable: true },
{ name: 'user-only', description: 'User-only', modelInvocable: false },
])
expect(seenCwds).toEqual(['/proj'])
expect(ctx.agents.get(session.id)).toBeUndefined()
})

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -10,6 +10,7 @@ import { createApiProxy } from '../src/api-proxy.ts'
async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
return {
ctx,
@@ -17,8 +18,11 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
}
}
function agent(id: string): Agent {
return { id } as unknown as Agent
function agent(ctx: Context): Agent {
const session = ctx.sessions.create()
const value = { id: session.id, session, status: 'idle', ctx } as Agent
ctx.agents.register(value)
return value
}
function openMux(api: ApiProxy, abort: AbortController): {
@@ -71,7 +75,7 @@ describe('question response validation', () => {
const abort = new AbortController()
const mux = openMux(api, abort)
const asked = ctx.userInteraction.ask({
agent: agent('session-multi'),
agent: agent(ctx),
questions: [{
id: 'targets',
question: 'Choose targets and add another',
@@ -95,7 +99,7 @@ describe('question response validation', () => {
const abort = new AbortController()
const mux = openMux(api, abort)
const asked = ctx.userInteraction.ask({
agent: agent('session-single'),
agent: agent(ctx),
questions: [{
id: 'target',
question: 'Choose one target',

View File

@@ -19,6 +19,7 @@ function bench(options: {
childStatus?: 'idle' | 'running'
entries?: object[]
followupError?: Error
interruptError?: Error
listError?: Error
/** Persistence forgets the child entirely (the vanished-mid-read race). */
storedChild?: false
@@ -53,6 +54,12 @@ function bench(options: {
) => options.followupError === undefined
? Promise.resolve('message-1')
: Promise.reject(options.followupError))
const interrupt = vi.fn((
_targetSessionId: SessionId,
_authority: { kind: 'user'; parentSessionId: SessionId },
) => {
if (options.interruptError !== undefined) throw options.interruptError
})
const childHeader = {
version: 0, id: CHILD, createdAt: 1, cwd: '/proj', parentSession: options.historyParent ?? PARENT,
} satisfies SessionHeader
@@ -72,7 +79,7 @@ function bench(options: {
})
const ctx = new Context()
ctx.provide('agents', { get: getAgent })
ctx.provide('subagents', { listChildren, followup })
ctx.provide('subagents', { listChildren, followup, interrupt })
ctx.provide('sessions', {
get: (id: SessionId) => options.liveChild === true && id === CHILD
? { id: CHILD, header: childHeader, events: childEvents }
@@ -90,7 +97,7 @@ function bench(options: {
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp',
})
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent }
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, interrupt, parent }
}
describe('subagent gateway', () => {
@@ -309,4 +316,48 @@ describe('subagent gateway', () => {
error: { code: 'internal', message: 'subagent prompt failed' },
})
})
it('interrupts through the core primitive alone while the parent Agent is offline', async () => {
const { api, interrupt, getAgent, listChildren, inspect } = bench({ parentLive: false })
const response = await api.subagents.interrupt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
}))
expect(response.rpcId).toBe('subagent-rpc')
expect(response.result).toEqual({ ok: true, value: { accepted: true } })
expect(interrupt).toHaveBeenCalledExactlyOnceWith(CHILD, { kind: 'user', parentSessionId: PARENT })
// No parent-registry, catalog, or history dependency: this is what keeps a
// live child interruptible after its parent Agent went offline.
expect(getAgent).not.toHaveBeenCalled()
expect(listChildren).not.toHaveBeenCalled()
expect(inspect).not.toHaveBeenCalled()
})
it('maps interrupt authorization rejection without touching other services', async () => {
const { api, listChildren } = bench({
interruptError: new SubagentError('secret lineage', 'UNAUTHORIZED'),
})
const response = await api.subagents.interrupt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
}))
expect(response.result).toEqual({
ok: false,
error: {
code: 'subagent-unauthorized',
message: 'subagent does not belong to this parent',
details: { childSessionId: CHILD },
},
})
expect(listChildren).not.toHaveBeenCalled()
})
it('hides unexpected interrupt failures behind the internal code', async () => {
const { api } = bench({ interruptError: new Error('secret activation state') })
const response = await api.subagents.interrupt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
}))
expect(response.result).toEqual({
ok: false,
error: { code: 'internal', message: 'subagent interrupt failed', details: {} },
})
})
})

View File

@@ -63,6 +63,7 @@ function scriptedApi(overrides: {
list: r => ok(r, { entries: [], parentAvailable: false }),
history: r => ok(r, { events: [], hasMore: false }),
prompt: r => ok(r, { messageId: 'message-1' as never }),
interrupt: r => ok(r, { accepted: true as const }),
...overrides.subagents,
},
host: {
@@ -248,6 +249,32 @@ describe('unary round trip', () => {
}
})
it('round-trips subagent.interrupt and rejects a one-shot or incomplete address', async () => {
const interrupt = vi.fn((r: RpcRequest<unknown>) => ok(r, { accepted: true as const }))
const api = scriptedApi({ subagents: { interrupt } })
const c = client(api)
const accepted = await c.subagents.interrupt({
parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'continuable',
})
expect(accepted.result).toEqual({ ok: true, value: { accepted: true } })
expect(interrupt).toHaveBeenCalledTimes(1)
// The wire schema owns the mode fence: a one-shot address never reaches the impl.
const oneShot = await c.subagents.interrupt({
parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'one-shot',
} as never)
expect(oneShot.result.ok).toBe(false)
if (!oneShot.result.ok) expect(oneShot.result.error.code).toBe('bad-request')
const incomplete = await c.subagents.interrupt({
parentSessionId: sid('parent'), mode: 'continuable',
} as never)
expect(incomplete.result.ok).toBe(false)
if (!incomplete.result.ok) expect(incomplete.result.error.code).toBe('bad-request')
expect(interrupt).toHaveBeenCalledTimes(1)
})
it('rejects a method/path mismatch as bad-request', async () => {
const handler = toFetchHandler(scriptedApi())
const body = { type: 'client-request', rpcId: 'r1', method: 'session.create', payload: {} }

View File

@@ -128,6 +128,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
result: { ok: true, value: { messageId: 'message-1' as never } },
}
},
async interrupt(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
},
host: {
async describe(request) {
@@ -196,7 +199,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
},
skills: {
async list(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } } }
return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } } }
},
},
goals: {
@@ -381,7 +384,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
const miss = await c.commands.execute({ sessionId: 's' as never, line: '/nope' })
expect(miss.result).toEqual({ ok: true, value: { matched: false } })
const skills = await c.skills.list({ sessionId: 's' as never })
expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } })
expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } })
})
it('lets command.execute finish after the 30-second default unary deadline', async () => {
@@ -433,6 +436,11 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
mode: 'continuable',
content: [],
})).result).toEqual({ ok: true, value: { messageId: 'message-1' } })
expect((await c.subagents.interrupt({
parentSessionId: 'parent' as never,
childSessionId: 'child' as never,
mode: 'continuable',
})).result).toEqual({ ok: true, value: { accepted: true } })
})
it('keeps caller and connection aborts on command.execute', async () => {

View File

@@ -395,12 +395,15 @@ describe('skills domain schemas', () => {
expect(() => skillListRequestSchema.parse({})).toThrow()
expect(skillListValueSchema.parse({ skills: [] }).skills).toEqual([])
const value = skillListValueSchema.parse({ skills: [
{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' },
{ name: 'bare', description: 'No guidance' },
{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', modelInvocable: true },
{ name: 'bare', description: 'No guidance', modelInvocable: false },
] })
expect(value.skills[0]?.whenToUse).toBe('when committing')
expect(value.skills[1]?.whenToUse).toBeUndefined()
expect(() => skillEntrySchema.parse({ name: '', description: 'd' })).toThrow()
expect(value.skills[1]?.modelInvocable).toBe(false)
expect(() => skillEntrySchema.parse({ name: '', description: 'd', modelInvocable: true })).toThrow()
// modelInvocable is required wire data: an entry without it fails.
expect(() => skillEntrySchema.parse({ name: 'n', description: 'd' })).toThrow()
})
})