Merge remote-tracking branch 'origin/master' into worktree-i18n-update-workflow
# Conflicts: # .agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml # .agents/skills/dsh-translate-docs/SKILL.md # docs/i18n/README.i18n.yaml
This commit is contained in:
6
packages/client/ui-subagent/README.i18n.yaml
Normal file
6
packages/client/ui-subagent/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
README.md: 7a70add139eae7bc507469b4fe7170359efdec31
|
||||
README.zh.md: 2d8ee677c71179df88211d90120a6017ceac8f6a
|
||||
31
packages/client/ui-subagent/README.md
Normal file
31
packages/client/ui-subagent/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# @deepseek-ai/dsh-client-ui-subagent
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Subagent reference source, browser half: registers the `@`-trigger `subagent` source into `ctx.slash`. Candidates are zero-RPC — filtered from the root `ctx.sessions.list` snapshot captured at registration (children of the per-call projection's session: `parentId` matches, `running`, `displayTitle` contains the query); picking a candidate lands the literal `@label ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` projects both faces as `@label` — the model serialization stays the raw label until the `@` consumption feature defines a model representation. The source implements no `matchSpace`/`matchEnter` hooks — subagent references never enter command adjudication and ride ordinary prompts into the default sink.
|
||||
|
||||
A session with no running children is simply candidate-less. This phase ships "menu + reference text" only; what consuming an `@label` means (steering the child, resuming a disposed one) is future business work.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`) only; the source object is internal to the registration effect.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Subagent label text in the user prompt
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A picked candidate lands the literal `@label` (the child session's display title) in the draft; the text reaches the model verbatim inside the ordinary user message (`session.prompt`), with no dedicated content block, prompt section, or host-side resolution. No consumption semantics exist yet: the model sees plain text and interprets it unaided.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Conditional and tiny: only a pick (or hand-typing the same text) adds the label's characters to that one user message. Menu browsing adds zero model tokens (candidates never leave the browser).
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only: the reference is part of a new user message appended after the reusable history prefix. This package never edits earlier request tokens.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`@` consumption semantics are unbuilt** — the reference is inert text; wiring it to steer/message the named child (and whether resuming a disposed child is allowed) awaits its own design decision in the ledger.
|
||||
- **Candidates are running children only** — completed or disposed subagents never appear, and the roster is the scoped session's direct children (no grandchildren, no cross-session agents).
|
||||
- **Labels are display titles, not stable ids** — two children sharing a display title produce indistinguishable references, and a title change orphans previously inserted text. Acceptable while references are inert; a consumption feature must bind to session ids.
|
||||
31
packages/client/ui-subagent/README.zh.md
Normal file
31
packages/client/ui-subagent/README.zh.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# @deepseek-ai/dsh-client-ui-subagent
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
subagent 引用 source 的浏览器半侧:把 `@` 触发的 `subagent` source 注册进 `ctx.slash`。候选零 RPC——从注册时捕获的根 `ctx.sessions.list` 快照过滤(每次调用的投影所指会话的子会话:`parentId` 匹配、`running`、`displayTitle` 包含 query);pick 一个候选会把字面文本 `@label ` 经 slash 管线落进草稿(决策 21 的纯文本引用),source 的 `codec` 把两种投影都产出为 `@label`——在 `@` 消费功能定义模型表示之前,模型序列化保持原始 label。source 不实现 `matchSpace`/`matchEnter` 钩子——subagent 引用永不进入命令裁决,随普通提示词落入 default sink。
|
||||
|
||||
没有运行中子会话的会话就是没有候选。本阶段只交付「菜单 + 引用文本」;消费一个 `@label` 意味着什么(对子会话做 steering(中途引导)、恢复已 dispose 的子会话)是未来的业务工作。
|
||||
|
||||
`/client` 导出表层只有插件主体(`apply`/`inject`);source 对象是注册 effect 的内部实现。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 用户提示词中的 subagent label 文本
|
||||
|
||||
#### 模型所见
|
||||
|
||||
被 pick 的候选会把字面文本 `@label`(子会话的显示标题)落进草稿;该文本原样进入普通用户消息(`session.prompt`)到达模型,没有专用内容块、提示词 section 或 host 侧解析。目前不存在任何消费语义:模型看到的是纯文本,只能自行解读。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
有条件且极小:只有 pick(或手动键入相同文本)会把 label 的字符加进那一条用户消息。浏览菜单增加零模型 token(候选永不离开浏览器)。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加:引用是追加在可复用历史前缀之后的新用户消息的一部分。该包绝不改写较早的请求 token。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **`@` 消费语义尚未构建**:引用只是惰性文本;把它接到对指名子会话的 steering/发消息(以及是否允许恢复已 dispose 的子会话),等待台账中它自己的设计决策。
|
||||
- **候选只有运行中的子会话**:已完成或已 dispose 的 subagent 永不出现,roster 只含 scope 所指会话的直接子会话(不含孙辈,不含跨会话 agent)。
|
||||
- **label 是显示标题,不是稳定 id**:两个子会话共用一个显示标题时,产生的引用无法区分;标题变更会使先前插入的文本失去指向。引用还是惰性文本时可以接受;消费功能必须绑定到会话 id。
|
||||
59
packages/client/ui-subagent/package.json
Normal file
59
packages/client/ui-subagent/package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-subagent",
|
||||
"description": "Subagent reference source: '@' menu candidates from the session snapshot (zero RPC), inserts @label references",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-slash"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
]
|
||||
}
|
||||
58
packages/client/ui-subagent/src/client/index.ts
Normal file
58
packages/client/ui-subagent/src/client/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Subagent reference plugin, browser half: registers the '@' source —
|
||||
* candidates filtered from the session list snapshot's running children
|
||||
* (zero RPC; the list rides the plugin's root-context sessions service, the
|
||||
* scoped session comes from the per-call projection), pick inserts the
|
||||
* literal `@label ` text (decision 21: the draft carries plain text, chip
|
||||
* visuals are derived by scanning against the source lexicon, and the
|
||||
* prompt ships the same literal). Consumption semantics stay with future
|
||||
* business work (design ledger). No adjudication hooks: subagent
|
||||
* references never enter command adjudication.
|
||||
*/
|
||||
import type { ClientContext, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientSessionContext, SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
|
||||
/** Required services: the slash registry + the session list face the source closes over. */
|
||||
export const inject = ['slash', 'sessions']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the '@' subagent source over the root session list.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const sessions = ctx.get('sessions') as SessionsService
|
||||
// Child labels live on the session list (parentId lineage + displayTitle),
|
||||
// not the conversation snapshot — the list store is the zero-RPC candidate feed.
|
||||
const childLabels = (session: ClientSessionContext, query: string): string[] => {
|
||||
const { byId } = sessions.list.getSnapshot()
|
||||
return Object.values(byId)
|
||||
.filter(child => child.parentId === session.sessionId && child.running && child.displayTitle.includes(query))
|
||||
.map(child => child.displayTitle)
|
||||
}
|
||||
const source: SlashSource = {
|
||||
trigger: '@',
|
||||
name: 'subagent',
|
||||
candidates(session, { query }) {
|
||||
return Promise.resolve(childLabels(session, query).map(name => ({ name })))
|
||||
},
|
||||
lexicon(session) {
|
||||
// The list snapshot is always warm — the full running-children roster.
|
||||
return childLabels(session, '')
|
||||
},
|
||||
onPick({ candidate }) {
|
||||
// Decision 21: plain-text reference — the literal lands in the draft
|
||||
// and ships to the model verbatim (trailing space closes the token).
|
||||
// Legacy path (decision 21), retained for the removal cut, no longer reached:
|
||||
// return { insert: { source: 'subagent', ref: candidate.name, label: candidate.name, clipboardText: `@${candidate.name}` } }
|
||||
return { text: `@${candidate.name} ` }
|
||||
},
|
||||
codec: {
|
||||
clipboardText: ref => `@${ref}`,
|
||||
// TODO: serialize returns the raw label until the '@' consumption
|
||||
// feature defines a model representation (design ledger).
|
||||
serialize: ref => Promise.resolve(`@${ref}`),
|
||||
},
|
||||
}
|
||||
const slash = ctx.get('slash') as SlashServiceContract
|
||||
ctx.effect(() => slash.registerSource(source), 'ui-subagent: @ source')
|
||||
}
|
||||
6
packages/client/ui-subagent/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-subagent/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
9
packages/client/ui-subagent/src/index.ts
Normal file
9
packages/client/ui-subagent/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Subagent reference plugin, node half. Pure UI plugin: the empty apply
|
||||
* exists so the plugin appears in the host cordis.yml / Loader; the browser
|
||||
* half ships via exports["./client"], discovered through the package.json
|
||||
* dshClient declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this source plugin. */
|
||||
export function apply(): void {}
|
||||
31
packages/client/ui-subagent/src/invariant.ts
Normal file
31
packages/client/ui-subagent/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-subagent`.
|
||||
* @module @deepseek-ai/dsh-client-ui-subagent/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-subagent'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-subagent-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: a single slash-source registration whose disposal is
|
||||
* proven by the HMR-safety spec — it emits no cordis events and owns no
|
||||
* cross-plugin mutable state.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
145
packages/client/ui-subagent/tests/browser-plugin.spec.ts
Normal file
145
packages/client/ui-subagent/tests/browser-plugin.spec.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* ui-subagent browser half: source registration (duplicate-name proof) +
|
||||
* fiber-teardown removal (HMR safety) against the real SlashService, then
|
||||
* the source behavior contract driven directly on the captured source with
|
||||
* real ClientSessionContext projections — zero-RPC candidates from the root
|
||||
* session list (running children of the projected session, label-contains
|
||||
* filtering, childless session → empty), the synchronous lexicon roster,
|
||||
* pick → plain-text outcome (decision 21), and the reference codec's two
|
||||
* projections. Direct driving is deliberate: this spec owns only the
|
||||
* source's own contract.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
|
||||
function summary(partial: Partial<SessionSummary> & { id: SessionId }): SessionSummary {
|
||||
return {
|
||||
displayTitle: partial.id,
|
||||
running: false,
|
||||
updatedAt: 0,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
|
||||
/** Fake root sessions face: the list snapshot the source closes over. */
|
||||
function sessionsWith(sessions: SessionSummary[]) {
|
||||
const byId: Record<string, SessionSummary> = {}
|
||||
for (const s of sessions) byId[s.id] = s
|
||||
const snapshot = { ids: sessions.map(s => s.id), byId, current: undefined } as unknown as SessionListState
|
||||
return { list: { getSnapshot: () => snapshot } }
|
||||
}
|
||||
|
||||
/** Boot the plugin over fake slash/sessions faces; returns the captured source. */
|
||||
async function bench(sessions: SessionSummary[]): Promise<SlashSource> {
|
||||
const ctx = new Context()
|
||||
let captured: SlashSource | undefined
|
||||
ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
|
||||
ctx.provide('sessions', sessionsWith(sessions))
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
return captured!
|
||||
}
|
||||
|
||||
const FAMILY: SessionSummary[] = [
|
||||
summary({ id: sid('parent'), displayTitle: 'parent', running: true }),
|
||||
summary({ id: sid('c1'), parentId: sid('parent'), displayTitle: 'worker-1', running: true }),
|
||||
summary({ id: sid('c2'), parentId: sid('parent'), displayTitle: 'worker-2', running: true }),
|
||||
// Filtered out: not running / other parent / label miss.
|
||||
summary({ id: sid('c3'), parentId: sid('parent'), displayTitle: 'worker-3', running: false }),
|
||||
summary({ id: sid('c4'), parentId: sid('other'), displayTitle: 'worker-4', running: true }),
|
||||
summary({ id: sid('c5'), parentId: sid('parent'), displayTitle: 'scout', running: true }),
|
||||
]
|
||||
|
||||
const proj = (id: string): ClientSessionContext => ({ sessionId: sid(id) })
|
||||
|
||||
const req = (query: string) =>
|
||||
({ query, position: 'inline' as const, signal: new AbortController().signal })
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['slash', 'sessions'])
|
||||
})
|
||||
|
||||
it('registers the "@" subagent source; disposal frees the name (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlashService).await()
|
||||
ctx.provide('sessions', sessionsWith(FAMILY))
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const slash = ctx.get('slash') as SlashService
|
||||
const rival = {
|
||||
trigger: '@' as const,
|
||||
name: 'subagent',
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
}
|
||||
// Live registration holds the (trigger, name) seat…
|
||||
expect(() => slash.registerSource(rival)).toThrow(/already registered/)
|
||||
// …and fiber teardown releases it.
|
||||
await fiber.dispose()
|
||||
expect(() => slash.registerSource(rival)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('candidates', () => {
|
||||
it('returns running children of the projected session, filtered by label containment', async () => {
|
||||
const source = await bench(FAMILY)
|
||||
await expect(source.candidates(proj('parent'), req('worker'))).resolves.toEqual([
|
||||
{ name: 'worker-1' }, { name: 'worker-2' },
|
||||
])
|
||||
})
|
||||
|
||||
it('matches every running child on an empty query (containment, not prefix)', async () => {
|
||||
const source = await bench(FAMILY)
|
||||
await expect(source.candidates(proj('parent'), req(''))).resolves.toEqual([
|
||||
{ name: 'worker-1' }, { name: 'worker-2' }, { name: 'scout' },
|
||||
])
|
||||
})
|
||||
|
||||
it('is candidate-less for a session with no children', async () => {
|
||||
const source = await bench(FAMILY)
|
||||
await expect(source.candidates(proj('childless'), req(''))).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('lexicon', () => {
|
||||
it('synchronously serves the projected session\'s full running-children roster', async () => {
|
||||
const source = await bench(FAMILY)
|
||||
expect(source.lexicon!(proj('parent'))).toEqual(['worker-1', 'worker-2', 'scout'])
|
||||
expect(source.lexicon!(proj('childless'))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pick and codec', () => {
|
||||
it('onPick returns the literal @label text with a closing space (decision 21)', async () => {
|
||||
const source = await bench(FAMILY)
|
||||
const outcome = source.onPick({
|
||||
candidate: { name: 'worker-1' },
|
||||
session: proj('parent'),
|
||||
position: 'inline',
|
||||
via: 'menu',
|
||||
span: { start: 4, end: 8, draftRev: 3 },
|
||||
})
|
||||
expect(outcome).toEqual({ text: '@worker-1 ' })
|
||||
})
|
||||
|
||||
it('codec projects clipboard `@label` and serializes the same raw label this phase', async () => {
|
||||
const source = await bench(FAMILY)
|
||||
expect(source.codec!.clipboardText('worker-1')).toBe('@worker-1')
|
||||
await expect(source.codec!.serialize('worker-1', new AbortController().signal))
|
||||
.resolves.toBe('@worker-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('adjudication', () => {
|
||||
it('never participates: no matchSpace/matchEnter hooks on the subagent source', async () => {
|
||||
const source = await bench(FAMILY)
|
||||
expect('matchSpace' in source && source.matchSpace !== undefined).toBe(false)
|
||||
expect('matchEnter' in source && source.matchEnter !== undefined).toBe(false)
|
||||
})
|
||||
})
|
||||
27
packages/client/ui-subagent/tsconfig.json
Normal file
27
packages/client/ui-subagent/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slash"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-subagent/tsdown.config.ts
Normal file
3
packages/client/ui-subagent/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-subagent', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
Reference in New Issue
Block a user