Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml # .agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.i18n.yaml # .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml # .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/persistence-catalog.md # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/session.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl # packages/acp/acp/README.i18n.yaml # packages/acp/acp/src/index.ts # packages/client/connection/src/client/index.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/runtime/src/client/sessions/conversation.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/runtime/tests/queue-store.spec.ts # packages/client/ui-conversation/src/client/input/contract.ts # packages/client/ui-goal/README.i18n.yaml # packages/compact/compact-basic/src/index.ts # packages/context/time-context/tests/time-context.spec.ts # packages/context/tmux-context/tests/tmux-context.spec.ts # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/README.md # packages/core/agent-loop/README.zh.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent/README.i18n.yaml # packages/core/agent/README.md # packages/core/agent/README.zh.md # packages/core/agent/src/types.ts # packages/core/session/README.i18n.yaml # packages/core/session/README.md # packages/core/session/README.zh.md # packages/goal/command-goal/tests/command-goal.spec.ts # packages/goal/goal/tests/goal.spec.ts # packages/goal/tool-goal/README.i18n.yaml # packages/goal/tool-goal/README.md # packages/goal/tool-goal/README.zh.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/events.schema.ts # packages/host/apiproxy/src/api/events.ts # packages/host/apiproxy/src/api/rpc.ts # packages/host/apiproxy/tests/api-proxy-commands.spec.ts # packages/host/apiproxy/tests/api-proxy-projections.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/pty/pty-local/tests/index.spec.ts # packages/pty/pty-local/tests/local.spec.ts # packages/pty/tool-pty/tests/loader-composition.spec.ts # packages/pty/tool-pty/tests/tools.spec.ts # packages/session-persistence/session-checkpoint-policy/src/index.ts # packages/session-persistence/session-persistence/tests/coordinator-contract.ts # packages/skill/tool-skill/tests/tool-skill.spec.ts # packages/subagent/subagent-inprocess/README.i18n.yaml # packages/subagent/subagent-inprocess/README.md # packages/subagent/subagent-inprocess/README.zh.md # packages/subagent/subagent-inprocess/src/index.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/support/acp-snapshot/src/harness.ts # packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
@@ -1,15 +1,16 @@
|
||||
/** Session-fork boundaries, lineage, and inherited model routing. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionId } 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 })
|
||||
session.append('user/message', createUserMessage({
|
||||
@@ -98,6 +105,103 @@ 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')
|
||||
const parentId = sid('session-cold-parent')
|
||||
const header: SessionHeader = {
|
||||
version: 0,
|
||||
id: sourceId,
|
||||
createdAt: 1,
|
||||
cwd: '/proj',
|
||||
parentSession: parentId,
|
||||
origin: 'subagent',
|
||||
}
|
||||
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: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }),
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[]
|
||||
ctx.provide('sessionPersistence', {
|
||||
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 }))
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) return
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
expect(ctx.agents.get(sourceId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(response.result.value.sessionId)?.header).toMatchObject({
|
||||
parentSession: sourceId,
|
||||
cwd: '/proj',
|
||||
})
|
||||
expect(ctx.sessions.get(response.result.value.sessionId)?.header.origin).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('uses the last completed turn only for omitted and past-end anchors', async () => {
|
||||
const ctx = await composed()
|
||||
const source = liveAgent(ctx, 'session-tail', 2, 'open')
|
||||
|
||||
Reference in New Issue
Block a user