fix(web): preserve subagent navigation and fork grouping

This commit is contained in:
imccyu
2026-08-01 15:35:50 +08:00
committed by Tianyi Cui
parent 8c9cd4c15d
commit 130410bb98
18 changed files with 296 additions and 62 deletions

View File

@@ -10,6 +10,7 @@ import SessionStore from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { Workspace } from '@deepseek-ai/dsh-workspace'
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'
@@ -21,13 +22,13 @@ function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`fork-${String(nextRpc++)}`), payload }
}
async function composed(): Promise<Context> {
async function composed(workspaces: readonly Workspace[] = []): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
ctx.provide('workspace', { list: () => [] } as never)
ctx.provide('workspace', { list: () => workspaces } as never)
ctx.agents.setFactory({
createAgent: async (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
const session = ctx.sessions.create(options.sessionId, {
@@ -49,8 +50,14 @@ async function composed(): Promise<Context> {
/** Tail turn appended after the completed ones: left open, or closed as aborted (a stopped turn). */
type Tail = 'none' | 'open' | 'aborted'
function liveAgent(ctx: Context, id: string, turns: number, tail: Tail = 'none'): Session {
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } })
function liveAgent(
ctx: Context,
id: string,
turns: number,
tail: Tail = 'none',
lineage: { parentSession?: SessionId; origin?: 'subagent' } = {},
): Session {
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj', ...lineage } })
for (let turn = 1; turn <= turns; turn++) {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', createUserMessage({
@@ -94,6 +101,51 @@ describe('sessions.fork', () => {
await ctx.fiber.dispose()
})
it('attaches a subagent fork to its nearest workspace-owning ancestor', async () => {
const accounted: SessionId[] = []
const attachSession = vi.fn<(sessionId: SessionId) => Promise<void>>()
.mockResolvedValue(undefined)
const workspace = {
sessionIds: accounted,
attachSession,
} as unknown as Workspace
const ctx = await composed([workspace])
const owner = liveAgent(ctx, 'session-owner', 1)
accounted.push(owner.id)
const child = liveAgent(ctx, 'session-child', 1, 'none', {
parentSession: owner.id,
origin: 'subagent',
})
const grandchild = liveAgent(ctx, 'session-grandchild', 1, 'none', {
parentSession: child.id,
origin: 'subagent',
})
ctx.provide('sessionQuery', {
traceSession: vi.fn(() => Promise.resolve({
target: { header: grandchild.header, live: true, persisted: false },
ancestors: [
{ header: child.header, live: true, persisted: false },
{ header: owner.header, live: true, persisted: false },
],
descendants: [],
complete: true,
root: { header: owner.header, live: true, persisted: false },
})),
} as never)
const response = await api(ctx).sessions.fork(request({ sessionId: grandchild.id }))
expect(response.result.ok).toBe(true)
if (!response.result.ok) return
expect(attachSession).toHaveBeenCalledWith(response.result.value.sessionId)
expect(ctx.sessions.get(response.result.value.sessionId)?.header).toMatchObject({
parentSession: grandchild.id,
cwd: '/proj',
})
expect(ctx.sessions.get(response.result.value.sessionId)?.header.origin).toBeUndefined()
await ctx.fiber.dispose()
})
it('forks a persisted subagent without resuming its Agent', async () => {
const ctx = await composed()
const sourceId = sid('session-cold-subagent')
@@ -121,6 +173,15 @@ describe('sessions.fork', () => {
list: () => Promise.resolve([header]),
inspect: () => Promise.resolve({ meta: header, events }),
} as never)
ctx.provide('sessionQuery', {
traceSession: () => Promise.resolve({
target: { header, live: false, persisted: true },
ancestors: [],
descendants: [],
complete: true,
root: { header, live: false, persisted: true },
}),
} as never)
const resume = vi.spyOn(ctx.agents, 'resume')
const response = await api(ctx).sessions.fork(request({ sessionId: sourceId }))