feat(web): add subagent conversation transport
This commit is contained in:
@@ -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/client/ui-skill/README.md
|
||||
README.md: 2cb382f53466c07b977eef4d5a1ef2804c13abea
|
||||
README.zh.md: 2fc30da5e4c895027ab9dea78e9e3f86890cafc1
|
||||
README.md: fc83ae47dc83e72d60f382892aa678989902d217
|
||||
README.zh.md: 60f2c258acdb7e19148e05f19061e0e3f2c28ee7
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Skill reference source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}` — every session is agent-backed and the host resolves `cwd` from the session header. The host returns the intersection of model-invocable and user-invocable skills because this browser path lets a user insert a model reference rather than loading the body directly. Catalogs cache per session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`; picking a candidate lands the literal `/name ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` owns the reference's two projections: `clipboardText` → `/name`, `serialize` → the model form `<skill>name</skill>` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink.
|
||||
Skill reference source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host returns the intersection of model-invocable and user-invocable skills because this browser path inserts a model reference rather than loading the body directly. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`; picking a candidate lands the literal `/name ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` owns the reference's two projections: `clipboardText` → `/name`, `serialize` → the model form `<skill>name</skill>` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink.
|
||||
|
||||
A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址——每个会话始终由 agent(智能体)支撑,host 从会话 header 解析 `cwd`。宿主返回模型可调用与用户可调用 skill 的交集,因为该浏览器路径让用户插入模型引用,而不是直接加载正文。目录按会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 管线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText` → `/name`,`serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace`/`matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。
|
||||
skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主返回模型可调用与用户可调用 skill 的交集,因为该浏览器路径插入的是模型引用,而不是直接加载正文。由目录寻址的可继续子代理在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 管线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText` → `/name`,`serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace`/`matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。
|
||||
|
||||
`skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* with an aborted signal just returns early.
|
||||
*/
|
||||
import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
|
||||
/** One session's catalog fetch: the shared promise plus its own abort handle. */
|
||||
@@ -32,8 +32,8 @@ interface CatalogFetch {
|
||||
settled?: readonly SkillEntry[]
|
||||
}
|
||||
|
||||
/** Required services: the slash registry + the wire face the source closes over. */
|
||||
export const inject = ['slash', 'connection']
|
||||
/** Required services: slash registry, routed sessions, and the wire face. */
|
||||
export const inject = ['slash', 'connection', 'sessions']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the '/' skill source over the root wire face.
|
||||
@@ -41,6 +41,7 @@ export const inject = ['slash', 'connection']
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const skills = (ctx.get('connection') as ConnectionHandle).api.skills
|
||||
const sessions = ctx.get('sessions') as ISessions
|
||||
// Session-keyed catalog cache; single-flight per key. Plugin-closure state:
|
||||
// the fiber effect below is its teardown boundary.
|
||||
const fetches = new Map<SessionId, CatalogFetch>()
|
||||
@@ -61,6 +62,7 @@ export function apply(ctx: ClientContext): void {
|
||||
}
|
||||
|
||||
const fetchCatalog = (sessionId: SessionId): Promise<readonly SkillEntry[]> => {
|
||||
if (sessions.subagentAddress(sessionId) !== undefined) return Promise.resolve([])
|
||||
const existing = fetches.get(sessionId)
|
||||
if (existing !== undefined) return existing.promise
|
||||
const abort = new AbortController()
|
||||
|
||||
@@ -24,11 +24,16 @@ type ListResult =
|
||||
type ListFn = (payload: object, signal?: AbortSignal) => Promise<{ result: ListResult }>
|
||||
|
||||
/** Boot the plugin over fake slash/connection faces; returns the captured source and its ctx. */
|
||||
async function bench(list: ListFn) {
|
||||
async function bench(list: ListFn, addressed?: SessionId) {
|
||||
const ctx = new Context()
|
||||
let captured: SlashSource | undefined
|
||||
ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
|
||||
ctx.provide('connection', { api: { skills: { list } } })
|
||||
ctx.provide('sessions', {
|
||||
subagentAddress: (id: SessionId) => id === addressed
|
||||
? { parentSessionId: sid('parent'), childSessionId: id }
|
||||
: undefined,
|
||||
})
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
return { ctx, source: captured! }
|
||||
}
|
||||
@@ -60,7 +65,7 @@ const req = (query: string, signal?: AbortSignal) =>
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['slash', 'connection'])
|
||||
expect(inject).toEqual(['slash', 'connection', 'sessions'])
|
||||
})
|
||||
|
||||
it('registers the "/" skill source; disposal frees the name (HMR safety)', async () => {
|
||||
@@ -106,6 +111,14 @@ describe('candidates: sessionId addressing', () => {
|
||||
await expect(source.candidates(proj('s1'), req('co')))
|
||||
.rejects.toThrow('skill.list failed: internal: boom')
|
||||
})
|
||||
|
||||
it('does not fetch Agent-bound skills for an addressed child', async () => {
|
||||
const { list, payloads } = countingList()
|
||||
const { source } = await bench(list, sid('child'))
|
||||
await expect(source.candidates(proj('child'), req(''))).resolves.toEqual([])
|
||||
source.warm!(proj('child'))
|
||||
expect(payloads).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('catalog cache', () => {
|
||||
|
||||
Reference in New Issue
Block a user