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:
_Kerman
2026-08-03 10:56:09 +08:00
605 changed files with 25740 additions and 2265 deletions

View File

@@ -56,6 +56,7 @@
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",

View File

@@ -1,8 +1,8 @@
/**
* commands domain contract: the web catalog/dispatch face of the host command
* registry (`ctx.commands`). Both methods address one session's agent via
* `sessionId` — every served session has an Agent (Session+Agent are born
* together), so there is no agent-less surface on this wire.
* registry (`ctx.commands`). Both methods address an ordinary session's Agent
* via `sessionId`, resuming it when cold. Session-backed subagents reject with
* `agent-busy` and retain their dedicated continuation owner.
*/
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
@@ -27,7 +27,8 @@ export interface CommandDescriptor {
export interface CommandsApi {
/**
* Lists the addressed agent's effective command catalog (name-sorted,
* globals plus its scoped shadows).
* globals plus its scoped shadows). Session-backed subagents reject with
* `agent-busy`.
*/
list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ commands: readonly CommandDescriptor[] }>>
@@ -42,6 +43,7 @@ export interface CommandsApi {
* pairing id, letting the issuing client correlate this acknowledgment
* with that flow node. The signal rides beside the request, never on the
* wire: the fetch carrier's request signal cancels the running handler.
* Session-backed subagents reject with `agent-busy` before dispatch.
*/
execute(request: RpcRequest<{ sessionId: SessionId; line: string }>, signal: AbortSignal):
Promise<RpcResponse<{ matched: boolean; commandId?: CommandId }>>

View File

@@ -22,7 +22,11 @@ export interface GoalRef {
readonly revision: number
}
/** Goal-domain unary methods (every mutation resolves the session's agent and applies one CAS-guarded verb). */
/**
* Goal-domain unary methods. Every mutation resolves an ordinary session's
* Agent and applies one CAS-guarded verb; session-backed subagents reject with
* `agent-busy`.
*/
export interface GoalsApi {
/** Create and arm a goal. */
create(request: RpcRequest<{ sessionId: SessionId; objective: string; maxGoalRounds?: number }>):

View File

@@ -9,6 +9,7 @@ import type { HostApi } from './host.ts'
import type { WorkspaceApi } from './workspace.ts'
import type { CommandsApi } from './commands.ts'
import type { SkillsApi } from './skills.ts'
import type { SubagentsApi } from './subagents.ts'
import type { EventsApi } from './events.ts'
import type { GoalsApi } from './goals.ts'
import type { SettingsApi } from './settings.ts'
@@ -19,6 +20,7 @@ import type { ClientResponse, RpcReceipt } from './rpc.ts'
/** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */
export interface ApiProxy {
sessions: SessionsApi
subagents: SubagentsApi
host: HostApi
workspace: WorkspaceApi
commands: CommandsApi
@@ -39,6 +41,9 @@ export type {
SessionsApi, SessionSummary,
} from './sessions.ts'
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
export type {
SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, SubagentsApi,
} from './subagents.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor } from './commands.ts'
export type { SkillsApi, SkillEntry } from './skills.ts'

View File

@@ -13,6 +13,7 @@ import type { GoalsApi } from './goals.ts'
import type { SettingsApi } from './settings.ts'
import type { CredentialsApi } from './credentials.ts'
import type { LlmApi } from './llm.ts'
import type { SubagentsApi } from './subagents.ts'
import type { RpcResponse } from './rpc.ts'
/**
@@ -32,6 +33,9 @@ export interface RpcMethodMap {
'session.prompt': SessionsApi['prompt']
'session.updateQueue': SessionsApi['updateQueue']
'session.cancel': SessionsApi['cancel']
'subagent.list': SubagentsApi['list']
'subagent.history': SubagentsApi['history']
'subagent.prompt': SubagentsApi['prompt']
'host.describe': HostApi['describe']
'host.pickDirectory': HostApi['pickDirectory']
'host.listDirectory': HostApi['listDirectory']

View File

@@ -48,6 +48,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }),
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }),
z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }),
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }),
@@ -56,6 +57,16 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }),
z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('subagent-parent-unavailable'), message: z.string(), details: z.object({ parentSessionId: z.string() }) }),
z.object({ code: z.literal('subagent-not-found'), message: z.string(), details: z.object({ parentSessionId: z.string(), childSessionId: z.string() }) }),
z.object({ code: z.literal('subagent-catalog-diagnostic'), message: z.string(), details: z.object({
parentSessionId: z.string(),
childSessionId: z.string(),
reason: z.union([z.literal('corrupt'), z.literal('unsupported'), z.literal('unavailable')]),
}) }),
z.object({ code: z.literal('subagent-not-resumable'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
z.object({ code: z.literal('subagent-unauthorized'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
z.object({ code: z.literal('subagent-delivery-unavailable'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
]) as unknown as z.ZodType<RpcError>

View File

@@ -53,6 +53,7 @@ export const sessionSummarySchema = z.object({
running: z.boolean(),
blank: z.boolean(),
parentSessionId: sessionIdSchema.optional(),
origin: z.literal('subagent').optional(),
cwd: z.string().optional(),
projections: z.lazy(() => sessionProjectionsBlockSchema).optional(),
}) as unknown as z.ZodType<Wire<SessionSummary>>
@@ -194,10 +195,10 @@ export const toolEventViewSchema = z.discriminatedUnion('for', [
]) as unknown as z.ZodType<ToolEventView>
/** One session.history item: the session event plus its optional host-computed tool view. */
export const historyEntrySchema = z.object({
export const historyEntrySchema: z.ZodType<Wire<HistoryEntry>> = z.object({
event: sessionEventSchema,
view: toolEventViewSchema.optional(),
}) satisfies z.ZodType<Wire<HistoryEntry>>
}) as unknown as z.ZodType<Wire<HistoryEntry>>
/**
* Projection baseline passthrough: `values` stays a wide record — each value
@@ -211,11 +212,11 @@ export const sessionProjectionsBlockSchema = z.object({
}) as unknown as z.ZodType<SessionProjectionsBlock>
/** session.history response value (projections rides the tail page only). */
export const sessionHistoryValueSchema = z.object({
export const sessionHistoryValueSchema: z.ZodType<Wire<ResponseValue<'session.history'>>> = z.object({
events: z.array(historyEntrySchema),
hasMore: z.boolean(),
projections: sessionProjectionsBlockSchema.optional(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.history'>>>
})
/** session.models request payload. */
export const sessionModelsRequestSchema = z.object({
@@ -268,6 +269,7 @@ export const sessionUpdateQueueRequestSchema = z.object({
action: z.discriminatedUnion('kind', [
z.object({ kind: z.literal('edit'), content: z.array(contentBlockSchema) }),
z.object({ kind: z.literal('remove') }),
z.object({ kind: z.literal('steer') }),
]),
}) as unknown as z.ZodType<RequestPayload<'session.updateQueue'>>

View File

@@ -129,6 +129,7 @@ export interface SessionModels {
export type QueueAction =
| { kind: 'edit'; content: ContentBlock[] }
| { kind: 'remove' }
| { kind: 'steer' }
/** Session list entry (v1 builds no index: list does readdir+stat). */
export interface SessionSummary {
@@ -153,6 +154,8 @@ export interface SessionSummary {
blank: boolean
/** fork/spawn lineage (session.header.parentSession passthrough); absent for root sessions. */
parentSessionId?: SessionId
/** Coarse durable origin used by navigation surfaces; never proves resumability. */
origin?: 'subagent'
/** Session working directory (header.cwd passthrough); absent when unrecorded. */
cwd?: string
/**
@@ -217,17 +220,22 @@ export interface SessionsApi {
* the client needs a fresh baseline already pulls the tail page, and
* loadOlder (the only beforeSeq path) is the only path that never needs one.
* A deployment without the registry serves histories without the block.
* Reading history uses an attached Session or persistence inspection and
* never resumes or publishes an Agent.
*/
history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean; projections?: SessionProjectionsBlock }>>
/** Reads a fresh advisory model directory for this session. Provider lookups run independently. */
/**
* Reads a fresh advisory model directory for an ordinary session. Provider
* lookups run independently; subagents reject with `agent-busy`.
*/
models(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<SessionModels>>
/**
* Selects the complete target for this session. Exact model metadata
* validates an optional reasoning effort, while catalog membership remains
* advisory.
* advisory. Session-backed subagents reject with `agent-busy`.
*/
selectModel(request: RpcRequest<{
sessionId: SessionId
@@ -243,6 +251,7 @@ export interface SessionsApi {
* normalized accepted title and the title event's seq return so the caller
* can settle its projection cell without waiting for the push frame. A
* title that normalizes to empty fails with `title-invalid`.
* Session-backed subagents reject with `agent-busy`.
*/
rename(request: RpcRequest<{ sessionId: SessionId; title: string }>):
Promise<RpcResponse<{ title: string; seq: number }>>
@@ -263,23 +272,31 @@ export interface SessionsApi {
* falls back to the source's last completed turn. An in-log anchor whose
* turn is still open fails with `fork-unavailable` instead of clipping to
* an earlier turn. The child inherits the source cwd, latest logged model
* target, workspace attachment, and `parentSessionId` lineage; the seed
* prefix carries the source title.
* target and `parentSessionId` lineage; the seed prefix carries the source
* title. Reading the source uses attached state or persistence inspection
* without acquiring an Agent. Workspace attachment follows the source
* directly, or the nearest workspace-owning ancestor when the source is a
* subagent.
*/
fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>):
Promise<RpcResponse<{ sessionId: SessionId }>>
/** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */
/** Sends a message to an ordinary session Agent. Session-backed subagents reject with `agent-busy` and use `subagent.prompt`. */
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
/**
* Edits or removes one pending queued occurrence.
* Edits, removes, or strictly steers one pending queued occurrence on an ordinary session.
* Session-backed subagents reject with `agent-busy`.
*/
updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: MessageId; action: QueueAction }>):
Promise<RpcResponse<{ accepted: true }>>
/** Stops the active turn, preserving pending inbox work that resumes in FIFO order after cancellation settles. */
/**
* Stops an ordinary session's active turn, preserving pending inbox work
* that resumes in FIFO order after cancellation settles. Session-backed
* subagents reject with `agent-busy`.
*/
cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ accepted: true }>>
}

View File

@@ -0,0 +1,77 @@
/** Zod schemas for the browser-safe subagent domain. */
import { z } from 'zod'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import {
contentBlockSchema, historyEntrySchema, sessionIdSchema, sessionProjectionsBlockSchema,
} from './sessions.schema.ts'
import type { SubagentListEntry } from './subagents.ts'
/** Healthy and diagnostic durable catalog rows. */
export const subagentListEntrySchema = z.union([
z.object({
kind: z.literal('child'),
id: sessionIdSchema,
mode: z.literal('one-shot'),
activity: z.union([z.literal('running'), z.literal('inactive')]),
hasChildren: z.boolean(),
label: z.string().optional(),
}),
z.object({
kind: z.literal('child'),
id: sessionIdSchema,
mode: z.literal('continuable'),
activity: z.union([z.literal('running'), z.literal('inactive')]),
hasChildren: z.boolean(),
label: z.string(),
}),
z.object({
kind: z.literal('diagnostic'),
id: sessionIdSchema,
reason: z.union([z.literal('corrupt'), z.literal('unsupported'), z.literal('unavailable')]),
}),
]) satisfies z.ZodType<Wire<SubagentListEntry>>
/** subagent.list request payload. */
export const subagentListRequestSchema = z.object({
parentSessionId: sessionIdSchema,
}) satisfies z.ZodType<Wire<RequestPayload<'subagent.list'>>>
/** subagent.list response value. */
export const subagentListValueSchema = z.object({
entries: z.array(subagentListEntrySchema),
parentAvailable: z.boolean(),
}) satisfies z.ZodType<Wire<ResponseValue<'subagent.list'>>>
/** subagent.history request payload. */
export const subagentHistoryRequestSchema = z.object({
parentSessionId: sessionIdSchema,
childSessionId: sessionIdSchema,
mode: z.union([z.literal('one-shot'), z.literal('continuable')]),
beforeSeq: z.number().int().nonnegative().optional(),
maxMessages: z.number().int().positive().optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'subagent.history'>>>
/** subagent.history response value. */
export const subagentHistoryValueSchema = z.object({
events: z.array(historyEntrySchema),
hasMore: z.boolean(),
projections: sessionProjectionsBlockSchema.optional(),
}) as unknown as z.ZodType<Wire<ResponseValue<'subagent.history'>>>
/** subagent.prompt request payload. */
export const subagentPromptRequestSchema = z.object({
parentSessionId: sessionIdSchema,
childSessionId: sessionIdSchema,
mode: z.literal('continuable'),
content: z.array(contentBlockSchema),
}) as unknown as z.ZodType<RequestPayload<'subagent.prompt'>>
const messageIdSchema = z.string() as unknown as z.ZodType<MessageId>
/** subagent.prompt response value. */
export const subagentPromptValueSchema = z.object({
messageId: messageIdSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'subagent.prompt'>>>

View File

@@ -0,0 +1,96 @@
/**
* Browser-safe subagent domain contract. Persisted transcript reads never
* activate an Agent, while continuable prompts route through the exact live
* direct parent into the child's Agent inbox.
*/
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { RpcRequest, RpcResponse } from './rpc.ts'
import type { HistoryEntry, SessionProjectionsBlock } from './sessions.ts'
/** Complete durable direct-child catalog row. */
export type SubagentListEntry =
| {
kind: 'child'
id: SessionId
/** Whether the child Agent driver is running at the Host sampling boundary. */
activity: 'running' | 'inactive'
/** Whether a direct descendant has durable `origin: 'subagent'`. */
hasChildren: boolean
} & (
| {
mode: 'one-shot'
label?: string
}
| {
mode: 'continuable'
label: string
}
)
| {
kind: 'diagnostic'
id: SessionId
reason: 'corrupt' | 'unsupported' | 'unavailable'
}
/** Inbox identity returned once the continuation accepts one human message. */
export interface SubagentPromptReceipt {
messageId: MessageId
}
/** Durable parent/child address that selects subagent transport in the client. */
export type SubagentAddress =
& {
parentSessionId: SessionId
childSessionId: SessionId
}
& (
| { mode: 'one-shot' }
| { mode: 'continuable' }
)
/** Complete direct-child catalog plus the delivery-time parent availability hint. */
export interface SubagentCatalog {
entries: SubagentListEntry[]
parentAvailable: boolean
}
/** Subagent-domain unary methods. */
export interface SubagentsApi {
/**
* Lists direct session-backed children without loading either side. Parent
* availability is a hint; continuable prompt performs the authoritative
* check.
*/
list(
request: RpcRequest<{ parentSessionId: SessionId }>,
signal?: AbortSignal,
): Promise<RpcResponse<SubagentCatalog>>
/**
* Reads one healthy catalog child's persisted raw log with ordinary
* message-aligned pagination and render intents, without Agent activation.
*/
history(
request: RpcRequest<SubagentAddress & { beforeSeq?: number; maxMessages?: number }>,
signal?: AbortSignal,
): Promise<RpcResponse<{
events: HistoryEntry[]
hasMore: boolean
projections?: SessionProjectionsBlock
}>>
/**
* Delivers human content to a continuable child through the exact live
* parent's continuation owner. Success identifies the message accepted by
* the child's FIFO inbox; later execution is independent of this request.
*/
prompt(
request: RpcRequest<
Extract<SubagentAddress, { mode: 'continuable' }> & { content: ContentBlock[] }
>,
signal: AbortSignal,
): Promise<RpcResponse<SubagentPromptReceipt>>
}

View File

@@ -55,6 +55,11 @@ import {
credentialsDescribeValueSchema, credentialsSetValueSchema, credentialsUnsetValueSchema,
} from '../api/credentials.schema.ts'
import { llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts'
import {
subagentHistoryValueSchema,
subagentListValueSchema,
subagentPromptValueSchema,
} from '../api/subagents.schema.ts'
/**
* Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
@@ -86,6 +91,11 @@ export interface IApiClient {
updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.updateQueue'>>>
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
}
subagents: {
list(payload: RequestPayload<'subagent.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.list'>>>
history(payload: RequestPayload<'subagent.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.history'>>>
prompt(payload: RequestPayload<'subagent.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.prompt'>>>
}
host: {
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.pickDirectory'>>>
@@ -155,6 +165,9 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'session.prompt': sessionPromptValueSchema,
'session.updateQueue': sessionUpdateQueueValueSchema,
'session.cancel': sessionCancelValueSchema,
'subagent.list': subagentListValueSchema,
'subagent.history': subagentHistoryValueSchema,
'subagent.prompt': subagentPromptValueSchema,
'host.describe': hostDescribeValueSchema,
'host.pickDirectory': hostPickDirectoryValueSchema,
'host.listDirectory': hostListDirectoryValueSchema,
@@ -385,6 +398,12 @@ export abstract class AbstractApiClient implements IApiClient {
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
}
readonly subagents: IApiClient['subagents'] = {
list: (payload, signal) => this.callUnary('subagent.list', payload, signal),
history: (payload, signal) => this.callUnary('subagent.history', payload, signal),
prompt: (payload, signal) => this.callUnary('subagent.prompt', payload, signal),
}
readonly host: IApiClient['host'] = {
describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
// A native system dialog is user-paced and may legitimately stay open

View File

@@ -57,6 +57,11 @@ import {
credentialsDescribeRequestSchema, credentialsSetRequestSchema, credentialsUnsetRequestSchema,
} from '../api/credentials.schema.ts'
import { llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts'
import {
subagentHistoryRequestSchema,
subagentListRequestSchema,
subagentPromptRequestSchema,
} from '../api/subagents.schema.ts'
/**
* Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
@@ -87,6 +92,9 @@ const UNARY_ROUTES: UnaryRoutes = {
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) },
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(r, signal) },
'subagent.history': { schema: subagentHistoryRequestSchema, invoke: (api, r, signal) => api.subagents.history(r, signal) },
'subagent.prompt': { schema: subagentPromptRequestSchema, invoke: (api, r, signal) => api.subagents.prompt(r, signal) },
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) },

View File

@@ -45,7 +45,10 @@ export interface Config {
* project directory and the fallback parent for name-created Workspaces.
*/
export class ApiProxyService extends Service implements ApiProxy {
static inject = ['agents', 'directoryPicker', 'llm', 'sessions', 'tools', 'userInteraction', 'workspace']
static inject = [
'agents', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery',
'tools', 'userInteraction', 'workspace',
]
static Config: z<Config> = z.object({
provider: z.string().required(),
@@ -54,6 +57,7 @@ export class ApiProxyService extends Service implements ApiProxy {
})
readonly sessions: ApiProxy['sessions']
readonly subagents: ApiProxy['subagents']
readonly workspace: ApiProxy['workspace']
readonly host: ApiProxy['host']
readonly commands: ApiProxy['commands']
@@ -75,6 +79,7 @@ export class ApiProxyService extends Service implements ApiProxy {
workspaceRoot: resolve(config.workspaceRoot ?? cwd),
})
this.sessions = api.sessions
this.subagents = api.subagents
this.workspace = api.workspace
this.host = api.host
this.commands = api.commands

View File

@@ -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'
@@ -42,7 +40,7 @@ describe('sessions.list cold merge', () => {
utimesSync(logPath, 5000, 5000) // mtime 5_000_000 ms — newer than every createdAt below
const metas = [
header('session-a', 1000),
header('session-b', 2000, { parentSession: sid('session-parent') }),
header('session-b', 2000, { parentSession: sid('session-parent'), origin: 'subagent' }),
header('session-c', 1500),
]
// Structural fake of the persistence face list() consumes: list + locate.
@@ -74,6 +72,7 @@ describe('sessions.list cold merge', () => {
expect(a?.parentSessionId).toBeUndefined()
expect(b?.updatedAt).toBe(2000)
expect(b?.parentSessionId).toBe('session-parent')
expect(b?.origin).toBe('subagent')
expect(c?.updatedAt).toBe(1500)
})
})
@@ -114,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)
@@ -126,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', () => {
@@ -170,4 +338,43 @@ describe('sessions.prompt synchronous rejection', () => {
}
}
})
it('classifies a raced cold-resume ID collision as agent-busy', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const sessionId = sid('race-resume')
const meta: SessionHeader = header('race-resume', 1000)
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }),
locate: () => undefined,
} as never)
// The raced winner: a live parent-owned subagent publishes the identity
// while the generic cold resume is in flight, so the resume collides.
const parentSession = ctx.sessions.create(sid('race-parent'), { meta: { cwd: '/proj' } })
const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent
ctx.agents.register(parent)
const childSession = ctx.sessions.create(sessionId, {
meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' },
})
const child = { id: sessionId, session: childSession, status: 'idle', ctx } as unknown as Agent
vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => {
// The parent's `enter()` wins the identity between the pre-resume
// re-check and publication; the generic resume then collides.
ctx.agents.register(child)
throw new Error('session id already published')
})
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const models = await api.sessions.models(request({ sessionId }))
expect(models.result.ok).toBe(false)
if (!models.result.ok) {
expect(models.result.error).toMatchObject({
code: 'agent-busy',
details: { reason: 'use subagent delivery for this child session' },
})
}
})
})

View File

@@ -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')

View File

@@ -0,0 +1,226 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
import { SubagentError } from '@deepseek-ai/dsh-subagent'
import { RpcId } from '../src/api/rpc.ts'
import type { RpcRequest } from '../src/api/rpc.ts'
import { createApiProxy } from '../src/api-proxy.ts'
const sid = (value: string): SessionId => value as SessionId
const PARENT = sid('parent')
const CHILD = sid('child')
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId('subagent-rpc'), payload }
}
function bench(options: {
parentLive?: boolean
childStatus?: 'idle' | 'running'
entries?: object[]
followupError?: Error
listError?: Error
readError?: Error
historyParent?: SessionId
} = {}) {
const parent = { id: PARENT }
const child = options.childStatus === undefined
? undefined
: { id: CHILD, status: options.childStatus }
const getAgent = vi.fn((id: SessionId) => {
if (options.parentLive !== false && id === PARENT) return parent
if (id === CHILD) return child
return undefined
})
const listChildren = vi.fn(() => options.listError === undefined
? Promise.resolve(options.entries ?? [
{
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
activity: 'inactive', hasChildren: false,
},
])
: Promise.reject(options.listError))
const followup = vi.fn((
_parent: unknown,
_childId: SessionId,
_content: unknown,
_delivery: { source: { kind: string; rpcId: RpcId }; signal: AbortSignal },
) => options.followupError === undefined
? Promise.resolve('message-1')
: Promise.reject(options.followupError))
const readSession = vi.fn(() => options.readError === undefined
? Promise.resolve({
session: {
version: 0, id: CHILD, createdAt: 1, parentSession: options.historyParent ?? PARENT,
} satisfies SessionHeader,
events: [
{ type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } } },
] as unknown as SessionEvent[],
})
: Promise.reject(options.readError))
const ctx = new Context()
ctx.provide('agents', { get: getAgent })
ctx.provide('subagents', { listChildren, followup })
ctx.provide('sessionQuery', { readSession })
ctx.provide('userInteraction', { registerProvider: () => () => {} })
const api = createApiProxy(ctx, {
provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp',
})
return { api, getAgent, listChildren, readSession, followup, parent }
}
describe('subagent gateway', () => {
it('lists the complete catalog and reports exact live-parent availability', async () => {
const { api, listChildren } = bench({ parentLive: false, entries: [
{
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
activity: 'inactive', hasChildren: true,
},
{
kind: 'child', id: sid('one-shot'), mode: 'one-shot',
activity: 'inactive', hasChildren: false,
},
{ kind: 'diagnostic', id: sid('bad'), reason: 'corrupt' },
] })
const response = await api.subagents.list(request({ parentSessionId: PARENT }))
expect(response.rpcId).toBe('subagent-rpc')
expect(response.result).toMatchObject({
ok: true,
value: {
parentAvailable: false,
entries: [
{ kind: 'child', mode: 'continuable' },
{ kind: 'child', mode: 'one-shot' },
{ kind: 'diagnostic' },
],
},
})
expect(listChildren).toHaveBeenCalledWith(PARENT, undefined)
})
it('derives catalog activity from the live child Agent rather than Session residency', async () => {
const residentIdle = bench({ childStatus: 'idle', entries: [{
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: false,
}] })
expect((await residentIdle.api.subagents.list(request({ parentSessionId: PARENT }))).result)
.toMatchObject({ ok: true, value: { entries: [{ activity: 'inactive' }] } })
const running = bench({ childStatus: 'running' })
expect((await running.api.subagents.list(request({ parentSessionId: PARENT }))).result)
.toMatchObject({ ok: true, value: { entries: [{ activity: 'running' }] } })
})
it('reads a healthy direct child without looking up or activating any Agent', async () => {
const { api, getAgent, readSession } = bench()
const response = await api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', maxMessages: 10,
}))
expect(response.result).toMatchObject({
ok: true,
value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
})
expect(readSession).toHaveBeenCalledWith(CHILD)
expect(getAgent).not.toHaveBeenCalled()
})
it('reads one-shot history and rejects an address with the wrong mode', async () => {
const oneShot = {
kind: 'child', id: CHILD, mode: 'one-shot', label: 'batch',
activity: 'inactive', hasChildren: false,
}
const { api, readSession } = bench({ entries: [oneShot] })
expect((await api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'one-shot',
}))).result).toMatchObject({ ok: true })
expect((await api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
}))).result).toMatchObject({ ok: false, error: { code: 'subagent-not-found' } })
expect(readSession).toHaveBeenCalledTimes(1)
})
it('rejects a diagnostic address before reading history', async () => {
const { api, readSession } = bench({ entries: [
{ kind: 'diagnostic', id: CHILD, reason: 'unsupported' },
] })
const response = await api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
}))
expect(response.result).toMatchObject({
ok: false,
error: {
code: 'subagent-catalog-diagnostic',
details: { parentSessionId: PARENT, childSessionId: CHILD, reason: 'unsupported' },
},
})
expect(readSession).not.toHaveBeenCalled()
})
it('routes human content through the exact live parent with rpc attribution', async () => {
const { api, parent, followup } = bench()
const content = [{ type: 'text' as const, text: '继续' }]
const signal = new AbortController().signal
const response = await api.subagents.prompt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content,
}), signal)
expect(response.result).toMatchObject({
ok: true, value: { messageId: 'message-1' },
})
expect(followup).toHaveBeenCalledWith(
parent,
CHILD,
content,
{ source: { kind: 'user', rpcId: RpcId('subagent-rpc') }, signal },
)
})
it('fails before delivery when the parent is absent and maps continuation failures', async () => {
const absent = bench({ parentLive: false })
expect((await absent.api.subagents.prompt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
}), new AbortController().signal)).result).toMatchObject({
ok: false, error: { code: 'subagent-parent-unavailable' },
})
expect(absent.listChildren).not.toHaveBeenCalled()
const failed = bench({ followupError: new SubagentError('draining', 'DRAINING') })
expect((await failed.api.subagents.prompt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
}), new AbortController().signal)).result).toMatchObject({
ok: false, error: { code: 'subagent-delivery-unavailable' },
})
})
it('maps history disappearance and hides unexpected backend details', async () => {
const disappeared = bench({
readError: new SessionQueryError('secret path', 'SESSION_QUERY_SESSION_NOT_FOUND'),
})
expect((await disappeared.api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
}))).result).toMatchObject({
ok: false,
error: {
code: 'subagent-not-found',
message: 'subagent disappeared during history read',
details: { parentSessionId: PARENT, childSessionId: CHILD },
},
})
const catalog = bench({ listError: new Error('secret descriptor') })
expect((await catalog.api.subagents.list(request({
parentSessionId: PARENT,
}))).result).toMatchObject({
ok: false,
error: { code: 'internal', message: 'subagent catalog read failed' },
})
const prompt = bench({ followupError: new Error('secret provider') })
expect((await prompt.api.subagents.prompt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
}), new AbortController().signal)).result).toMatchObject({
ok: false,
error: { code: 'internal', message: 'subagent prompt failed' },
})
})
})

View File

@@ -49,7 +49,7 @@ function stubAgent(session: Session): Agent {
ctx: new Context(),
send: () => {},
followup: () => {},
steer: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
cancel() {},
whenIdle: () => Promise.resolve(),
@@ -362,6 +362,36 @@ describe('session creation and Workspace membership', () => {
})
describe('Host Workspace increments', () => {
it('projects subagent origin in attached summaries and creation increments', async () => {
const { api, ctx } = await harness()
const abort = new AbortController()
const stream: AsyncIterator<RpcRequest<HostFrame>> =
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
const pending = nextHostFrame(stream)
const childId = SessionId('session-subagent-child')
ctx.sessions.create(childId, {
meta: {
cwd: '/tmp',
parentSession: SessionId('session-parent'),
origin: 'subagent',
},
})
expect(await pending).toMatchObject({
payload: {
type: 'host/session-added',
sessionId: childId,
parentSessionId: 'session-parent',
origin: 'subagent',
},
})
expect(expectOk(await api.sessions.list(request({}))).items).toContainEqual(
expect.objectContaining({ sessionId: childId, origin: 'subagent' }),
)
abort.abort()
})
it('streams committed Workspace and Session increments after empty baselines', async () => {
const { api } = await harness()
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])

View File

@@ -19,6 +19,7 @@ function ok<T>(request: RpcRequest<unknown>, value: T): Promise<RpcResponse<T>>
/** Scripted impl: every method resolves an empty-ish OK unless a case overrides it. */
function scriptedApi(overrides: {
sessions?: Partial<ApiProxy['sessions']>
subagents?: Partial<ApiProxy['subagents']>
host?: Partial<ApiProxy['host']>
commands?: Partial<ApiProxy['commands']>
skills?: Partial<ApiProxy['skills']>
@@ -57,6 +58,12 @@ function scriptedApi(overrides: {
cancel: r => ok(r, { accepted: true as const }),
...overrides.sessions,
},
subagents: {
list: r => ok(r, { entries: [], parentAvailable: false }),
history: r => ok(r, { events: [], hasMore: false }),
prompt: r => ok(r, { messageId: 'message-1' as never }),
...overrides.subagents,
},
host: {
describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }),
pickDirectory: r => ok(r, { path: null }),

View File

@@ -103,6 +103,31 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
},
subagents: {
async list(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { entries: [], parentAvailable: false } } }
},
async history(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { events: [], hasMore: false } } }
},
async prompt(request, signal) {
if (request.payload.content.some(block => block.type === 'text' && block.text === 'hang')) {
if (!signal.aborted) {
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
}
return {
rpcId: request.rpcId,
result: { ok: false, error: { code: 'cancelled' as const, message: 'aborted', details: {} } },
}
}
return {
rpcId: request.rpcId,
result: { ok: true, value: { messageId: 'message-1' as never } },
}
},
},
host: {
async describe(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } }
@@ -386,6 +411,23 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
}
})
it('round-trips the subagent domain through the wire form', async () => {
const c = client()
expect((await c.subagents.list({ parentSessionId: 'parent' as never })).result)
.toEqual({ ok: true, value: { entries: [], parentAvailable: false } })
expect((await c.subagents.history({
parentSessionId: 'parent' as never,
childSessionId: 'child' as never,
mode: 'one-shot',
})).result).toEqual({ ok: true, value: { events: [], hasMore: false } })
expect((await c.subagents.prompt({
parentSessionId: 'parent' as never,
childSessionId: 'child' as never,
mode: 'continuable',
content: [],
})).result).toEqual({ ok: true, value: { messageId: 'message-1' } })
})
it('keeps caller and connection aborts on command.execute', async () => {
const api = fakeApi()
const started = Promise.withResolvers<AbortSignal>()
@@ -451,6 +493,34 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
expect(parsed.result.error?.code).toBe('cancelled')
})
it('propagates the carrier Request signal into subagent.prompt', async () => {
const handler = toFetchHandler(fakeApi())
const controller = new AbortController()
const body = JSON.stringify({
type: 'client-request',
rpcId: 'r-subagent-sig',
method: 'subagent.prompt',
payload: {
parentSessionId: 'parent',
childSessionId: 'child',
mode: 'continuable',
content: [{ type: 'text', text: 'hang' }],
},
})
const pending = handler.fetch(new Request(
'http://x/api/subagent.prompt',
{ method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal },
))
controller.abort()
const response = await pending
const parsed = await response.json() as {
rpcId: string
result: { error?: { code: string } }
}
expect(parsed.rpcId).toBe('r-subagent-sig')
expect(parsed.result.error?.code).toBe('cancelled')
})
it('propagates the carrier Request signal into host.pickDirectory', async () => {
const api = fakeApi()
api.host.pickDirectory = async (request, signal) => {

View File

@@ -53,6 +53,12 @@
{
"path": "../../session-title/session-title"
},
{
"path": "../../session-query/session-query"
},
{
"path": "../../subagent/subagent"
},
{
"path": "../../skill/skill"
},

View File

@@ -61,11 +61,9 @@ export async function apply(ctx: Context): Promise<void> {
// nothing is left to unmount or await then.
const entry = ctx.loader.store[id]
if (entry === undefined) return
const fiber = entry.fiber
ctx.loader.remove(id)
// remove() only starts the fiber's dispose; join it so the chooser's
// unload signals completion only after the backend quiesced.
await fiber?.dispose()
// remove() disposes the entry transactionally, so the chooser's unload
// signals completion only after the backend quiesced.
await ctx.loader.remove(id)
}
}, 'directory-picker-auto: backend entry')
}

View File

@@ -167,7 +167,7 @@ describe('real Loader composition', () => {
const { ctx, configPath } = await loadComposition('127.0.0.1')
const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)!
ctx.loader.remove(backendEntry.id)
await ctx.loader.remove(backendEntry.id)
const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow()
expect(entryNames(ctx)).not.toContain(NATIVE)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/webserver/README.md
README.md: ace8c09e43dd8544a28d300f97b04610be78bc69
README.zh.md: b9948e3d387a5da393ff62b9eeacfe310516f46a
README.md: c3c7b222683bc7731a6c21f2fffd325225099bab
README.zh.md: 99c0560eb74dc8076772ba1deef3034000f5f0db

View File

@@ -6,7 +6,7 @@ Plain HTTP route-registration plugin (default-exported `HttpServerService`, conf
The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
A listen failure (EADDRINUSE…) throws out of activation — a FAILED fiber the boot's fail-loud sweep reports. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. Disposal pairs `close()` with `closeAllConnections()` because held-open responses (SSE) never end on their own.
A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. Disposal pairs `close()` with `closeAllConnections()` because held-open responses (SSE) never end on their own.
In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. Each rescan stages its candidate table, graph, and watch map before publishing them, so a baseline failure preserves the prior graph. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window marks the path dirty, retains the last successful baseline, and forces a re-hash when the bundle reappears even with identical metadata.

View File

@@ -6,7 +6,7 @@
该包不了解任何 harness 概念:`/api` 桥接是 connection 插件的路由,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的路由。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。
监听失败(EADDRINUSE……)会从激活过程抛出,使 fiber 进入 FAILED 状态并由启动流程的快速失败扫描报告。处理请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。资源释放会把 `close()` 与 `closeAllConnections()` 配对,因为一直保持打开的响应(SSE)不会自行结束。
监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。资源释放会把 `close()` 与 `closeAllConnections()` 配对,因为一直保持打开的 SSE(Server-Sent Events)响应不会自行结束。
在开发环境中,客户端插件注册表会在返回前同步捕获每个已构建 bundle 的 stat 基线,随后轮询这些基线,并在内容变化后重新计算哈希。每次重新扫描都会先暂存候选表、图和监听 map,再统一发布,因此基线失败会保留先前的图。这样,即时重建不会消失在异步建立的监听基线中;重命名窗口会把路径标记为脏,保留最近一次成功基线,并在 bundle 重新出现时强制重新计算哈希,即使其元数据完全相同也不例外。

View File

@@ -11,7 +11,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { Context, FiberState } from 'cordis'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import HttpServer from '../src/index.ts'
@@ -111,6 +111,8 @@ describe('real Loader composition', () => {
// Static fallback semantics: real asset served, traversal 403, non-GET/
// HEAD without a matching route 405.
expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export {}' })
await writeFile(join(root!, 'dist', 'app.js'), 'export const rebuilt = true')
expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export const rebuilt = true' })
expect((await request(port, '/..%2f..%2fetc%2fpasswd')).status).toBe(403)
expect((await request(port, '/nowhere', { method: 'POST' })).status).toBe(405)
@@ -140,25 +142,17 @@ describe('real Loader composition', () => {
const firstRoot = root
root = undefined // keep the first composition's files until the end
// loader.await() never rejects (allSettled); the bind failure surfaces as
// a FAILED fiber whose error escapes as a late rejection — the shape the
// boot's installFailLoud is contracted to catch. Capture it here the same
// way, and assert it really is the bind error.
const rejections: unknown[] = []
const onUnhandled = (err: unknown): void => { rejections.push(err) }
process.on('unhandledRejection', onUnhandled)
let second: Context | undefined
try {
second = await loadComposition(takenPort)
const entry = [...second.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-host-webserver')
expect(entry?.fiber?.state).toBe(FiberState.FAILED)
// The rejection escapes a tick after loader.await() settles; bounded poll.
for (let i = 0; i < 100 && rejections.length === 0; i++) {
await new Promise(resolve => setTimeout(resolve, 10))
let failure: unknown
try {
await loadComposition(takenPort)
} catch (error) {
failure = error
}
expect(rejections.map(String).join('\n')).toContain('EADDRINUSE')
second = context
expect(String(failure)).toMatch(/failed to apply loader entry.*EADDRINUSE/)
} finally {
process.off('unhandledRejection', onUnhandled)
await second?.fiber.dispose()
context = first
if (root !== undefined) await rm(root, { recursive: true, force: true })