fix(host): preserve subagent continuation ownership
This commit is contained in:
@@ -1,21 +1,19 @@
|
||||
/**
|
||||
* Cold-session and degenerate-composition paths of the host ApiProxy:
|
||||
* sessions.list merging persisted-but-unattached summaries (mtime source,
|
||||
* createdAt fallbacks, lineage projection), the resume error split when
|
||||
* the composition has no persistence gate and no agent factory, and the
|
||||
* agent-busy mapping of a synchronous prompt rejection.
|
||||
* metadata-only listing, Agent-free history reads, subagent ownership
|
||||
* isolation, and prompt failure mapping.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, writeFileSync, utimesSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { InboxItemId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
@@ -115,8 +113,160 @@ describe('attached updatedAt excludes end-seed', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('subagent ownership fence', () => {
|
||||
it('reads a cold child without an Agent and rejects generic resume or adoption', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const sessionId = sid('session-child')
|
||||
const meta = header('session-child', 1000, {
|
||||
parentSession: sid('session-parent'),
|
||||
seedLength: 0,
|
||||
})
|
||||
const events = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{
|
||||
type: 'user/message',
|
||||
seq: 1,
|
||||
time: 2,
|
||||
data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } },
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'subagent/descriptor',
|
||||
seq: 2,
|
||||
time: 3,
|
||||
data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' },
|
||||
},
|
||||
{ type: 'turn/end', seq: 3, time: 4, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[]
|
||||
const inspect = vi.fn(() => Promise.resolve({ meta, events }))
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([meta]),
|
||||
inspect,
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
const resume = vi.spyOn(ctx.agents, 'resume')
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
const history = await api.sessions.history(request({ sessionId }))
|
||||
expect(history.result.ok).toBe(true)
|
||||
if (history.result.ok) {
|
||||
expect(history.result.value.events.map(entry => entry.event.type)).toEqual(events.map(event => event.type))
|
||||
}
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
|
||||
const prompt = await api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: 'follow up' }],
|
||||
}))
|
||||
expect(prompt.result.ok).toBe(false)
|
||||
if (!prompt.result.ok) {
|
||||
expect(prompt.result.error).toMatchObject({
|
||||
code: 'agent-busy',
|
||||
details: { reason: 'use subagent delivery for this child session' },
|
||||
})
|
||||
}
|
||||
|
||||
const create = await api.sessions.create(request({ sessionId, cwd: '/proj' }))
|
||||
expect(create.result.ok).toBe(false)
|
||||
if (!create.result.ok) expect(create.result.error.code).toBe('agent-busy')
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
expect(inspect).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('rejects origin-marked and runtime-owned live children from generic controls', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const parentSession = ctx.sessions.create(sid('session-parent'), { meta: { cwd: '/proj' } })
|
||||
const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent
|
||||
ctx.agents.register(parent)
|
||||
|
||||
const originSession = ctx.sessions.create(sid('session-origin-child'), {
|
||||
meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' },
|
||||
})
|
||||
const cancel = vi.fn()
|
||||
const updateInbox = vi.fn(() => 'applied' as const)
|
||||
const originChild = {
|
||||
id: originSession.id,
|
||||
session: originSession,
|
||||
status: 'idle',
|
||||
ctx,
|
||||
cancel,
|
||||
updateInbox,
|
||||
} as unknown as Agent
|
||||
ctx.agents.register(originChild)
|
||||
|
||||
const startingSession = ctx.sessions.create(sid('session-starting-child'), {
|
||||
meta: { cwd: '/proj', parentSession: parent.id },
|
||||
})
|
||||
const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent
|
||||
ctx.agents.enter(startingChild, parent)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
const stopped = await api.sessions.cancel(request({ sessionId: originChild.id }))
|
||||
expect(stopped.result.ok).toBe(false)
|
||||
if (!stopped.result.ok) expect(stopped.result.error.code).toBe('agent-busy')
|
||||
expect(cancel).not.toHaveBeenCalled()
|
||||
|
||||
const queued = await api.sessions.updateQueue(request({
|
||||
sessionId: originChild.id,
|
||||
itemId: InboxItemId('queued-item'),
|
||||
action: { kind: 'remove' },
|
||||
}))
|
||||
expect(queued.result.ok).toBe(false)
|
||||
if (!queued.result.ok) expect(queued.result.error.code).toBe('agent-busy')
|
||||
expect(updateInbox).not.toHaveBeenCalled()
|
||||
|
||||
const models = await api.sessions.models(request({ sessionId: startingChild.id }))
|
||||
expect(models.result.ok).toBe(false)
|
||||
if (!models.result.ok) expect(models.result.error.code).toBe('agent-busy')
|
||||
|
||||
const create = await api.sessions.create(request({ sessionId: originChild.id, cwd: '/proj' }))
|
||||
expect(create.result.ok).toBe(false)
|
||||
if (!create.result.ok) expect(create.result.error.code).toBe('agent-busy')
|
||||
|
||||
const history = await api.sessions.history(request({ sessionId: originChild.id }))
|
||||
expect(history.result.ok).toBe(true)
|
||||
expect(ctx.agents.get(originChild.id)).toBe(originChild)
|
||||
})
|
||||
|
||||
it('does not classify an ordinary fork from an inherited ancestor descriptor', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const session = ctx.sessions.create(sid('session-ordinary-fork'), {
|
||||
seed: [{
|
||||
type: 'subagent/descriptor',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'ancestor' },
|
||||
}],
|
||||
meta: { cwd: '/proj', parentSession: sid('session-source'), seedLength: 1 },
|
||||
})
|
||||
const followup = vi.fn()
|
||||
const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
|
||||
ctx.agents.register(agent)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
const response = await api.sessions.prompt(request({
|
||||
sessionId: agent.id,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: 'ordinary work' }],
|
||||
}))
|
||||
expect(response.result.ok).toBe(true)
|
||||
expect(followup).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('degenerate composition (no persistence, no factory)', () => {
|
||||
it('list skips the cold merge and resume maps a non-not-found failure to internal', async () => {
|
||||
it('list skips the cold merge and history reports missing persistence as internal', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
@@ -127,15 +277,32 @@ describe('degenerate composition (no persistence, no factory)', () => {
|
||||
expect(listed.result.ok).toBe(true)
|
||||
if (listed.result.ok) expect(listed.result.value.items).toEqual([])
|
||||
|
||||
// No persistence → the servable gate passes silently; the factory-less
|
||||
// registry then rejects resume, which is NOT a SessionNotFound.
|
||||
// No persistence means cold history cannot inspect a transcript.
|
||||
const response = await api.sessions.history(request({ sessionId: sid('session-ghost') }))
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (!response.result.ok) {
|
||||
expect(response.result.error.code).toBe('internal')
|
||||
expect(response.result.error.message).toMatch(/resume failed for session "session-ghost"/)
|
||||
expect(response.result.error.message).toMatch(/history unavailable for session "session-ghost"/)
|
||||
}
|
||||
})
|
||||
|
||||
it('maps a persistence catalog miss to session-not-found without inspection', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const inspect = vi.fn()
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([]),
|
||||
inspect,
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
const response = await api.sessions.history(request({ sessionId: sid('session-missing') }))
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
|
||||
expect(inspect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessions.prompt synchronous rejection', () => {
|
||||
|
||||
Reference in New Issue
Block a user