Merge remote-tracking branch 'origin/master' into feat/web-workspace-file-links

# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/chat/ToolRow.module.css
#	packages/host/apiproxy/src/native-path-opener.ts
This commit is contained in:
imccyu
2026-08-07 16:49:20 +08:00
604 changed files with 21472 additions and 2659 deletions

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/apiproxy/README.md
README.md: 0963476a767801b465a6ead24feb0ecc9988b5f5
README.zh.md: e3634c5f92f3a3723eb3c14e39223d9d9550c6f9
README.md: bb805fbd779fedc01bc3db5d970e9e4119876a55
README.zh.md: 99b80908d3d37d2a8ae0a60dd45c84ce5d937474

View File

@@ -32,7 +32,7 @@ A stale continuation discards every partial result, deduplication entry, and cur
Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method does not use the default 30-second unary timeout, while caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request.
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.

View File

@@ -32,7 +32,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,不使用默认的 30 秒一元调用超时,而调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable``directory-exists``directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏dsh-client-connection像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径macOS 为 `open`Windows 为 `Invoke-Item`Linux 为 `xdg-open`)。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径macOS 为 `open`Windows 为 `Invoke-Item`桌面 Linux 为 `xdg-open`)。对于 `.html``.htm``.xhtml``.svg`macOS 和桌面 Linux 会优先使用能够确定的默认浏览器无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent被服务的会话必有 Agent`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。

View File

@@ -527,6 +527,28 @@ function detachedProjectionsFor(
return registry.restore({}, events, 0).snapshot
}
/**
* Best-effort projections for one subagent history page, fail-soft like
* {@link listProjectionsFor}: a registered unit throwing on a corrupt payload
* never blocks transcript reading — the page is served without the block.
* @param ctx - context carrying the logger for the degradation warning.
* @param childSessionId - the child whose page is being decorated.
* @param compute - the arm-specific fold (live watermark or detached restore).
* @returns the projections block, or undefined when the fold failed.
*/
function subagentHistoryProjections(
ctx: Context,
childSessionId: SessionId,
compute: () => SessionProjectionsBlock | undefined,
): SessionProjectionsBlock | undefined {
try {
return compute()
} catch (error) {
ctx.logger.warn(`subagent.history: projections for "${childSessionId}" failed (serving the page without them): ${String(error)}`)
return undefined
}
}
/** Map continuation admission failures without exposing provider details. */
function subagentPromptError(
request: RpcRequest<{ childSessionId: SessionId }>,
@@ -567,6 +589,15 @@ function subagentPromptError(
return err(request, { code: 'internal', message: 'subagent prompt failed', details: {} })
}
/** Stable RPC face of the missing projections capability, shared by every catalog read path. */
function projectionsUnavailableError(): RpcError {
return {
code: 'internal',
message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
details: {},
}
}
/** Verify one address and mode against the complete direct-child catalog. */
async function catalogChild(
ctx: Context,
@@ -600,19 +631,11 @@ async function catalogChild(
}
return { entry }
} catch (error: unknown) {
if (signal?.aborted
|| (error instanceof SubagentError && error.code === 'CANCELLED')
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) {
return { error: { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} } }
}
if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
return {
error: {
code: 'subagent-not-found',
message: `parent session "${parentSessionId}" was not found`,
details: { parentSessionId, childSessionId },
},
}
if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') {
return { error: projectionsUnavailableError() }
}
return { error: { code: 'internal', message: 'subagent catalog read failed', details: {} } }
}
@@ -925,28 +948,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
}
/** Whether the session's own suffix carries the durable subagent discriminator. */
function hasSubagentDescriptor(session: Pick<Session, 'events' | 'header'>): boolean {
const events = session.events
// Indexed scan from the own-suffix start: slicing copies the whole suffix
// on every Agent-bound RPC, including each `session.prompt` on long
// transcripts.
for (let index = session.header.seedLength ?? 0; index < events.length; index += 1) {
if (events[index]?.type === 'subagent/descriptor') return true
}
return false
}
/**
* Generic Host interaction cannot claim a durably classified subagent or an
* Agent created through its live parent. The runtime-owner arm also covers
* descriptor-less child publication windows and older stored headers.
* Generic Host interaction cannot claim a durably classified subagent
* (`origin: 'subagent'` in the header) or an Agent runtime-owned by its
* live parent.
*/
function hasSubagentOwner(
session: Pick<Session, 'events' | 'header'>,
session: Pick<Session, 'header'>,
agent: Agent | undefined,
): boolean {
if (session.header.origin === 'subagent' || hasSubagentDescriptor(session)) return true
if (session.header.origin === 'subagent') return true
const parentId = session.header.parentSession
if (parentId === undefined || agent === undefined) return false
const parent = ctx.agents.get(parentId)
@@ -1002,7 +1013,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
resume = (async () => {
try {
const inspected = await inspectServable(sessionId)
if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) {
if (hasSubagentOwner({ header: inspected.meta }, undefined)) {
throw new SubagentSessionOwnership(sessionId)
}
const publishedSession = ctx.sessions.get(sessionId)
@@ -1121,7 +1132,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// Ownership first: explicit-id adoption of a session-backed
// subagent must answer `agent-busy` regardless of the requested
// cwd (the api/commands.ts contract), not a cwd conflict.
if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) {
if (hasSubagentOwner({ header: inspected.meta }, undefined)) {
throw new SubagentSessionOwnership(sessionId)
}
if (inspected.meta.cwd !== cwd) {
@@ -1903,15 +1914,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
parentAvailable: ctx.agents.get(request.payload.parentSessionId) !== undefined,
})
} catch (error: unknown) {
if (signal?.aborted
|| (error instanceof SubagentError && error.code === 'CANCELLED')
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) {
return err(request, {
code: 'cancelled',
message: 'subagent catalog read was cancelled',
details: {},
})
}
if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') {
return err(request, projectionsUnavailableError())
}
return err(request, {
code: 'internal',
message: 'subagent catalog read failed',
@@ -1928,44 +1940,65 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
parentSessionId, childSessionId, mode,
}, signal)
if (verified.error !== undefined) return err(request, verified.error)
try {
const snapshot = await ctx.sessionQuery.readSession(childSessionId)
signal?.throwIfAborted()
if (snapshot.session.parentSession !== parentSessionId) {
return err(request, {
code: 'subagent-unauthorized',
message: 'subagent parent changed during history read',
details: { childSessionId },
})
}
const page = historyPage(ctx, snapshot.events, beforeSeq, maxMessages)
const projections = beforeSeq === undefined
? detachedProjectionsFor(ctx, snapshot.events)
// The generic-history data plane: an attached child serves its
// in-memory snapshot and the registry's live watermark projections; a
// cold child is one persistence inspection plus a detached fold.
let header: SessionHeader
let events: SessionEvent[]
let projections: SessionProjectionsBlock | undefined
const attached = ctx.sessions.get(childSessionId)
if (attached !== undefined) {
header = attached.header
events = [...attached.events]
projections = beforeSeq === undefined
? subagentHistoryProjections(ctx, childSessionId, () => projectionsFor(ctx, attached))
: undefined
return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
} catch (error: unknown) {
if (signal?.aborted
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
} else {
try {
const inspected = await inspectServable(childSessionId)
header = inspected.meta
events = inspected.events
projections = beforeSeq === undefined
? subagentHistoryProjections(ctx, childSessionId, () => detachedProjectionsFor(ctx, inspected.events))
: undefined
} catch (error: unknown) {
if (signal?.aborted) {
return err(request, {
code: 'cancelled',
message: 'subagent history read was cancelled',
details: {},
})
}
if (error instanceof SessionNotFound) {
return err(request, {
code: 'subagent-not-found',
message: 'subagent disappeared during history read',
details: { parentSessionId, childSessionId },
})
}
return err(request, {
code: 'cancelled',
message: 'subagent history read was cancelled',
code: 'internal',
message: 'subagent history read failed',
details: {},
})
}
if (error instanceof SessionQueryError
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
return err(request, {
code: 'subagent-not-found',
message: 'subagent disappeared during history read',
details: { parentSessionId, childSessionId },
})
}
}
if (signal?.aborted) {
return err(request, {
code: 'internal',
message: 'subagent history read failed',
code: 'cancelled',
message: 'subagent history read was cancelled',
details: {},
})
}
if (header.parentSession !== parentSessionId) {
return err(request, {
code: 'subagent-unauthorized',
message: 'subagent parent changed during history read',
details: { childSessionId },
})
}
const page = historyPage(ctx, events, beforeSeq, maxMessages)
return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
},
async prompt(request, signal) {

View File

@@ -70,7 +70,8 @@ export interface SubagentsApi {
): Promise<RpcResponse<SubagentCatalog>>
/**
* Reads one healthy catalog child's persisted raw log with ordinary
* Reads one healthy catalog child's transcript — the in-memory snapshot of
* a live child, the persisted log of a cold one — with ordinary
* message-aligned pagination and render intents, without Agent activation.
*/
history(

View File

@@ -2,16 +2,13 @@
* Cross-platform native path and text-document openers used by the local GUI
* carrier.
*
* Under the default intent, a document a browser RENDERS is opened with the
* user's default browser rather than the default application for its type,
* when the platform can name one: a developer who binds `.html` to an editor
* would otherwise click a produced page and get source code. The contract is
* uniform — prefer the default browser, fall back to the default application —
* while how completely a platform can answer "which browser" differs, and
* every failure falls back rather than surfacing. The text-editor intent never
* consults the browser: it exists to open the document's TEXT.
* The default intent prefers the default browser for documents it renders when
* the platform can name one, then falls back to the default application. WSL
* translates every path for the Windows desktop instead of assuming a Linux
* GUI. The text-editor intent never consults the browser.
*/
import { release as osRelease } from 'node:os'
import { extname } from 'node:path'
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
@@ -21,9 +18,11 @@ export type PathOpenerRunner = NativeCommandRunner
/** Injectable platform facts for deterministic adapter tests. */
export interface PathOpenerInternals {
platform?: NodeJS.Platform
run?: PathOpenerRunner
/** Environment the linux browser convention reads; defaults to the process env. */
/** Kernel release override used to distinguish WSL from desktop Linux. */
osRelease?: string
/** Environment used for WSL markers and the desktop Linux browser convention. */
env?: NodeJS.ProcessEnv
run?: PathOpenerRunner
}
/** Documents a browser renders, as opposed to ones an editor merely edits. */
@@ -86,6 +85,36 @@ function powershellLiteral(path: string): string {
return `'${path.replace(/'/g, "''")}'`
}
/** Whether one environment marker is set to a non-empty value. */
function present(value: string | undefined): boolean {
return value !== undefined && value !== ''
}
/** Distinguish WSL from desktop Linux using its process and kernel markers. */
function isWsl(internals: PathOpenerInternals): boolean {
const env = internals.env ?? process.env
if (present(env.WSL_DISTRO_NAME) || present(env.WSL_INTEROP)) return true
return (internals.osRelease ?? osRelease()).toLowerCase().includes('microsoft')
}
/** Open one Windows-resolvable path through its registered desktop application. */
async function openWindowsPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise<void> {
await run('powershell.exe', [
'-NoProfile',
'-Command',
`Invoke-Item -LiteralPath ${powershellLiteral(path)}`,
], signal)
}
/** Translate a WSL path before handing it to the Windows desktop. */
async function openWslPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise<void> {
const translated = await run('wslpath', ['-w', path], signal)
signal.throwIfAborted()
const windowsPath = translated.stdout.replace(/[\r\n]+$/, '')
if (windowsPath === '') throw new Error('wslpath returned no Windows path')
await openWindowsPath(windowsPath, signal, run)
}
/** Dispatch one shell-free platform command for the requested open intent. */
async function openNativePathWithIntent(
path: string,
@@ -96,8 +125,9 @@ async function openNativePathWithIntent(
const platform = internals.platform ?? process.platform
const run = internals.run ?? runNativeCommand
const env = internals.env ?? process.env
const wsl = platform === 'linux' && isWsl(internals)
if (intent === 'default' && BROWSER_DOCUMENTS.has(extname(path).toLowerCase())
if (!wsl && intent === 'default' && BROWSER_DOCUMENTS.has(extname(path).toLowerCase())
&& await openInBrowser(path, signal, platform, run, env)) return
if (platform === 'darwin') {
@@ -106,15 +136,15 @@ async function openNativePathWithIntent(
}
if (platform === 'win32') {
await run('powershell.exe', [
'-NoProfile',
'-Command',
`Invoke-Item -LiteralPath ${powershellLiteral(path)}`,
], signal)
await openWindowsPath(path, signal, run)
return
}
if (platform === 'linux') {
if (wsl) {
await openWslPath(path, signal, run)
return
}
await run('xdg-open', [path], signal)
return
}

View File

@@ -190,6 +190,7 @@ describe('subagent ownership fence', () => {
const meta = header('session-child', 1000, {
parentSession: sid('session-parent'),
seedLength: 0,
origin: 'subagent',
})
const events = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
@@ -245,6 +246,47 @@ describe('subagent ownership fence', () => {
expect(inspect).toHaveBeenCalledTimes(3)
})
it('no longer treats a descriptor-only cold child without origin as subagent-owned', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const sessionId = sid('session-legacy-child')
const meta = header('session-legacy-child', 1000, {
parentSession: sid('session-parent'),
seedLength: 0,
})
const events = [
{
type: 'subagent/descriptor',
seq: 0,
time: 1,
data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' },
},
] as SessionEvent[]
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events }),
locate: () => undefined,
} as never)
// Pre-#1569 stores classify a child only through the descriptor event and
// carry no header `origin`; the pre-release decision stops recognizing
// them, so the ownership fence lets generic resume reach the registry
// instead of answering `agent-busy`.
const resume = vi.spyOn(ctx.agents, 'resume')
.mockRejectedValue(new Error('registry unavailable in this bench'))
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const prompt = await api.sessions.prompt(request({
sessionId,
mode: 'queue',
content: [{ type: 'text', text: 'follow up' }],
}))
expect(resume).toHaveBeenCalledTimes(1)
expect(prompt.result.ok).toBe(false)
if (!prompt.result.ok) expect(prompt.result.error.code).toBe('internal')
})
it('rejects origin-marked and runtime-owned live children from generic controls', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)

View File

@@ -1,7 +1,6 @@
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'
@@ -21,7 +20,12 @@ function bench(options: {
entries?: object[]
followupError?: Error
listError?: Error
readError?: Error
/** Persistence forgets the child entirely (the vanished-mid-read race). */
storedChild?: false
/** Attach the child to the live session store instead of persistence only. */
liveChild?: true
/** Every registered projection unit throws on this child's payloads. */
projectionsThrow?: true
historyParent?: SessionId
} = {}) {
const parent = { id: PARENT }
@@ -49,25 +53,44 @@ function bench(options: {
) => 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 childHeader = {
version: 0, id: CHILD, createdAt: 1, cwd: '/proj', parentSession: options.historyParent ?? PARENT,
} satisfies SessionHeader
const childEvents = [
{ type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } } },
] as unknown as SessionEvent[]
const inspect = vi.fn(() => Promise.resolve({ meta: childHeader, events: childEvents }))
const liveBlock = { values: {}, asOfSeq: 3 }
const coldBlock = { values: {}, asOfSeq: 0 }
const snapshot = vi.fn(() => {
if (options.projectionsThrow === true) throw new Error('hostile unit')
return liveBlock
})
const restore = vi.fn(() => {
if (options.projectionsThrow === true) throw new Error('hostile unit')
return { snapshot: coldBlock }
})
const ctx = new Context()
ctx.provide('agents', { get: getAgent })
ctx.provide('subagents', { listChildren, followup })
ctx.provide('sessionQuery', { readSession })
ctx.provide('sessions', {
get: (id: SessionId) => options.liveChild === true && id === CHILD
? { id: CHILD, header: childHeader, events: childEvents }
: undefined,
})
ctx.provide('sessionPersistence', {
list: () => Promise.resolve(options.storedChild === false ? [] : [childHeader]),
inspect,
locate: () => undefined,
})
// The gateway's own projection push feed subscribes at construction; the
// no-op disposer keeps that seam quiet while these tests pin history reads.
ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} })
ctx.provide('userInteraction', { registerProvider: () => () => {} })
const api = createApiProxy(ctx, {
provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp',
})
return { api, getAgent, listChildren, readSession, followup, parent }
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent }
}
describe('subagent gateway', () => {
@@ -113,7 +136,7 @@ describe('subagent gateway', () => {
})
it('reads a healthy direct child without looking up or activating any Agent', async () => {
const { api, getAgent, readSession } = bench()
const { api, getAgent, inspect, restore } = bench()
const response = await api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', maxMessages: 10,
}))
@@ -121,27 +144,65 @@ describe('subagent gateway', () => {
ok: true,
value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
})
expect(readSession).toHaveBeenCalledWith(CHILD)
expect(inspect).toHaveBeenCalledWith(CHILD)
expect(restore).toHaveBeenCalledTimes(1)
expect(getAgent).not.toHaveBeenCalled()
})
it('serves a live child from the in-memory snapshot and the watermark projections', async () => {
const { api, inspect, snapshot, restore } = bench({ liveChild: true })
const response = await api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
}))
expect(response.result).toMatchObject({
ok: true,
value: { hasMore: false, projections: { asOfSeq: 3 } },
})
expect(snapshot).toHaveBeenCalledTimes(1)
expect(restore).not.toHaveBeenCalled()
expect(inspect).not.toHaveBeenCalled()
})
it('serves the page without projections when a hostile unit breaks the fold', async () => {
const cold = bench({ projectionsThrow: true })
const coldResponse = await cold.api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
}))
expect(coldResponse.result).toMatchObject({
ok: true,
value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
})
if (coldResponse.result.ok) expect('projections' in coldResponse.result.value).toBe(false)
const live = bench({ projectionsThrow: true, liveChild: true })
const liveResponse = await live.api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
}))
expect(liveResponse.result).toMatchObject({
ok: true,
value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
})
if (liveResponse.result.ok) expect('projections' in liveResponse.result.value).toBe(false)
expect(live.snapshot).toHaveBeenCalledTimes(1)
})
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] })
const { api, inspect } = 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)
expect(inspect).toHaveBeenCalledTimes(1)
})
it('rejects a diagnostic address before reading history', async () => {
const { api, readSession } = bench({ entries: [
const { api, inspect } = bench({ entries: [
{ kind: 'diagnostic', id: CHILD, reason: 'unsupported' },
] })
const response = await api.subagents.history(request({
@@ -154,7 +215,34 @@ describe('subagent gateway', () => {
details: { parentSessionId: PARENT, childSessionId: CHILD, reason: 'unsupported' },
},
})
expect(readSession).not.toHaveBeenCalled()
expect(inspect).not.toHaveBeenCalled()
})
it('maps the missing projections capability to one wire face on list, history, and prompt', async () => {
const listError = () => new SubagentError(
'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE',
)
const expected = {
code: 'internal',
message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
}
const list = bench({ listError: listError() })
expect((await list.api.subagents.list(request({ parentSessionId: PARENT }))).result)
.toMatchObject({ ok: false, error: expected })
const history = bench({ listError: listError() })
expect((await history.api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
}))).result).toMatchObject({ ok: false, error: expected })
expect(history.inspect).not.toHaveBeenCalled()
const prompt = bench({ listError: listError() })
expect((await prompt.api.subagents.prompt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
}), new AbortController().signal)).result).toMatchObject({ ok: false, error: expected })
expect(prompt.followup).not.toHaveBeenCalled()
})
it('routes human content through the exact live parent with rpc attribution', async () => {
@@ -193,9 +281,7 @@ describe('subagent gateway', () => {
})
it('maps history disappearance and hides unexpected backend details', async () => {
const disappeared = bench({
readError: new SessionQueryError('secret path', 'SESSION_QUERY_SESSION_NOT_FOUND'),
})
const disappeared = bench({ storedChild: false })
expect((await disappeared.api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
}))).result).toMatchObject({

View File

@@ -14,6 +14,7 @@ const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn<ExecFileMock>()
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
import { release as osRelease } from 'node:os'
import { describe, expect, it, vi } from 'vitest'
import { openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts'
@@ -34,10 +35,58 @@ describe('native path opener', () => {
it('uses the Linux desktop association for text documents', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativeTextFile('/tmp/settings.yaml', signal(), { platform: 'linux', run })
await openNativeTextFile('/tmp/settings.yaml', signal(), {
platform: 'linux', osRelease: '6.8.0-generic', env: {}, run,
})
expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/settings.yaml'], expect.any(AbortSignal))
})
it.each([
['distribution marker', { WSL_DISTRO_NAME: 'Ubuntu' }, '6.8.0-generic'],
['interop marker', { WSL_INTEROP: '/run/WSL/123_interop' }, '6.8.0-generic'],
['kernel release', {}, '5.15.153.1-microsoft-standard-WSL2'],
])('hands WSL text documents to the Windows desktop from the %s', async (_label, env, osRelease) => {
const requestSignal = signal()
const run = vi.fn<PathOpenerRunner>(async command => command === 'wslpath'
? { stdout: '\\\\wsl.localhost\\Ubuntu\\home\\test user\\settings.yaml\r\n', stderr: '' }
: { stdout: '', stderr: '' })
await openNativeTextFile('/home/test user/settings.yaml', requestSignal, {
platform: 'linux', osRelease, env, run,
})
expect(run.mock.calls).toEqual([
['wslpath', ['-w', '/home/test user/settings.yaml'], requestSignal],
[
'powershell.exe',
[
'-NoProfile',
'-Command',
"Invoke-Item -LiteralPath '\\\\wsl.localhost\\Ubuntu\\home\\test user\\settings.yaml'",
],
requestSignal,
],
])
})
it('rejects an empty WSL path translation before invoking Windows', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '\r\n', stderr: '' }))
await expect(openNativeTextFile('/home/test/settings.yaml', signal(), {
platform: 'linux', osRelease: '6.8.0-generic', env: { WSL_DISTRO_NAME: 'Ubuntu' }, run,
})).rejects.toThrow('wslpath returned no Windows path')
expect(run).toHaveBeenCalledOnce()
})
it('does not invoke Windows when the request aborts during WSL path translation', async () => {
const abort = new AbortController()
const run = vi.fn<PathOpenerRunner>(async () => {
abort.abort(new Error('closed'))
return { stdout: '\\\\wsl.localhost\\Ubuntu\\home\\test\\settings.yaml\n', stderr: '' }
})
await expect(openNativeTextFile('/home/test/settings.yaml', abort.signal, {
platform: 'linux', osRelease: '6.8.0-generic', env: { WSL_DISTRO_NAME: 'Ubuntu' }, run,
})).rejects.toThrow('closed')
expect(run).toHaveBeenCalledOnce()
})
it('opens with Windows Invoke-Item and escapes single quotes', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativePath("C:\\work\\o'reilly.txt", signal(), { platform: 'win32', run })
@@ -60,7 +109,10 @@ describe('native path opener', () => {
it('opens with Linux xdg-open', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativePath('/tmp/a.txt', signal(), { platform: 'linux', run })
await openNativePath('/tmp/a.txt', signal(), {
platform: 'linux', osRelease: '6.8.0-generic',
env: { WSL_DISTRO_NAME: '', WSL_INTEROP: '' }, run,
})
expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/a.txt'], expect.any(AbortSignal))
})
@@ -71,7 +123,9 @@ describe('native path opener', () => {
it('uses the current process platform when no platform override is supplied', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativePath('/tmp/platform-default.txt', signal(), { run })
await openNativePath('/tmp/platform-default.txt', signal(), {
osRelease: '6.8.0-generic', env: {}, run,
})
const expected = process.platform === 'win32'
? 'powershell.exe'
: process.platform === 'linux'
@@ -80,6 +134,17 @@ describe('native path opener', () => {
expect(run.mock.calls[0]?.[0]).toBe(expected)
})
it('samples ambient WSL markers and kernel release when no fact overrides are supplied', async () => {
const ambientWsl = [process.env.WSL_DISTRO_NAME, process.env.WSL_INTEROP]
.some(value => value !== undefined && value !== '')
|| osRelease().toLowerCase().includes('microsoft')
const run = vi.fn<PathOpenerRunner>(async command => command === 'wslpath'
? { stdout: 'C:\\settings.yaml\n', stderr: '' }
: { stdout: '', stderr: '' })
await openNativePath('/tmp/ambient-facts.yaml', signal(), { platform: 'linux', run })
expect(run.mock.calls[0]?.[0]).toBe(ambientWsl ? 'wslpath' : 'xdg-open')
})
it('runs the default command adapter without a shell and preserves command failures', async () => {
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
callback(null, '', '')
@@ -172,6 +237,7 @@ describe('browser-renderable documents', () => {
const linux: string[][] = []
await openNativePath('/w/page.html', new AbortController().signal, {
platform: 'linux',
osRelease: '6.8.0-generic',
env: { BROWSER: 'firefox' },
run: async (command, args) => { linux.push([command, ...args]); return { stdout: '', stderr: '' } },
})
@@ -181,6 +247,7 @@ describe('browser-renderable documents', () => {
const bare: string[][] = []
await openNativePath('/w/page.html', new AbortController().signal, {
platform: 'linux',
osRelease: '6.8.0-generic',
env: {},
run: async (command, args) => { bare.push([command, ...args]); return { stdout: '', stderr: '' } },
})
@@ -194,4 +261,29 @@ describe('browser-renderable documents', () => {
})
expect(win[0]?.[0]).toBe('powershell.exe')
})
it('hands browser-renderable WSL paths to the Windows desktop', async () => {
const calls: string[][] = []
await openNativePath('/home/test/page.html', new AbortController().signal, {
platform: 'linux',
osRelease: '5.15.153.1-microsoft-standard-WSL2',
env: { BROWSER: 'firefox' },
run: async (command, args) => {
calls.push([command, ...args])
return {
stdout: command === 'wslpath' ? 'C:\\workspace\\page.html\n' : '',
stderr: '',
}
},
})
expect(calls).toEqual([
['wslpath', '-w', '/home/test/page.html'],
[
'powershell.exe',
'-NoProfile',
'-Command',
"Invoke-Item -LiteralPath 'C:\\workspace\\page.html'",
],
])
})
})