Merge branch 'master' into feat/web-terminal-card
This commit is contained in:
@@ -11,10 +11,21 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-
|
||||
1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`.
|
||||
2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `<domain>.<entry>.<hole>` (e.g. `'conversation.chat.toolview'`).
|
||||
3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.
|
||||
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
|
||||
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five standing seats, plus the `use<Name>` hooks the renderer binds from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
|
||||
5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription.
|
||||
6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path).
|
||||
7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hooks, no ReactNode producers, no whole-service objects. Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for.
|
||||
7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact rides the reserved `hooks` compartment (bare observables the renderer binds to `use<Name>`; components never see the sources). Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for.
|
||||
|
||||
## Reactive read and contract-currency discipline
|
||||
|
||||
How live data reaches render code, and what may cross a business boundary:
|
||||
|
||||
1. **Everything a render reads that can change outside React arrives through a framework hook** (rule 4 above). Event-handler code may read live snapshots (e.g. `keyboard.snapshot`); render code subscribes.
|
||||
2. **Business components contain no subscription machinery** — no `useSyncExternalStore`, no manual subscribe wiring, no mirroring an external snapshot into local state or a second store. Give each reactive fact its owning channel instead: registrant-private → the inject `hooks` compartment; cross-entry or remount-surviving → a declared store; per-session standard → `sessions.provide`.
|
||||
3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use<Name>`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework seam and needs main-thread arbitration.
|
||||
4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is not a currency: route render content through a slot; no new ReactNode-valued owner props or inject members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` seats are grandfathered and get migrated to slots progressively).
|
||||
5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves).
|
||||
6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering.
|
||||
|
||||
## Export discipline (client plugin packages)
|
||||
|
||||
|
||||
@@ -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/runtime/README.md
|
||||
README.md: 81261945cb2fd8b15f7c2f15cb1ae0b8e9928499
|
||||
README.zh.md: cbbf6eded4a5375223791275f26f3bc7b6553200
|
||||
README.md: 16c1124ec812b9f030ce8266a16cdb8f5db0e6cc
|
||||
README.zh.md: a3d2a2dfdd1662afee65ec45e26b1ef1029f44b5
|
||||
|
||||
@@ -39,5 +39,5 @@ Changing the target can change or invalidate provider-side cache reuse; this pac
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.
|
||||
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`provideInfo()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land.
|
||||
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`binding()`/`scope()`) is pure addressing, render-safe; the render layer reads the current bundle through the `currentProvideInfo` observable. The staged state can widen to a multi-pane list when concurrent panes land.
|
||||
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).
|
||||
|
||||
@@ -39,5 +39,5 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber 释放 → 注册级联 → 样式移除)随 HMR 项目落地。
|
||||
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`provideInfo()`/`binding()`/`scope()`)只是纯寻址,可安全用于渲染。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。
|
||||
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`binding()`/`scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。
|
||||
- **插件组合包从该包执行值导入时必须使用 `/client` 子路径**:裸包名不在 loader external 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配(空状态 P0 事故复盘)。
|
||||
|
||||
@@ -151,6 +151,13 @@ export class SessionsService {
|
||||
readonly list: SnapshotStore<SessionListState>
|
||||
/** The object-layer instance cluster and frame dispatch entry. */
|
||||
private readonly manager: SessionManager
|
||||
/**
|
||||
* Atomic current-session provide projection: selection changes and
|
||||
* provider-roster changes publish through this one source (the renderer
|
||||
* host's `sessions.provide` feed), so a roster change under a stable
|
||||
* current id republishes the bundle instead of stranding mounted entries.
|
||||
*/
|
||||
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
|
||||
|
||||
/**
|
||||
* Persisted selection cell (the durable half of `list.current`). Private on
|
||||
@@ -167,6 +174,10 @@ export class SessionsService {
|
||||
private readonly providers: SessionProvideDescriptor[] = []
|
||||
/** Static no-session projection, rebuilt only when the provider roster changes. */
|
||||
private maybeInfo: SessionMaybeProvideInfo
|
||||
/** Latest published {@link SessionsService.currentProvideInfo} bundle (identity comparison dedupes republish). */
|
||||
private currentProvideInfoSnapshot: SessionMaybeProvideInfo
|
||||
/** currentProvideInfo subscribers (plain cell: bundles hold live Session sources, so no store freeze may touch them). */
|
||||
private readonly currentProvideInfoListeners = new Set<() => void>()
|
||||
/**
|
||||
* The staged session id — follows `list.current` exactly, holding its last
|
||||
* defined value across masked gaps (a transiently absent selection blanks
|
||||
@@ -198,7 +209,11 @@ export class SessionsService {
|
||||
// dedicated code path. Safe to run synchronously inside the store notify:
|
||||
// the follower writes no list state — session.open()'s synchronous prefix
|
||||
// touches only session-side state and its own microtask-batched notifier.
|
||||
this.list.subscribe(() => { this.followCurrent() })
|
||||
// The current-provide projection follows the same current writes.
|
||||
this.list.subscribe(() => {
|
||||
this.followCurrent()
|
||||
this.updateCurrentProvideInfo()
|
||||
})
|
||||
// The runtime's own contribution comes first: useSession rides the same
|
||||
// provide channel every plugin uses (no renderer special case).
|
||||
this.providers.push({
|
||||
@@ -206,6 +221,14 @@ export class SessionsService {
|
||||
resolve: binding => ({ hooks: { session: binding.session } }),
|
||||
})
|
||||
this.maybeInfo = this.materializeMaybeProvideInfo()
|
||||
this.currentProvideInfoSnapshot = this.maybeInfo
|
||||
this.currentProvideInfo = {
|
||||
getSnapshot: () => this.currentProvideInfoSnapshot,
|
||||
subscribe: (fn) => {
|
||||
this.currentProvideInfoListeners.add(fn)
|
||||
return () => { this.currentProvideInfoListeners.delete(fn) }
|
||||
},
|
||||
}
|
||||
rootCtx.reflect.provide('sessions', this, undefined)
|
||||
}
|
||||
|
||||
@@ -238,6 +261,30 @@ export class SessionsService {
|
||||
for (const record of this.scopes.values()) {
|
||||
record.provideInfo = this.materializeProvideInfo(record.binding)
|
||||
}
|
||||
this.updateCurrentProvideInfo()
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive the current selection's provide bundle and publish it when it
|
||||
* changed. Bundles are identity-stable per (scope, roster)
|
||||
* materialization, so an identity compare is exact; synchronous notify —
|
||||
* both call sites (list.subscribe, provide()) already sit behind their own
|
||||
* batching or registration edges.
|
||||
*/
|
||||
private updateCurrentProvideInfo(): void {
|
||||
const next = this.maybeProvideInfo(this.list.getSnapshot().current)
|
||||
if (next === this.currentProvideInfoSnapshot) return
|
||||
this.currentProvideInfoSnapshot = next
|
||||
for (const fn of [...this.currentProvideInfoListeners]) {
|
||||
try {
|
||||
fn()
|
||||
} catch (error) {
|
||||
// Contain subscriber failures: this notify runs inside the list
|
||||
// notification, where a throwing render-side subscriber would starve
|
||||
// later listeners and abort the projection pass that scheduled it.
|
||||
console.error('sessions.currentProvideInfo subscriber failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the static no-session kit and reject duplicate declared names. */
|
||||
@@ -404,25 +451,21 @@ export class SessionsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the render-layer standard-props bundle (SessionProvider's feed
|
||||
* through the renderer host; ctx never enters the render layer). Pure
|
||||
* resolution — render-safe: SessionProvider calls this during render, so no
|
||||
* staging, no window side effects (StrictMode double-invokes and concurrent
|
||||
* discarded passes must stay free).
|
||||
* @param id - session id.
|
||||
* @returns the provide info, or undefined for a session neither listed nor already scoped.
|
||||
* Resolve one session's render-layer standard-props bundle (ctx never
|
||||
* enters the render layer; the renderer subscribes to
|
||||
* {@link SessionsService.currentProvideInfo}). Pure resolution — render-safe:
|
||||
* no staging, no window side effects (StrictMode double-invokes and
|
||||
* concurrent discarded passes must stay free).
|
||||
*/
|
||||
provideInfo(id: string): SessionProvideInfo | undefined {
|
||||
private provideInfo(id: string): SessionProvideInfo | undefined {
|
||||
return this.resolve(id as SessionId)?.provideInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current-session-optional standard kit. Unknown or absent ids
|
||||
* return the static no-session projection rather than removing hook props.
|
||||
* @param id - current session id, when selected.
|
||||
* @returns a definite or no-session provide bundle.
|
||||
*/
|
||||
maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
|
||||
private maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
|
||||
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
|
||||
}
|
||||
|
||||
|
||||
@@ -246,13 +246,6 @@ export class SlotsService extends Service {
|
||||
if (workspaces === undefined) {
|
||||
throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first")
|
||||
}
|
||||
// Identity-stable view: current rides the list snapshot (arbitrated), but
|
||||
// the provider consumes it as its own observable; one cached object keeps
|
||||
// the renderer's per-source hook cache stable.
|
||||
const current = {
|
||||
getSnapshot: () => sessions.list.getSnapshot().current as string | undefined,
|
||||
subscribe: (fn: () => void) => sessions.list.subscribe(fn),
|
||||
}
|
||||
this._host = {
|
||||
subscribe: (key, fn) => this._core.subscribe(key, fn),
|
||||
getVersion: key => this._core.getVersion(key),
|
||||
@@ -263,9 +256,7 @@ export class SlotsService extends Service {
|
||||
entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey),
|
||||
sessions: {
|
||||
list: sessions.list,
|
||||
current,
|
||||
provideInfo: id => sessions.provideInfo(id),
|
||||
maybeProvideInfo: id => sessions.maybeProvideInfo(id),
|
||||
provideInfo: sessions.currentProvideInfo,
|
||||
},
|
||||
workspaces: { list: workspaces.list },
|
||||
}
|
||||
|
||||
@@ -79,7 +79,8 @@ describe('scope tree', () => {
|
||||
expect(scopeOf(scoped as Context)).toBe('s1')
|
||||
expect(scopeOf(b.ctx)).toBeUndefined()
|
||||
const binding = b.svc.binding(sid('s1'))
|
||||
expect(binding?.session).toBe(b.svc.provideInfo('s1')?.hooks['session'])
|
||||
b.svc.open(sid('s1'))
|
||||
expect(binding?.session).toBe(b.svc.currentProvideInfo.getSnapshot().hooks['session'])
|
||||
expect(b.svc.binding(sid('s1'))).toBe(binding)
|
||||
expect(binding?.ctx).toBe(scoped)
|
||||
})
|
||||
@@ -183,24 +184,82 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s
|
||||
})
|
||||
|
||||
describe('cell (render-layer session kit)', () => {
|
||||
it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => {
|
||||
it('resolves an identity-stable {sessionId, session} cell through the current projection', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
const info = b.svc.provideInfo('s1')
|
||||
expect(info).toBeDefined()
|
||||
expect(info?.sessionId).toBe('s1')
|
||||
b.svc.open(sid('s1'))
|
||||
const info = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(info.sessionId).toBe('s1')
|
||||
// The bundle carries bare observables; hook binding happens in React.
|
||||
expect(info?.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
|
||||
expect(b.svc.provideInfo('s1')).toBe(info)
|
||||
expect(b.svc.provideInfo('ghost')).toBeUndefined()
|
||||
expect(info.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
|
||||
// Re-staging the same id republishes nothing: identity holds.
|
||||
b.svc.open(sid('s1'))
|
||||
expect(b.svc.currentProvideInfo.getSnapshot()).toBe(info)
|
||||
})
|
||||
|
||||
it('provideInfo()/binding() are pure resolution: no staging, no deferred sweep', async () => {
|
||||
it('currentProvideInfo follows selection: absent projection ↔ definite bundle, notified on each move', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
const absent = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(absent.sessionId).toBeUndefined()
|
||||
expect(Object.hasOwn(absent.hooks, 'session')).toBe(true)
|
||||
const notified = vi.fn()
|
||||
b.svc.currentProvideInfo.subscribe(notified)
|
||||
b.svc.open(sid('s1'))
|
||||
const s1Bundle = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(s1Bundle.sessionId).toBe('s1')
|
||||
expect(s1Bundle.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
|
||||
expect(notified).toHaveBeenCalledTimes(1)
|
||||
b.svc.open(sid('s2'))
|
||||
const s2Bundle = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(s2Bundle.sessionId).toBe('s2')
|
||||
expect(s2Bundle).not.toBe(s1Bundle)
|
||||
expect(notified).toHaveBeenCalledTimes(2)
|
||||
b.svc.clear()
|
||||
await Promise.resolve() // clearSelection projects through the manager notifier
|
||||
expect(b.svc.currentProvideInfo.getSnapshot().sessionId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a provider roster change under a stable current id republishes the bundle', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.open(sid('s1'))
|
||||
const before = b.svc.currentProvideInfo.getSnapshot()
|
||||
const notified = vi.fn()
|
||||
b.svc.currentProvideInfo.subscribe(notified)
|
||||
const source = { getSnapshot: () => 'live', subscribe: () => () => {} }
|
||||
const dispose = b.svc.provide({
|
||||
hooks: ['extra'],
|
||||
props: ['marker'],
|
||||
resolve: () => ({ hooks: { extra: source }, props: { marker: 7 } }),
|
||||
})
|
||||
const added = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(added).not.toBe(before)
|
||||
expect(added).toMatchObject({ sessionId: 's1', props: { marker: 7 } })
|
||||
expect(added.hooks['extra']).toBe(source)
|
||||
expect(notified).toHaveBeenCalledTimes(1)
|
||||
dispose()
|
||||
const removed = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(removed).not.toBe(added)
|
||||
expect(Object.hasOwn(removed.hooks, 'extra')).toBe(false)
|
||||
expect(notified).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('an unsubscribed currentProvideInfo listener stops receiving notifications', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
const notified = vi.fn()
|
||||
const off = b.svc.currentProvideInfo.subscribe(notified)
|
||||
off()
|
||||
b.svc.open(sid('s1'))
|
||||
expect(notified).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('binding() is pure resolution: no staging, no deferred sweep', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
b.svc.open(sid('s1')) // staged
|
||||
b.svc.provideInfo('s2') // resolution only — must NOT move the stage
|
||||
b.svc.binding(sid('s2'))
|
||||
b.svc.binding(sid('s2')) // resolution only — must NOT move the stage
|
||||
await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives
|
||||
expect(b.svc.scope(sid('s1'))).toBeDefined()
|
||||
})
|
||||
@@ -211,7 +270,6 @@ describe('cell (render-layer session kit)', () => {
|
||||
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
|
||||
// Resolution is addressing, not staging: no window pull.
|
||||
b.svc.scope(sid('s1'))
|
||||
b.svc.provideInfo('s1')
|
||||
b.svc.binding(sid('s1'))
|
||||
expect(historyCalls()).toHaveLength(0)
|
||||
b.svc.open(sid('s1'))
|
||||
|
||||
@@ -97,18 +97,13 @@ function fakeWorkspaces() {
|
||||
return { list: { getSnapshot: () => state, subscribe: () => () => undefined } }
|
||||
}
|
||||
|
||||
/** Minimal sessions face for the host seam (list observable + provide bundle). */
|
||||
/** Minimal sessions face for the host seam (list observable + current provide projection). */
|
||||
function fakeSessions() {
|
||||
const state = { ids: [], byId: {}, current: undefined as string | undefined }
|
||||
const absentInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
|
||||
return {
|
||||
list: { getSnapshot: () => state, subscribe: () => () => undefined },
|
||||
provideInfo: (id: string) => (id === 'known'
|
||||
? {
|
||||
sessionId: id,
|
||||
hooks: { session: { getSnapshot: () => undefined, subscribe: () => () => undefined } },
|
||||
props: {},
|
||||
}
|
||||
: undefined),
|
||||
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => undefined },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,13 +227,11 @@ describe('host face', () => {
|
||||
expect(host.entriesOf('t.host')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('exposes sessions list/current/provideInfo (current riding the list snapshot)', async () => {
|
||||
it('exposes the session list and the atomic current provide projection', async () => {
|
||||
const bench = await boot()
|
||||
const host = captureHost(bench)
|
||||
expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] })
|
||||
expect(host.sessions.current.getSnapshot()).toBeUndefined()
|
||||
expect(host.sessions.provideInfo('known')).toMatchObject({ sessionId: 'known' })
|
||||
expect(host.sessions.provideInfo('ghost')).toBeUndefined()
|
||||
expect(host.sessions.provideInfo.getSnapshot()).toMatchObject({ sessionId: undefined })
|
||||
})
|
||||
|
||||
it('exposes the independent Workspace list source', async () => {
|
||||
|
||||
@@ -135,13 +135,15 @@ export function apply(ctx: Context): void {
|
||||
'conversation.input.model': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
inject: (sessionId: SessionId): ComposerBarInjected => {
|
||||
const shell = inputHub.shell(sessionId)
|
||||
return {
|
||||
keyboard: inputHub.keyboard(sessionId),
|
||||
keyboard: shell,
|
||||
stop: () => {
|
||||
scopedConversation(sessions, sessionId).cancel().catch(() => {
|
||||
// Stop failure surfaces via snapshot.promptError; nothing to restore.
|
||||
})
|
||||
},
|
||||
hooks: { notices: shell.notices, lexicon: shell.lexicon },
|
||||
}
|
||||
},
|
||||
}, InputBar)
|
||||
|
||||
@@ -40,13 +40,20 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
|
||||
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted }: AssistantMarkdownProps) {
|
||||
const last = blocks.length - 1
|
||||
// Tool-call heads render as tool rows in the chat view's grouping pass, so
|
||||
// a node that is only those heads (or empty) would paint an empty root
|
||||
// between tool groups — skip the shell unless something visible remains.
|
||||
const hasVisible = streaming
|
||||
|| interrupted === true
|
||||
|| blocks.some(block => block.kind !== 'tool-call')
|
||||
if (!hasVisible) return null
|
||||
return (
|
||||
<div className={css.root} data-streaming={streaming || undefined}>
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.kind) {
|
||||
case 'text': return <MarkdownText key={i} text={block.text} streaming={streaming} />
|
||||
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
|
||||
// Tool-call heads render as tool rows in the chat view's grouping pass.
|
||||
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
|
||||
case 'tool-call': return null
|
||||
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/** Conversation slot declarations and their composed component props. */
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import type {
|
||||
MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
|
||||
InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ConversationSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, ObservableSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ComposerKeyboard, InputActions, InputState } from '../input/contract.ts'
|
||||
import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
|
||||
|
||||
@@ -220,6 +220,13 @@ export interface ComposerBarInjected {
|
||||
keyboard: ComposerKeyboard
|
||||
/** Cancel the in-flight turn. */
|
||||
stop: () => void
|
||||
/** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
|
||||
hooks: {
|
||||
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */
|
||||
notices: ObservableSnapshot<InputNotice | null>
|
||||
/** Hot plain-text reference lexicon for the decoration scan (decision 21). */
|
||||
lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,11 +238,11 @@ export interface InputControlOwnerProps {
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share. */
|
||||
/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share (hooks compartment bound). */
|
||||
export type ComposerBarProps =
|
||||
PropsRuntime<'conversation.composer.bar'>
|
||||
& PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'>
|
||||
& ComposerBarInjected
|
||||
& InjectFace<ComposerBarInjected>
|
||||
|
||||
/**
|
||||
* Composer chain currency: what ConversationRoot dispatches at its
|
||||
|
||||
@@ -77,8 +77,6 @@ export interface InputNotice {
|
||||
* satisfies it structurally.
|
||||
*/
|
||||
export interface ComposerKeyboard {
|
||||
/** Latest surfaced notice store (null after none). */
|
||||
readonly notices: SnapshotStore<InputNotice | null>
|
||||
/** Live machine state for event-handler reads (render reads go through useInput). */
|
||||
readonly snapshot: InputState
|
||||
/** Draft write with the DOM-observed edit shape (narrows occurrence math). */
|
||||
@@ -99,8 +97,6 @@ export interface ComposerKeyboard {
|
||||
space(): boolean
|
||||
/** Dismiss the popupSelect shell (any interaction outside the box). */
|
||||
dismissPopup(): void
|
||||
/** Hot plain-text reference lexicons for the decoration scan (decision 21; empty Map without a pipeline). */
|
||||
lexicon(): ReadonlyMap<'/' | '@', readonly string[]>
|
||||
}
|
||||
|
||||
/** One queued-message row projected from the session/queued frames (T9 supplies the store). */
|
||||
|
||||
@@ -206,11 +206,14 @@ export class SessionInputShell implements SessionInput {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hot plain-text reference lexicons for the decoration scan (decision 21).
|
||||
* @returns the controller's per-trigger aggregation; empty Map without a pipeline.
|
||||
* Hot plain-text reference lexicon source for the decoration scan
|
||||
* (decision 21): delegates to the controller's aggregated store. Stable
|
||||
* identity per shell; without a pipeline the snapshot is the empty Map and
|
||||
* subscribers never fire.
|
||||
*/
|
||||
lexicon(): ReadonlyMap<'/' | '@', readonly string[]> {
|
||||
return this.deps.slash?.()?.lexicon() ?? EMPTY_LEXICON
|
||||
readonly lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>> = {
|
||||
getSnapshot: () => this.deps.slash?.()?.lexicon.getSnapshot() ?? EMPTY_LEXICON,
|
||||
subscribe: fn => this.deps.slash?.()?.lexicon.subscribe(fn) ?? (() => {}),
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/** The default composer body: the 'conversation.composer.bar' slot entry
|
||||
* (decision 20). Machine state arrives through the standard provide channel
|
||||
* (useInput + inputActions); the keyboard/DOM command face and stop arrive
|
||||
* through this entry's own inject; layout-phase inputs (variant, placeholder,
|
||||
* through this entry's own inject, whose hooks compartment binds
|
||||
* useNotices/useLexicon; layout-phase inputs (variant, placeholder,
|
||||
* region-slot content) ride the owner props. Session facts
|
||||
* (running/removed/promptError) are self-selected via useSession. */
|
||||
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
@@ -27,15 +28,12 @@ const READONLY_OPTIONS: readonly { id: string; label: string }[] = [
|
||||
]
|
||||
|
||||
export function InputBar({
|
||||
useSession, useInput, inputActions, keyboard, stop, renderSlot,
|
||||
useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon,
|
||||
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
|
||||
}: InputBarProps) {
|
||||
const input = useInput(s => s)
|
||||
const noticeStore = keyboard.notices
|
||||
const notice = useSyncExternalStore(
|
||||
(fn: () => void) => noticeStore.subscribe(fn),
|
||||
() => noticeStore.getSnapshot(),
|
||||
)
|
||||
const notice = useNotices(s => s)
|
||||
const lexicon = useLexicon(s => s)
|
||||
const promptError = useSession(s => s.promptError)
|
||||
const running = useSession(s => s.running)
|
||||
const disabled = useSession(s => s.removed)
|
||||
@@ -244,7 +242,7 @@ export function InputBar({
|
||||
// claim token highlights through behind the textarea glyphs; each U+FFFC
|
||||
// placeholder renders as a chip (the textarea's own glyph is invisible, the
|
||||
// backdrop chip supplies the visual); the claim hint is ghost text.
|
||||
const deco = deriveDecorations(input, keyboard.lexicon())
|
||||
const deco = deriveDecorations(input, lexicon)
|
||||
const backdrop: ReactNode[] = []
|
||||
{
|
||||
// Segment boundaries: the token range end, every chip offset, and every
|
||||
|
||||
@@ -1,29 +1,44 @@
|
||||
/* todo_write plan-update row: title + progress summary on one line. */
|
||||
/* todo_write plan-update row: ToolRow chrome (figma 780:53675) —
|
||||
[16 checklist] gap6 [title 14/24] gap8 [2x2 dot] gap8 [summary FILL truncate]. */
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.badge {
|
||||
.leading {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-weight: 510;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-primary-dimmed);
|
||||
}
|
||||
|
||||
.sep {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
margin: 0 8px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.summary {
|
||||
@@ -32,11 +47,15 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.err {
|
||||
flex: none;
|
||||
margin-left: 8px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
// hole like the bash sample (a product registration, not a sample). The row
|
||||
// summarizes the written list (counts + active item) from the call args; the
|
||||
// durable list itself renders in the TodoPanel above the composer, so the
|
||||
// row stays one line.
|
||||
// row stays one line. Chrome matches ToolRow (figma 780:53675).
|
||||
|
||||
import type { KeyboardEvent } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconChecklistOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import css from './todo-row.module.css'
|
||||
|
||||
/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */
|
||||
@@ -40,6 +40,17 @@ function summarize(argsRaw: string): string | null {
|
||||
: head
|
||||
}
|
||||
|
||||
/** Leading-slot state substitution matches ToolRow / bash: icon yields to the
|
||||
* state semantic while running or failed; ok keeps the checklist glyph. */
|
||||
function leadingFor(state: ToolRowState) {
|
||||
switch (state) {
|
||||
case 'running': return <StateDot state="ongoing" />
|
||||
case 'error': return <StateDot state="error" />
|
||||
case 'stopped': return <StateDot state="warning" />
|
||||
default: return <IconChecklistOutline16 />
|
||||
}
|
||||
}
|
||||
|
||||
/** One-line plan update row (click opens the raw args in details). Non-ok
|
||||
* execution states keep the generic row's dot semantics — a cancelled call
|
||||
* wrote no todo/write, so it must not read as a completed update. */
|
||||
@@ -64,10 +75,9 @@ export function TodoRow({ toolName, block, openDetails }: ToolRowProps) {
|
||||
onClick={openDetails}
|
||||
onKeyDown={openFromKeyboard}
|
||||
>
|
||||
{model.state === 'ok'
|
||||
? <span className={css.badge} aria-hidden>☰</span>
|
||||
: <StateDot state={model.state === 'running' ? 'ongoing' : model.state === 'stopped' ? 'warning' : 'error'} />}
|
||||
<span className={css.leading} aria-hidden>{leadingFor(model.state)}</span>
|
||||
<span className={css.title}>更新任务清单</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
<span className={css.summary}>{summary}</span>
|
||||
{model.state === 'error' && <span className={css.err}>failed</span>}
|
||||
{model.state === 'stopped' && <span className={css.err}>已中断</span>}
|
||||
|
||||
@@ -87,12 +87,13 @@ async function bench() {
|
||||
}
|
||||
}
|
||||
const providers: TestProvider[] = []
|
||||
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
|
||||
const sessionsFake = {
|
||||
list: listStore,
|
||||
binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }),
|
||||
scope: (id: SessionId) => mint(id),
|
||||
provideInfo: () => undefined,
|
||||
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
|
||||
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
|
||||
provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} },
|
||||
scopeOf,
|
||||
sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake),
|
||||
|
||||
@@ -32,12 +32,13 @@ async function bench() {
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
|
||||
const sessionsFake = {
|
||||
list: listStore,
|
||||
binding: vi.fn(),
|
||||
scope: () => undefined,
|
||||
provideInfo: () => undefined,
|
||||
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
|
||||
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
|
||||
provide: vi.fn(() => () => {}),
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
|
||||
@@ -87,6 +87,9 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
// Provide-channel contributions land in this bundle the way the runtime
|
||||
// materializes them; the renderer host serves it through provideInfo.
|
||||
const provided: { hooks: Record<string, unknown>; props: Record<string, unknown> } = { hooks: {}, props: {} }
|
||||
// Identity-stable currentProvideInfo snapshot (uSES getSnapshot contract),
|
||||
// materialized on first render after the provide contributions landed.
|
||||
let infoCell: { sessionId: SessionId; hooks: Record<string, unknown>; props: Record<string, unknown> } | undefined
|
||||
const sessionsFake = {
|
||||
list,
|
||||
binding: (id: SessionId) => (id === SID
|
||||
@@ -103,9 +106,10 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
provideInfo: (id: string) => (id === SID
|
||||
? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }
|
||||
: undefined),
|
||||
maybeProvideInfo: (id: string | undefined) => (id === SID
|
||||
? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }
|
||||
: { hooks: provided.hooks, props: provided.props }),
|
||||
currentProvideInfo: {
|
||||
getSnapshot: () => infoCell ??= { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props },
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/clien
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
/** Identity-stable no-session bundle (uSES getSnapshot contract). */
|
||||
const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
|
||||
|
||||
afterEach(cleanup)
|
||||
// The chat store persists under its declared key; clear between cases.
|
||||
beforeEach(() => {
|
||||
@@ -89,30 +92,28 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
subscribe: (fn: () => void) => session.subscribe(fn),
|
||||
},
|
||||
})
|
||||
const provideInfo = (id: string) => {
|
||||
if (id !== SID) return undefined
|
||||
if (info === undefined) {
|
||||
const hooks: Record<string, unknown> = { session }
|
||||
const props: Record<string, unknown> = {}
|
||||
for (const provider of providers) {
|
||||
const c = provider(bindingOf(SID))
|
||||
Object.assign(hooks, c.hooks ?? {})
|
||||
Object.assign(props, c.props ?? {})
|
||||
}
|
||||
info = { sessionId: SID, hooks, props }
|
||||
}
|
||||
return info
|
||||
}
|
||||
ctx.provide('sessions', {
|
||||
list,
|
||||
binding: bindingOf,
|
||||
scope: () => actxFake,
|
||||
provideInfo: (id: string) => {
|
||||
if (id !== SID) return undefined
|
||||
if (info === undefined) {
|
||||
const hooks: Record<string, unknown> = { session }
|
||||
const props: Record<string, unknown> = {}
|
||||
for (const provider of providers) {
|
||||
const c = provider(bindingOf(SID))
|
||||
Object.assign(hooks, c.hooks ?? {})
|
||||
Object.assign(props, c.props ?? {})
|
||||
}
|
||||
info = { sessionId: SID, hooks, props }
|
||||
}
|
||||
return info
|
||||
},
|
||||
maybeProvideInfo(id: string | undefined) {
|
||||
// `this` inside an object-literal method is any under strict lint; the
|
||||
// fake resolves through its own provideInfo above.
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unsafe-return,
|
||||
@typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
|
||||
return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} }
|
||||
provideInfo,
|
||||
currentProvideInfo: {
|
||||
getSnapshot: () => provideInfo(SID),
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
|
||||
scopeOf: () => SID,
|
||||
@@ -254,7 +255,10 @@ describe('registrant load-order seam', () => {
|
||||
binding: () => undefined,
|
||||
scope: () => undefined,
|
||||
provideInfo: () => undefined,
|
||||
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
|
||||
currentProvideInfo: {
|
||||
getSnapshot: () => ABSENT_INFO,
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
provide: () => () => {},
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
|
||||
@@ -60,6 +60,20 @@ describe('tails', () => {
|
||||
expect(stopped.getByText('已停止')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('AssistantMarkdown skips the root shell when only tool-call heads remain', () => {
|
||||
// Tool heads are drawn by ChatView's tool groups; an empty root between
|
||||
// groups is layout noise (no text, no pulse, no interrupted marker).
|
||||
const empty = render(
|
||||
<AssistantMarkdown
|
||||
blocks={[{ kind: 'tool-call', callId: 'c', name: 'todo_write', argsRaw: '{}' }]}
|
||||
streaming={false}
|
||||
/>,
|
||||
)
|
||||
expect(empty.container.firstChild).toBeNull()
|
||||
const blank = render(<AssistantMarkdown blocks={[]} streaming={false} />)
|
||||
expect(blank.container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('a settled others-variant row renders the sparkle icon in the leading slot', () => {
|
||||
const settled: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5',
|
||||
|
||||
@@ -56,7 +56,11 @@ function bench(over?: BenchOptions) {
|
||||
// Lexicon-only stub: adjudication untouched (undefined slash methods are
|
||||
// never reached — these benches drive plain-draft flows only).
|
||||
...(lex !== undefined
|
||||
? { slash: (() => ({ lexicon: () => lex })) as unknown as NonNullable<ShellDeps['slash']> }
|
||||
? {
|
||||
slash: (() => ({
|
||||
lexicon: { getSnapshot: () => lex, subscribe: () => () => {} },
|
||||
})) as unknown as NonNullable<ShellDeps['slash']>,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
|
||||
@@ -87,6 +91,8 @@ function bench(over?: BenchOptions) {
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
stop,
|
||||
renderSlot,
|
||||
variant: over?.variant ?? 'composer',
|
||||
|
||||
@@ -42,6 +42,8 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
renderSlot: (() => null) as InputBarProps['renderSlot'],
|
||||
stop: vi.fn(),
|
||||
variant: 'composer',
|
||||
|
||||
@@ -128,6 +128,8 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
renderSlot: (() => null) as InputBarProps['renderSlot'],
|
||||
stop: vi.fn(),
|
||||
variant: 'composer',
|
||||
@@ -234,6 +236,35 @@ describe('scenario H: backspace breaks the token', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('scenario: reference decoration lights up when the lexicon settles', () => {
|
||||
it('a typed /name token gains the text-ref mark without further input once the roll goes hot', async () => {
|
||||
let roll: readonly string[] | undefined
|
||||
let notify: (() => void) | undefined
|
||||
const b = await scopedBench((slash) => {
|
||||
slash.registerSource({
|
||||
trigger: '/', name: 'skill',
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
lexicon: () => roll,
|
||||
subscribeLexicon: (_session: ClientSessionContext, listener: () => void) => {
|
||||
notify = listener
|
||||
return () => { notify = undefined }
|
||||
},
|
||||
} as never)
|
||||
})
|
||||
// Typed before the catalog settled: a plain token, no decoration.
|
||||
b.type('/deploy now')
|
||||
expect(b.view.container.querySelector('[data-decoration="text-ref"]')).toBeNull()
|
||||
// The catalog settles (ui-skill's settle path fires the same notification).
|
||||
act(() => {
|
||||
roll = ['deploy']
|
||||
notify?.()
|
||||
})
|
||||
const mark = b.view.container.querySelector('[data-decoration="text-ref"]')
|
||||
expect(mark?.textContent).toBe('/deploy')
|
||||
})
|
||||
})
|
||||
|
||||
describe('scenario I: unknown /xyz + enter', () => {
|
||||
it('adjudication misses in one hop and the whole line rides the default sink', async () => {
|
||||
const b = await bench()
|
||||
|
||||
@@ -11,6 +11,9 @@ import { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
/** Identity-stable no-session bundle (uSES getSnapshot contract). */
|
||||
const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
|
||||
|
||||
interface Bench {
|
||||
slots: SlotsService
|
||||
chat: ReturnType<typeof createChatStore>
|
||||
@@ -23,7 +26,10 @@ function bench(): Bench {
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
}),
|
||||
provideInfo: () => undefined,
|
||||
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
|
||||
currentProvideInfo: {
|
||||
getSnapshot: () => ABSENT_INFO,
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
provide: () => () => {},
|
||||
})
|
||||
ctx.provide('workspaces', {
|
||||
|
||||
@@ -118,6 +118,8 @@ function mount(
|
||||
useInput={useInput}
|
||||
inputActions={inputActions}
|
||||
keyboard={wiring}
|
||||
useNotices={bindSnapshotSelector(wiring.notices)}
|
||||
useLexicon={bindSnapshotSelector(wiring.lexicon)}
|
||||
stop={stop}
|
||||
renderSlot={(() => null) as InputBarProps['renderSlot']}
|
||||
{...bar}
|
||||
|
||||
@@ -653,6 +653,16 @@ export const IconDataOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_checklist_outline_16 (figma extract): two rings + two list bars. */
|
||||
export const IconChecklistOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path transform="translate(1.736 2.0752)" d="M12.5279 8.64648V9.92617H6.48105V8.64648H12.5279Z" fill="currentColor" />
|
||||
<path transform="translate(1.736 2.0752)" d="M12.5279 1.92275V3.20244H6.48105V1.92275H12.5279Z" fill="currentColor" />
|
||||
<path transform="translate(1.736 2.0752)" d="M3.84531 9.28623C3.84525 8.57774 3.271 8.00342 2.5625 8.00342C1.85405 8.00348 1.27975 8.57778 1.27969 9.28623C1.27969 9.99474 1.85401 10.569 2.5625 10.569C3.27105 10.569 3.84531 9.99478 3.84531 9.28623ZM5.12578 9.28623C5.12578 10.7017 3.97797 11.8495 2.5625 11.8495C1.14709 11.8494 0 10.7017 0 9.28623C6.59755e-05 7.87086 1.14713 6.7238 2.5625 6.72373C3.97793 6.72373 5.12572 7.87082 5.12578 9.28623Z" fill="currentColor" />
|
||||
<path transform="translate(1.736 2.0752)" d="M3.84551 2.5625C3.84549 1.85402 3.27118 1.27969 2.5627 1.27969C1.85422 1.2797 1.2799 1.85403 1.27988 2.5625C1.27988 3.27098 1.85422 3.8453 2.5627 3.84531C3.27119 3.84531 3.84551 3.27099 3.84551 2.5625ZM5.1252 2.5625C5.1252 3.97792 3.97811 5.125 2.5627 5.125C1.14729 5.12499 0.000195313 3.97791 0.000195313 2.5625C0.000208508 1.1471 1.1473 1.31957e-05 2.5627 0C3.9781 0 5.12518 1.1471 5.1252 2.5625Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_List_Pen_outline_16 */
|
||||
export const IconListPenOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
@@ -14,8 +14,8 @@ const icons = Object.fromEntries(
|
||||
const iconNames = Object.keys(icons)
|
||||
|
||||
describe('ic_ds_ icon set', () => {
|
||||
it('exports the full P-I set (43 deepsuite + 12 figma extracts)', () => {
|
||||
expect(iconNames.length).toBe(55)
|
||||
it('exports the full P-I set (43 deepsuite + 13 figma extracts)', () => {
|
||||
expect(iconNames.length).toBe(56)
|
||||
})
|
||||
|
||||
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SettingsRootComponentProps } from './contract/slots.ts'
|
||||
import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
|
||||
import css from './SettingsRoot.module.css'
|
||||
|
||||
/** Nav glyph by section id; unknown ids fall back to the settings gear. */
|
||||
@@ -20,7 +20,7 @@ function navIcon(id: string) {
|
||||
}
|
||||
|
||||
type PanelProps = {
|
||||
rows: ReturnType<SettingsRootComponentProps['sections']>
|
||||
rows: readonly SettingsSectionRow[]
|
||||
renderSlot: SettingsRootComponentProps['renderSlot']
|
||||
onClose: () => void
|
||||
}
|
||||
@@ -92,20 +92,14 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
|
||||
* @returns the settings shell element tree.
|
||||
*/
|
||||
export function SettingsRoot(props: SettingsRootComponentProps) {
|
||||
const { wide, subscribeSections, sectionsVersion, sections, renderSlot } = props
|
||||
const { wide, useSections, renderSlot } = props
|
||||
const [open, setOpen] = useState(false)
|
||||
const close = useCallback(() => { setOpen(false) }, [])
|
||||
|
||||
// The ledger tick keeps the nav rows fresh: registrants re-register with
|
||||
// freshly localized text on locale change, and the trigger/header/close
|
||||
// seats re-render through their own outlets' subscriptions.
|
||||
// State = ledger version: same-version notifications dedupe to no render.
|
||||
const [, setSectionsRev] = useState(() => sectionsVersion())
|
||||
useEffect(
|
||||
() => subscribeSections(() => { setSectionsRev(sectionsVersion()) }),
|
||||
[subscribeSections, sectionsVersion],
|
||||
)
|
||||
const rows = sections()
|
||||
const rows = useSections(s => s)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* setting never means editing the shell; copy that belongs to no single
|
||||
* feature (chrome, the General section) is owned by ui-settings-general.
|
||||
*/
|
||||
import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { HostObservable, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry)
|
||||
// into every program that sees this contract.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
@@ -72,26 +72,32 @@ export interface SettingsSectionOwnerProps {
|
||||
children?: never
|
||||
}
|
||||
|
||||
/** One nav row projected from a settings.section registration's options. */
|
||||
export interface SettingsSectionRow {
|
||||
id: string
|
||||
order: number
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrant-private injected share of the settings shell (assembled in
|
||||
* apply): ledger projections only — the shell reads no locale state.
|
||||
* apply): the ledger's nav-row projection as a hooks-compartment source —
|
||||
* the shell reads no locale state and subscribes through the bound hook.
|
||||
*/
|
||||
export type SettingsRootInjected = {
|
||||
/** Read the settings.section ledger version (nav invalidation). */
|
||||
sectionsVersion: () => number
|
||||
/** Subscribe to settings.section ledger changes. */
|
||||
subscribeSections: (listener: () => void) => () => void
|
||||
/** Project the settings.section ledger into nav rows (id/order/label). */
|
||||
sections: () => readonly { id: string; order: number; label: string }[]
|
||||
hooks: {
|
||||
/** settings.section ledger projected into ordered nav rows. */
|
||||
sections: HostObservable<readonly SettingsSectionRow[]>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full component props of the settings shell root: the sidebar owner share
|
||||
* (wide/rail state) plus the declared render shares and the injected face.
|
||||
* No store is registered — modal open state and active section id are
|
||||
* component-local viewing state.
|
||||
* (wide/rail state) plus the declared render shares and the injected face
|
||||
* (hooks compartment bound to useSections). No store is registered — modal
|
||||
* open state and active section id are component-local viewing state.
|
||||
*/
|
||||
export type SettingsRootComponentProps =
|
||||
PropsRuntime<'sidebar.settings'>
|
||||
& PropsRenderSlots<'settings.trigger' | 'settings.header' | 'settings.close' | 'settings.section'>
|
||||
& SettingsRootInjected
|
||||
& InjectFace<SettingsRootInjected>
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SettingsRootInjected } from './contract/slots.ts'
|
||||
import type { SettingsRootInjected, SettingsSectionRow } from './contract/slots.ts'
|
||||
import { SettingsRoot } from './SettingsRoot.tsx'
|
||||
|
||||
export type {
|
||||
SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected,
|
||||
SettingsSectionOwnerProps, SettingsTriggerOwnerProps,
|
||||
SettingsSectionOwnerProps, SettingsSectionRow, SettingsTriggerOwnerProps,
|
||||
} from './contract/slots.ts'
|
||||
|
||||
/**
|
||||
@@ -32,17 +32,31 @@ export const inject = ['slots']
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
// Ledger → nav-row projection as an observable source (uSES contract:
|
||||
// getSnapshot returns the cached rows until the ledger version moves).
|
||||
let rowsVersion = -1
|
||||
let rows: readonly SettingsSectionRow[] = []
|
||||
const injected = (): SettingsRootInjected => ({
|
||||
sectionsVersion: () => ctx.slots.getVersion('settings.section'),
|
||||
subscribeSections: listener => ctx.slots.subscribe('settings.section', listener),
|
||||
sections: () => ctx.slots.entries('settings.section')
|
||||
.map(e => ({
|
||||
/* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
|
||||
id: e.options.id ?? '',
|
||||
order: e.options.order ?? 0,
|
||||
label: e.options.label ?? '',
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order),
|
||||
hooks: {
|
||||
sections: {
|
||||
getSnapshot: () => {
|
||||
const version = ctx.slots.getVersion('settings.section')
|
||||
if (version !== rowsVersion) {
|
||||
rowsVersion = version
|
||||
rows = ctx.slots.entries('settings.section')
|
||||
.map(e => ({
|
||||
/* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
|
||||
id: e.options.id ?? '',
|
||||
order: e.options.order ?? 0,
|
||||
label: e.options.label ?? '',
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
return rows
|
||||
},
|
||||
subscribe: listener => ctx.slots.subscribe('settings.section', listener),
|
||||
},
|
||||
},
|
||||
})
|
||||
ctx.effect(() => {
|
||||
const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () =>
|
||||
|
||||
@@ -60,22 +60,25 @@ describe('ui-settings apply', () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const injected = injectedOf(b.slots)
|
||||
const { sections } = injectedOf(b.slots).hooks
|
||||
// The shell ships no sections of its own — registrants fill the ledger.
|
||||
expect(injected.sections()).toEqual([])
|
||||
expect(sections.getSnapshot()).toEqual([])
|
||||
b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
|
||||
// No order and no label: both projection defaults apply.
|
||||
b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
|
||||
expect(injected.sections()).toEqual([
|
||||
const rows = sections.getSnapshot()
|
||||
expect(rows).toEqual([
|
||||
{ id: 'a', order: 0, label: '' },
|
||||
{ id: 'z', order: 20, label: 'Z' },
|
||||
])
|
||||
expect(injected.sectionsVersion()).toBe(b.slots.getVersion('settings.section'))
|
||||
// Snapshot identity is stable until the ledger moves (uSES contract).
|
||||
expect(sections.getSnapshot()).toBe(rows)
|
||||
const listener = vi.fn()
|
||||
const off = injected.subscribeSections(listener)
|
||||
const off = sections.subscribe(listener)
|
||||
b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null)
|
||||
await Promise.resolve()
|
||||
expect(listener).toHaveBeenCalled()
|
||||
expect(sections.getSnapshot()).not.toBe(rows)
|
||||
off()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts'
|
||||
import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
|
||||
@@ -22,9 +23,9 @@ function mount({
|
||||
{ id: 'models', order: 10, label: 'Models' },
|
||||
],
|
||||
}: { wide?: boolean; rows?: Row[] } = {}) {
|
||||
// Mutable row store standing in for the ledger; bump() plays a change.
|
||||
// Mutable row source standing in for the bound useSections hook; bump()
|
||||
// plays a ledger change through the same observable contract.
|
||||
let current = rows
|
||||
let version = 0
|
||||
const listeners = new Set<() => void>()
|
||||
const renderSlot = vi.fn(
|
||||
((key: string, _owner: unknown, opts?: { only?: string }) => {
|
||||
@@ -38,19 +39,21 @@ function mount({
|
||||
useSessions: unusedHook,
|
||||
useWorkspaces: unusedHook,
|
||||
wide,
|
||||
sectionsVersion: () => version,
|
||||
subscribeSections: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
useSections: (select) => {
|
||||
const [, force] = useState(0)
|
||||
useEffect(() => {
|
||||
const listener = () => { force(n => n + 1) }
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
}, [])
|
||||
return select(current)
|
||||
},
|
||||
sections: () => current,
|
||||
renderSlot,
|
||||
}
|
||||
const view = render(<SettingsRoot {...props} />)
|
||||
const bump = (next: Row[]) => {
|
||||
act(() => {
|
||||
current = next
|
||||
version += 1
|
||||
for (const fn of [...listeners]) fn()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,6 +44,21 @@ export function apply(ctx: ClientContext): void {
|
||||
// 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>()
|
||||
// Per-session lexicon invalidation listeners (subscribeLexicon consumers).
|
||||
const lexiconListeners = new Map<SessionId, Set<() => void>>()
|
||||
|
||||
const notifyLexicon = (sessionId: SessionId): void => {
|
||||
for (const listener of [...(lexiconListeners.get(sessionId) ?? [])]) {
|
||||
try {
|
||||
listener()
|
||||
} catch (error) {
|
||||
// Contain listener failures: settlement notifies from an ignored
|
||||
// promise chain (a throw would surface as an unhandled rejection)
|
||||
// and one faulty consumer must not starve the others.
|
||||
console.error('[ui-skill] lexicon listener failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCatalog = (sessionId: SessionId): Promise<readonly SkillEntry[]> => {
|
||||
const existing = fetches.get(sessionId)
|
||||
@@ -58,7 +73,10 @@ export function apply(ctx: ClientContext): void {
|
||||
fetches.set(sessionId, entry)
|
||||
promise.then(
|
||||
// Settled snapshot backs the synchronous lexicon reads.
|
||||
(skills) => { entry.settled = skills },
|
||||
(skills) => {
|
||||
entry.settled = skills
|
||||
notifyLexicon(sessionId)
|
||||
},
|
||||
// A failed fetch must not poison the key: the next consumer retries.
|
||||
() => {
|
||||
if (fetches.get(sessionId) === entry) fetches.delete(sessionId)
|
||||
@@ -72,6 +90,7 @@ export function apply(ctx: ClientContext): void {
|
||||
if (entry === undefined) return
|
||||
fetches.delete(key)
|
||||
entry.abort.abort()
|
||||
notifyLexicon(key)
|
||||
}
|
||||
|
||||
const clearAll = (): void => {
|
||||
@@ -97,6 +116,16 @@ export function apply(ctx: ClientContext): void {
|
||||
lexicon(session) {
|
||||
return fetches.get(session.sessionId)?.settled?.map(skill => skill.name)
|
||||
},
|
||||
subscribeLexicon(session, listener) {
|
||||
const key = session.sessionId
|
||||
const listeners = lexiconListeners.get(key) ?? new Set()
|
||||
listeners.add(listener)
|
||||
lexiconListeners.set(key, listeners)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
if (listeners.size === 0) lexiconListeners.delete(key)
|
||||
}
|
||||
},
|
||||
onPick({ candidate }) {
|
||||
// Decision 21: plain-text reference — the literal lands in the draft
|
||||
// and ships to the model verbatim (trailing space closes the token).
|
||||
|
||||
@@ -208,6 +208,33 @@ describe('lexicon', () => {
|
||||
// Another session's key is independent — cold until its own fetch.
|
||||
expect(source.lexicon!(proj('s2'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('subscribeLexicon notifies on catalog settle and on invalidation, per session', async () => {
|
||||
const { list } = countingList()
|
||||
const { ctx, source } = await bench(list)
|
||||
const s1 = vi.fn()
|
||||
const s2 = vi.fn()
|
||||
source.subscribeLexicon!(proj('s1'), s1)
|
||||
source.subscribeLexicon!(proj('s2'), s2)
|
||||
await source.candidates(proj('s1'), req(''))
|
||||
expect(s1).toHaveBeenCalledTimes(1)
|
||||
expect(s2).not.toHaveBeenCalled()
|
||||
// Reset invalidates every cached session: each key notifies its own listeners.
|
||||
await source.candidates(proj('s2'), req(''))
|
||||
ctx.emit('connection/reset')
|
||||
expect(s1).toHaveBeenCalledTimes(2)
|
||||
expect(s2).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('an unsubscribed lexicon listener stops receiving notifications', async () => {
|
||||
const { list } = countingList()
|
||||
const { source } = await bench(list)
|
||||
const listener = vi.fn()
|
||||
const off = source.subscribeLexicon!(proj('s1'), listener)
|
||||
off()
|
||||
await source.candidates(proj('s1'), req(''))
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pick and codec', () => {
|
||||
|
||||
@@ -1,6 +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: d2978695d71686059bfbcbb4fc3ef896d92add4a
|
||||
README.zh.md: 6aeb078a922aaa93d50ed16b4dbe54329737d018
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-slash/README.md
|
||||
README.md: 4e363c2682bf91862ec40f3f2174831451fb9b0d
|
||||
README.zh.md: 76d39673cb853d1889ee84cb9f3595708eae2db3
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone and the roster is warmed once at scope birth. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins.
|
||||
Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins.
|
||||
|
||||
Layering: `src/core/` (T2) is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract (design v4 §5.1); changes require main-thread arbitration.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
输入触发管线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份,roster 在 scope 出生时预热一次。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。
|
||||
输入触发管线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` snapshot store 发布。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。
|
||||
|
||||
分层:`src/core/`(T2)是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。
|
||||
|
||||
|
||||
@@ -40,18 +40,35 @@ export interface SlashControllerDeps {
|
||||
export class SlashController {
|
||||
/** Menu state store (per-session; survives session switches, dies with the scope). */
|
||||
readonly menu: SnapshotStore<MenuState> = createSnapshotStore<MenuState>(MENU_CLOSED)
|
||||
/**
|
||||
* Aggregated hot reference lexicon, grouped by trigger (decision 21):
|
||||
* sources implementing the lexicon hook are polled with the session
|
||||
* projection; undefined answers (roll not hot yet) are skipped; multiple
|
||||
* sources on one trigger concatenate in registration order. A snapshot
|
||||
* store because rolls change asynchronously (catalog settles, children
|
||||
* spawn/exit) — render-side consumers subscribe instead of re-reading a
|
||||
* mutable answer.
|
||||
*/
|
||||
readonly lexicon: SnapshotStore<ReadonlyMap<TriggerChar, readonly string[]>> =
|
||||
createSnapshotStore<ReadonlyMap<TriggerChar, readonly string[]>>(new Map())
|
||||
|
||||
/** The authoritative hit: single truth for span CAS material (menu snapshot never carries it alone). */
|
||||
private hit: TriggerHit | null = null
|
||||
private fetch: AbortController | null = null
|
||||
private disposed = false
|
||||
/** Per-source lexicon unsubscribers (sources without the hook never enter). */
|
||||
private readonly lexiconOffs = new Map<SlashSource, () => void>()
|
||||
|
||||
constructor(private readonly deps: SlashControllerDeps) {
|
||||
// Scope-birth prewarm: sessions are always agent-backed, so the one-time
|
||||
// roster warm here replaces the projection-transition watch — there are
|
||||
// no capability steps to react to.
|
||||
const projection = this.project()
|
||||
for (const src of deps.roster.all()) src.warm?.(projection)
|
||||
for (const src of deps.roster.all()) {
|
||||
src.warm?.(projection)
|
||||
this.watchLexicon(src, projection)
|
||||
}
|
||||
this.refreshLexicon()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,6 +237,23 @@ export class SlashController {
|
||||
if (state.open && state.hit !== null && state.hit.trigger === source.trigger) {
|
||||
this.reduce({ type: 'source-failed', generation: state.generation, source: source.name })
|
||||
}
|
||||
this.lexiconOffs.get(source)?.()
|
||||
this.lexiconOffs.delete(source)
|
||||
this.refreshLexicon()
|
||||
}
|
||||
|
||||
/**
|
||||
* Admit a source registered after this controller's birth (root registry
|
||||
* change notification): warm it and fold its roll into the live lexicon —
|
||||
* the constructor-time prewarm covers only the roster present at scope
|
||||
* birth.
|
||||
* @param source - the newly registered source.
|
||||
*/
|
||||
sourceAdded(source: SlashSource): void {
|
||||
const projection = this.project()
|
||||
source.warm?.(projection)
|
||||
this.watchLexicon(source, projection)
|
||||
this.refreshLexicon()
|
||||
}
|
||||
|
||||
/** Scope teardown: close and abort (the service deletes the map entry). */
|
||||
@@ -228,6 +262,8 @@ export class SlashController {
|
||||
this.stopFetch()
|
||||
this.reduce({ type: 'close' })
|
||||
this.hit = null
|
||||
for (const off of this.lexiconOffs.values()) off()
|
||||
this.lexiconOffs.clear()
|
||||
}
|
||||
|
||||
/** The session projection handed to sources (agent-backed identity; constant per scope). */
|
||||
@@ -248,25 +284,33 @@ export class SlashController {
|
||||
return actx.bail(actx, 'slash/input-insert-reference', { reference: outcome.insert, span }) === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate the sources' plain-text reference lexicons (decision 21),
|
||||
* grouped by trigger: sources implementing the hook are polled with the
|
||||
* session projection (onSpace's poll pattern); undefined answers (roll not
|
||||
* hot yet) are skipped; multiple sources on one trigger concatenate in
|
||||
* registration order.
|
||||
* @returns trigger → decorated-name roll for the decoration scan.
|
||||
*/
|
||||
lexicon(): ReadonlyMap<TriggerChar, readonly string[]> {
|
||||
/** Re-poll every lexicon-bearing source and publish the aggregated rolls (see the store doc). */
|
||||
private refreshLexicon(): void {
|
||||
const projection = this.project()
|
||||
const rolls = new Map<TriggerChar, readonly string[]>()
|
||||
for (const src of this.deps.roster.all()) {
|
||||
if (src.lexicon === undefined) continue
|
||||
const names = src.lexicon(projection)
|
||||
let names: readonly string[] | undefined
|
||||
try {
|
||||
names = src.lexicon(projection)
|
||||
} catch (error) {
|
||||
// A faulty source drops silently with a console record (the
|
||||
// candidate-fetch failure policy); the refresh runs inside
|
||||
// notification callbacks, where a throw would starve other consumers.
|
||||
console.error(`[ui-slash] source "${src.name}" lexicon failed:`, error)
|
||||
continue
|
||||
}
|
||||
if (names === undefined) continue
|
||||
const prev = rolls.get(src.trigger)
|
||||
rolls.set(src.trigger, prev === undefined ? names : [...prev, ...names])
|
||||
}
|
||||
return rolls
|
||||
this.lexicon.set(rolls)
|
||||
}
|
||||
|
||||
/** Wire one source's lexicon invalidation channel into refresh (hookless or roll-less sources never notify). */
|
||||
private watchLexicon(source: SlashSource, projection: ClientSessionContext): void {
|
||||
if (source.lexicon === undefined || source.subscribeLexicon === undefined) return
|
||||
this.lexiconOffs.set(source, source.subscribeLexicon(projection, () => { this.refreshLexicon() }))
|
||||
}
|
||||
|
||||
/** Launch the candidate fetch for one hit generation, superseding the previous one. */
|
||||
|
||||
@@ -38,7 +38,8 @@ export class SlashService extends Service implements SlashServiceContract {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one trigger source.
|
||||
* Register one trigger source. Live session controllers are notified so a
|
||||
* source arriving after scope birth still warms and joins the lexicon.
|
||||
* @param src - the source; (trigger, name) must be unique — duplicates throw.
|
||||
* @returns the disposer (callers wrap registration in ctx.effect). Disposal
|
||||
* while a controller shows the source's menu group drops that group.
|
||||
@@ -49,6 +50,16 @@ export class SlashService extends Service implements SlashServiceContract {
|
||||
throw new Error(`slash source "${src.trigger}${src.name}" is already registered`)
|
||||
}
|
||||
live.sources.push(src)
|
||||
for (const controller of live.controllers.values()) {
|
||||
try {
|
||||
controller.sourceAdded(src)
|
||||
} catch (error) {
|
||||
// Contain faulty source callbacks (warm/subscribeLexicon): the
|
||||
// registration must stand with a usable disposer and the remaining
|
||||
// controllers must still be notified.
|
||||
console.error(`[ui-slash] source "${src.trigger}${src.name}" late-registration setup failed:`, error)
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
const at = live.sources.indexOf(src)
|
||||
if (at < 0) return
|
||||
|
||||
@@ -165,6 +165,16 @@ export interface SlashSource {
|
||||
* (the render path must stay synchronous and side-effect free).
|
||||
*/
|
||||
lexicon?(session: ClientSessionContext): readonly string[] | undefined
|
||||
/**
|
||||
* Subscribe to changes of this source's {@link SlashSource.lexicon} answer
|
||||
* for one session (backing data settled, invalidated, or refreshed). The
|
||||
* controller re-polls lexicon on each notification; a source whose roll
|
||||
* never changes after warm omits the hook.
|
||||
* @param session - stable session projection.
|
||||
* @param listener - invalidation callback.
|
||||
* @returns unsubscribe.
|
||||
*/
|
||||
subscribeLexicon?(session: ClientSessionContext, listener: () => void): () => void
|
||||
/** Reference codec; required for sources producing insert outcomes. */
|
||||
readonly codec?: ReferenceCodec
|
||||
}
|
||||
|
||||
@@ -126,6 +126,18 @@ describe('registerSource', () => {
|
||||
slash.registerSource(deferredSource('/', 'beta').source)
|
||||
})
|
||||
|
||||
it('a source registered after controller birth warms in every live controller', async () => {
|
||||
const { slash, mint } = await serviceBench()
|
||||
const ca = slash.sessionOf(mint('a').actx)
|
||||
const cb = slash.sessionOf(mint('b').actx)
|
||||
const late = deferredSource('/', 'late', { lexicon: () => ['fresh'] })
|
||||
slash.registerSource(late.source)
|
||||
expect(late.warm).toHaveBeenNthCalledWith(1, { sessionId: sid('a') })
|
||||
expect(late.warm).toHaveBeenNthCalledWith(2, { sessionId: sid('b') })
|
||||
expect(ca.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
|
||||
expect(cb.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
|
||||
})
|
||||
|
||||
it('HMR shape: dispose of the registering fiber removes the source', async () => {
|
||||
const { root, slash, mint } = await serviceBench()
|
||||
const controller = slash.sessionOf(mint('a').actx)
|
||||
@@ -513,7 +525,7 @@ describe('lexicon', () => {
|
||||
skill,
|
||||
lexSource('@', 'subagent', ['worker-1']),
|
||||
])
|
||||
const rolls = controller.lexicon()
|
||||
const rolls = controller.lexicon.getSnapshot()
|
||||
expect([...rolls.keys()]).toEqual(['/', '@'])
|
||||
expect(rolls.get('/')).toEqual(['commit-helper', 'review'])
|
||||
expect(rolls.get('@')).toEqual(['worker-1'])
|
||||
@@ -522,7 +534,7 @@ describe('lexicon', () => {
|
||||
|
||||
it('an undefined answer (roll not hot) is skipped without seeding the trigger', () => {
|
||||
const { controller } = controllerBench([lexSource('/', 'skill', undefined)])
|
||||
expect(controller.lexicon().size).toBe(0)
|
||||
expect(controller.lexicon.getSnapshot().size).toBe(0)
|
||||
})
|
||||
|
||||
it('two sources on one trigger concatenate in registration order', () => {
|
||||
@@ -531,10 +543,63 @@ describe('lexicon', () => {
|
||||
lexSource('/', 'prompt', ['c']),
|
||||
lexSource('@', 'subagent', undefined), // not hot: '@' stays absent
|
||||
])
|
||||
const rolls = controller.lexicon()
|
||||
const rolls = controller.lexicon.getSnapshot()
|
||||
expect(rolls.get('/')).toEqual(['b', 'a', 'c'])
|
||||
expect(rolls.has('@')).toBe(false)
|
||||
})
|
||||
|
||||
it('a source lexicon notification republishes the aggregated store', () => {
|
||||
let roll: readonly string[] | undefined = undefined
|
||||
let notify: (() => void) | undefined
|
||||
const source: SlashSource = {
|
||||
trigger: '/',
|
||||
name: 'skill',
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
lexicon: () => roll,
|
||||
subscribeLexicon: (_session, listener) => {
|
||||
notify = listener
|
||||
return () => { notify = undefined }
|
||||
},
|
||||
}
|
||||
const { controller } = controllerBench([source])
|
||||
expect(controller.lexicon.getSnapshot().size).toBe(0)
|
||||
const seen: number[] = []
|
||||
controller.lexicon.subscribe(() => { seen.push(controller.lexicon.getSnapshot().size) })
|
||||
roll = ['commit-helper']
|
||||
notify?.()
|
||||
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['commit-helper'])
|
||||
expect(seen).toEqual([1])
|
||||
controller.dispose()
|
||||
expect(notify).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a source registered after scope birth is warmed and folded into the live lexicon', () => {
|
||||
const { controller, sources } = controllerBench([])
|
||||
expect(controller.lexicon.getSnapshot().size).toBe(0)
|
||||
const warm = vi.fn()
|
||||
const late: SlashSource = {
|
||||
trigger: '/',
|
||||
name: 'late',
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
warm,
|
||||
lexicon: () => ['fresh'],
|
||||
}
|
||||
sources.push(late)
|
||||
controller.sourceAdded(late)
|
||||
expect(warm).toHaveBeenCalledWith({ sessionId: sid('a') })
|
||||
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
|
||||
})
|
||||
|
||||
it('a removed source leaves the aggregated lexicon', () => {
|
||||
const src = lexSource('/', 'skill', ['gone'])
|
||||
const { controller, sources } = controllerBench([src])
|
||||
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['gone'])
|
||||
sources.splice(sources.indexOf(src), 1)
|
||||
controller.sourceRemoved(src)
|
||||
expect(controller.lexicon.getSnapshot().size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('arbitrate', () => {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* consumer merges keys in and the intersection is what keeps them string-typed.
|
||||
* The rule fires on the empty-map view, not on real redundancy. */
|
||||
import type { ReactNode } from 'react'
|
||||
import type { HostObservable } from './renderer.ts'
|
||||
import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDecl } from './store.ts'
|
||||
|
||||
export * from './store.ts'
|
||||
@@ -214,11 +215,40 @@ export type PropsRenderSlots<S extends keyof SlotMap & string> = {
|
||||
*/
|
||||
export type SlotComponent<P> = (props: P) => ReactNode
|
||||
|
||||
/**
|
||||
* Registrant hooks compartment: bare observable sources (getSnapshot +
|
||||
* subscribe pairs) supplied under the reserved `hooks` key of an inject
|
||||
* face. The registrant-private twin of the `sessions.provide` hooks
|
||||
* compartment: the renderer binds each source into a `use<Name>` selector
|
||||
* hook, so the sources never reach the component and plugin-private reactive
|
||||
* facts ride the same subscription machinery as the standard kit instead of
|
||||
* hand-rolled component subscriptions.
|
||||
*/
|
||||
export type HooksSources = Record<string, HostObservable<unknown>>
|
||||
|
||||
/**
|
||||
* Selector-hook share synthesized from a hooks compartment: each source
|
||||
* `name` becomes a `use<Name>` selector hook over its snapshot type.
|
||||
*/
|
||||
export type PropsHooks<HS extends HooksSources> = {
|
||||
[N in keyof HS & string as `use${Capitalize<N>}`]:
|
||||
SnapshotSelectorHook<HS[N] extends HostObservable<infer T> ? T : never>
|
||||
}
|
||||
|
||||
/**
|
||||
* The component-side view of an inject face: the reserved `hooks`
|
||||
* compartment (when declared) arrives as bound `use<Name>` selector hooks;
|
||||
* every other member passes through verbatim.
|
||||
*/
|
||||
export type InjectFace<I extends object> =
|
||||
I extends { hooks: infer HS extends HooksSources } ? Omit<I, 'hooks'> & PropsHooks<HS> : I
|
||||
|
||||
/**
|
||||
* The four-share component props intersection: runtime share (SlotMap) +
|
||||
* child-render share (children declaration) + store share (declared handle) +
|
||||
* the registrant's injected business face. Each share derives from its single
|
||||
* source of truth; components reference this composition, never re-type it.
|
||||
* the registrant's injected business face (its hooks compartment bound, see
|
||||
* {@link InjectFace}). Each share derives from its single source of truth;
|
||||
* components reference this composition, never re-type it.
|
||||
*/
|
||||
export type ComposedProps<
|
||||
K extends keyof SlotMap & string,
|
||||
@@ -226,7 +256,7 @@ export type ComposedProps<
|
||||
H,
|
||||
I extends object,
|
||||
M = never,
|
||||
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & I & MatchedShare<SlotMap[K], M>
|
||||
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M>
|
||||
|
||||
/**
|
||||
* Inject factory parameter list, derived from the registration's declaration:
|
||||
|
||||
@@ -105,18 +105,14 @@ export interface SlotRendererHost {
|
||||
sessions: {
|
||||
/** Session list source backing the useSessions standard hook. */
|
||||
list: HostObservable<unknown>
|
||||
/** Current-session source used by SessionProvider. */
|
||||
current: HostObservable<string | undefined>
|
||||
/** Resolve a definite session bundle, or undefined when the id is unknown. */
|
||||
provideInfo(id: string): SessionProvideInfo | undefined
|
||||
/**
|
||||
* Resolve the current-session-optional standard props bundle. The result
|
||||
* always carries the static provider roster, even when `id` is absent or
|
||||
* cannot resolve to a live session.
|
||||
* @param id - current session id, when selected.
|
||||
* @returns the optional provide info.
|
||||
* Atomic current-session provide projection used by SessionProvider:
|
||||
* selection changes and provider-roster changes publish through this one
|
||||
* source, so a stable current id cannot strand mounted entries on an
|
||||
* obsolete hook/prop schema. Carries the static roster with sessionId
|
||||
* undefined while no current session resolves.
|
||||
*/
|
||||
maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo
|
||||
provideInfo: HostObservable<SessionMaybeProvideInfo>
|
||||
}
|
||||
/** Workspace-side standard-kit sources. */
|
||||
workspaces: {
|
||||
|
||||
@@ -39,6 +39,10 @@ export function apply(ctx: ClientContext): void {
|
||||
// The list snapshot is always warm — the full running-children roster.
|
||||
return childLabels(session, '')
|
||||
},
|
||||
subscribeLexicon(_session, listener) {
|
||||
// The roll derives from the list snapshot, so its change feed IS the list's.
|
||||
return sessions.list.subscribe(listener)
|
||||
},
|
||||
onPick({ candidate }) {
|
||||
// Decision 21: plain-text reference — the literal lands in the draft
|
||||
// and ships to the model verbatim (trailing space closes the token).
|
||||
|
||||
@@ -32,17 +32,31 @@ 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 } }
|
||||
const subs = new Set<() => void>()
|
||||
return {
|
||||
list: {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (fn: () => void) => { subs.add(fn); return () => { subs.delete(fn) } },
|
||||
},
|
||||
notify: () => { for (const fn of [...subs]) fn() },
|
||||
listenerCount: () => subs.size,
|
||||
}
|
||||
}
|
||||
|
||||
/** Boot the plugin over fake slash/sessions faces; returns the captured source. */
|
||||
async function bench(sessions: SessionSummary[]): Promise<SlashSource> {
|
||||
/** Boot the plugin over fake slash/sessions faces; returns the captured source and the list face. */
|
||||
async function fullBench(sessions: SessionSummary[]) {
|
||||
const ctx = new Context()
|
||||
let captured: SlashSource | undefined
|
||||
const face = sessionsWith(sessions)
|
||||
ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
|
||||
ctx.provide('sessions', sessionsWith(sessions))
|
||||
ctx.provide('sessions', face)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
return captured!
|
||||
return { source: captured!, face }
|
||||
}
|
||||
|
||||
/** Source-only bench for the behavior-contract suites. */
|
||||
async function bench(sessions: SessionSummary[]): Promise<SlashSource> {
|
||||
return (await fullBench(sessions)).source
|
||||
}
|
||||
|
||||
const FAMILY: SessionSummary[] = [
|
||||
@@ -113,6 +127,19 @@ describe('lexicon', () => {
|
||||
expect(source.lexicon!(proj('parent'))).toEqual(['worker-1', 'worker-2', 'scout'])
|
||||
expect(source.lexicon!(proj('childless'))).toEqual([])
|
||||
})
|
||||
|
||||
it('subscribeLexicon forwards the session-list change feed and unsubscribes cleanly', async () => {
|
||||
const { source, face } = await fullBench(FAMILY)
|
||||
let notified = 0
|
||||
const off = source.subscribeLexicon!(proj('parent'), () => { notified += 1 })
|
||||
expect(face.listenerCount()).toBe(1)
|
||||
face.notify()
|
||||
expect(notified).toBe(1)
|
||||
off()
|
||||
expect(face.listenerCount()).toBe(0)
|
||||
face.notify()
|
||||
expect(notified).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pick and codec', () => {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
|
||||
import {
|
||||
SlotOwnershipError, StaleAuthorizationError,
|
||||
type ChainRenderOpts, type RenderOpts, type SessionMaybeProvideInfo, type SessionProvideInfo,
|
||||
type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
|
||||
type ChainRenderOpts, type HostObservable, type RenderOpts, type SessionMaybeProvideInfo,
|
||||
type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
|
||||
@@ -96,7 +96,26 @@ function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined
|
||||
const args: unknown[] = []
|
||||
if (info !== undefined) args.push(info.sessionId)
|
||||
if (actions !== undefined) args.push(actions)
|
||||
return (inject as (...args: unknown[]) => InjectedProps)(...args)
|
||||
return bindInjectHooks((inject as (...args: unknown[]) => InjectedProps)(...args))
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind an inject face's reserved `hooks` compartment (bare observable
|
||||
* sources, see HooksSources) into `use<Name>` selector hooks — the
|
||||
* registrant-private twin of the provide-bundle binding in standardKit.
|
||||
* Runs once per cached inject result; hook identity rides observableHook's
|
||||
* per-source cache.
|
||||
*/
|
||||
function bindInjectHooks(face: InjectedProps): InjectedProps {
|
||||
const sources = face['hooks']
|
||||
if (sources === undefined) return face
|
||||
const { hooks: _hooks, ...rest } = face
|
||||
const bound: InjectedProps = rest
|
||||
for (const [name, source] of Object.entries(sources as Record<string, HostObservable<unknown>>)) {
|
||||
const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
|
||||
bound[hookName] = observableHook(source)
|
||||
}
|
||||
return bound
|
||||
}
|
||||
|
||||
function cachedRootInject(entry: StoredEntry, actions: object | undefined): InjectedProps {
|
||||
|
||||
@@ -90,9 +90,9 @@ function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S,
|
||||
*/
|
||||
export function SessionMaybeProvider({ children }: { children: ReactNode }) {
|
||||
const host = useHost()
|
||||
const id = observableHook(host.sessions.current)(s => s)
|
||||
const info = observableHook(host.sessions.provideInfo)(s => s)
|
||||
return (
|
||||
<BindingContext.Provider value={host.sessions.maybeProvideInfo(id)}>
|
||||
<BindingContext.Provider value={info}>
|
||||
{children}
|
||||
</BindingContext.Provider>
|
||||
)
|
||||
@@ -107,17 +107,17 @@ export interface SessionProviderProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Framework-wired session area: subscribes to the host's current-session
|
||||
* source, resolves the session cell, and remounts the body under
|
||||
* `key={sessionId}` so a session switch rebuilds the session subtree. This
|
||||
* dependency-inverted layer uses plain string ids; `PropsRuntime` applies the
|
||||
* branded type at the component boundary.
|
||||
* Framework-wired session area: subscribes to the host's current provide
|
||||
* source and remounts the body under `key={sessionId}` so a session switch
|
||||
* rebuilds the session subtree. This dependency-inverted layer uses plain
|
||||
* string ids; `PropsRuntime` applies the branded type at the component
|
||||
* boundary.
|
||||
*/
|
||||
export function SessionProvider({ empty, children }: SessionProviderProps) {
|
||||
const host = useHost()
|
||||
const id = observableHook(host.sessions.current)(s => s)
|
||||
const info = id === undefined ? undefined : host.sessions.provideInfo(id)
|
||||
if (id === undefined || info === undefined) return <>{empty?.() ?? null}</>
|
||||
const info = observableHook(host.sessions.provideInfo)(s => s)
|
||||
const id = info.sessionId
|
||||
if (id === undefined) return <>{empty?.() ?? null}</>
|
||||
return (
|
||||
<BindingContext.Provider value={info} key={id}>
|
||||
{children(id)}
|
||||
|
||||
@@ -26,6 +26,7 @@ type FrameSlots = PropsRenderSlots<'spec.single' | 'spec.list'>
|
||||
|
||||
/** Passthrough host over the real core (store/session seats unused here). */
|
||||
function hostOver(core: SlotCore): SlotRendererHost {
|
||||
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
|
||||
return {
|
||||
subscribe: (key, fn) => core.subscribe(key, fn),
|
||||
getVersion: key => core.getVersion(key),
|
||||
@@ -35,9 +36,7 @@ function hostOver(core: SlotCore): SlotRendererHost {
|
||||
storeOf: () => undefined,
|
||||
sessions: {
|
||||
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
|
||||
current: { getSnapshot: () => undefined, subscribe: () => () => {} },
|
||||
provideInfo: () => undefined,
|
||||
maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }),
|
||||
provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
|
||||
},
|
||||
workspaces: {
|
||||
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
|
||||
|
||||
@@ -12,6 +12,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, render } from '@testing-library/react'
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
createSlotRenderer, SessionProvider, SlotOwnershipError, StaleAuthorizationError,
|
||||
type RenderOpts, type SessionProvideInfo,
|
||||
@@ -85,7 +86,9 @@ function makeHost() {
|
||||
const storeCache = new Map<StoredEntry, Map<string, StoreInstanceLike>>()
|
||||
const list = observable<{ ids: string[] }>({ ids: [] })
|
||||
const workspaces = observable<{ ids: string[] }>({ ids: [] })
|
||||
const current = observable<string | undefined>(undefined)
|
||||
const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: {}, props: {} }
|
||||
const provide = observable<SessionMaybeProvideInfo>(absentInfo)
|
||||
let currentId: string | undefined
|
||||
const infos = new Map<string, SessionProvideInfo>()
|
||||
|
||||
const bump = (key: string) => {
|
||||
@@ -123,10 +126,7 @@ function makeHost() {
|
||||
},
|
||||
sessions: {
|
||||
list,
|
||||
current,
|
||||
provideInfo: id => infos.get(id),
|
||||
maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
|
||||
?? { sessionId: undefined, hooks: {}, props: {} },
|
||||
provideInfo: provide,
|
||||
},
|
||||
workspaces: { list: workspaces },
|
||||
}
|
||||
@@ -134,7 +134,14 @@ function makeHost() {
|
||||
host,
|
||||
list,
|
||||
workspaces,
|
||||
current,
|
||||
// Same driver surface as the old current cell: set(id) publishes the
|
||||
// resolved bundle (or the absent projection) through the provide source.
|
||||
current: {
|
||||
set: (id: string | undefined) => {
|
||||
currentId = id
|
||||
provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo)
|
||||
},
|
||||
},
|
||||
declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) },
|
||||
add: (key: string, partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }) => {
|
||||
const entry = entryOf(partial)
|
||||
@@ -161,6 +168,7 @@ function makeHost() {
|
||||
props: {},
|
||||
}
|
||||
infos.set(id, info)
|
||||
if (currentId === id) provide.set(info)
|
||||
return info
|
||||
},
|
||||
}
|
||||
@@ -740,6 +748,25 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
|
||||
expect(inject).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('binds the inject hooks compartment into use<Name> selector hooks (sources never reach the component)', () => {
|
||||
const h = makeHost()
|
||||
h.declare('k.single', SINGLE_ROOT)
|
||||
const badge = observable('cold')
|
||||
const seen: Record<string, unknown>[] = []
|
||||
h.add('k.single', {
|
||||
component: (props: { useBadge?: <S>(sel: (s: string) => S) => S; hooks?: unknown; plain?: string }) => {
|
||||
seen.push({ hooks: props.hooks, plain: props.plain, read: props.useBadge!(s => s) })
|
||||
return null
|
||||
},
|
||||
inject: () => ({ plain: 'kept', hooks: { badge } }),
|
||||
})
|
||||
mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
|
||||
// The raw compartment is consumed by the binding; the plain member passes through.
|
||||
expect(seen.at(-1)).toEqual({ hooks: undefined, plain: 'kept', read: 'cold' })
|
||||
act(() => { badge.set('hot') })
|
||||
expect(seen.at(-1)!['read']).toBe('hot')
|
||||
})
|
||||
|
||||
it('session inject receives sessionId and caches per (entry x session): switch-back reuses', () => {
|
||||
const h = makeHost()
|
||||
h.declare('k.session', SINGLE_SESSION)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionMaybeProvideInfo, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
createSlotRenderer, SessionProvider,
|
||||
type SessionProvideInfo, type SlotRendererHost,
|
||||
@@ -26,12 +26,14 @@ function observable<T>(initial: T) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal host: SessionProvider only reads sessions.current/cell, but it must
|
||||
* Minimal host: SessionProvider only reads sessions.provideInfo, but it must
|
||||
* render inside the renderer tree (HostContext), so the harness mounts a real
|
||||
* root entry whose body is the test's render-prop provider.
|
||||
*/
|
||||
function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.ReactNode) => React.ReactNode }) {
|
||||
const current = observable<string | undefined>(undefined)
|
||||
const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
|
||||
const provide = observable<SessionMaybeProvideInfo>(absentInfo)
|
||||
let currentId: string | undefined
|
||||
const infos = new Map<string, SessionProvideInfo>()
|
||||
const sessionEntries: StoredEntry[] = []
|
||||
const rootEntry: StoredEntry = {
|
||||
@@ -49,16 +51,20 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
|
||||
storeOf: () => undefined,
|
||||
sessions: {
|
||||
list: observable<unknown>({ ids: [] }),
|
||||
current,
|
||||
provideInfo: id => infos.get(id),
|
||||
maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
|
||||
?? { sessionId: undefined, hooks: { session: undefined }, props: {} },
|
||||
provideInfo: provide,
|
||||
},
|
||||
workspaces: { list: observable<unknown>({ items: [] }) },
|
||||
}
|
||||
return {
|
||||
host,
|
||||
current,
|
||||
// Same driver surface as the old current cell: set(id) publishes the
|
||||
// resolved bundle (or the absent projection) through the provide source.
|
||||
current: {
|
||||
set: (id: string | undefined) => {
|
||||
currentId = id
|
||||
provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo)
|
||||
},
|
||||
},
|
||||
addSession: (id: string) => {
|
||||
// Bare source per bundle (identity-stable): the machinery binds useSession from it.
|
||||
const info: SessionProvideInfo = {
|
||||
@@ -67,8 +73,14 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
|
||||
props: {},
|
||||
}
|
||||
infos.set(id, info)
|
||||
if (currentId === id) provide.set(info)
|
||||
return info
|
||||
},
|
||||
/** Swap one session's bundle in place (roster-change stand-in); republish when current. */
|
||||
replaceSession: (info: SessionProvideInfo) => {
|
||||
infos.set(info.sessionId, info)
|
||||
if (currentId === info.sessionId) provide.set(info)
|
||||
},
|
||||
registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
|
||||
}
|
||||
}
|
||||
@@ -149,6 +161,28 @@ describe('SessionProvider', () => {
|
||||
expect(seen.at(-1)!['sessionId']).toBe('s2')
|
||||
})
|
||||
|
||||
it('republishes a mounted session entry when its provide bundle changes under the same id', () => {
|
||||
const seen: unknown[] = []
|
||||
const h = makeHost({
|
||||
root: renderSlot => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
|
||||
})
|
||||
const original = h.addSession('s1')
|
||||
h.registerSession({
|
||||
component: (props: { feature?: string }) => {
|
||||
seen.push(props.feature)
|
||||
return null
|
||||
},
|
||||
options: {},
|
||||
})
|
||||
render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
|
||||
act(() => { h.current.set('s1') })
|
||||
expect(seen.at(-1)).toBeUndefined()
|
||||
// A provider-roster change rematerializes the bundle; the provide source
|
||||
// must carry it to already-mounted entries without a selection change.
|
||||
act(() => { h.replaceSession({ ...original, props: { feature: 'now-live' } }) })
|
||||
expect(seen.at(-1)).toBe('now-live')
|
||||
})
|
||||
|
||||
it('fails loud when mounted outside the renderer tree (no host channel)', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
expect(() => render(
|
||||
|
||||
@@ -21,6 +21,7 @@ function makeHost() {
|
||||
const versions = new Map<string, number>()
|
||||
const subs = new Map<string, Set<() => void>>()
|
||||
const live = new Set<StoredEntry>()
|
||||
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
|
||||
const bump = (key: string) => {
|
||||
versions.set(key, (versions.get(key) ?? 0) + 1)
|
||||
for (const fn of [...(subs.get(key) ?? [])]) fn()
|
||||
@@ -39,9 +40,7 @@ function makeHost() {
|
||||
storeOf: () => undefined,
|
||||
sessions: {
|
||||
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
|
||||
current: { getSnapshot: () => undefined, subscribe: () => () => {} },
|
||||
provideInfo: () => undefined,
|
||||
maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }),
|
||||
provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
|
||||
},
|
||||
workspaces: {
|
||||
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
|
||||
|
||||
@@ -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/context/session-reference/README.md
|
||||
README.md: 2ca461f88b266b4dec1ffa4132c8cb17455f4b8e
|
||||
README.zh.md: 9f8fd0bace9b37b2f7885eded7686ecac8c625ba
|
||||
README.md: 6def2923cf3bc0021b0db578279a1b0571106d41
|
||||
README.zh.md: 9d7abfa78e6d35b2149c9436d5b397e4a30404d7
|
||||
|
||||
@@ -6,7 +6,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Public API
|
||||
|
||||
- `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id or cwd, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses its latest log-backed title as the mention label and falls back to the session id; titles and message bodies are not searched.
|
||||
- `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id, cwd, or the latest log-backed title, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses that title as the mention label and falls back to the session id when the title is absent or unreadable; message bodies are not searched.
|
||||
- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated `UserMessageData` context. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `followup()` or `steer()`.
|
||||
- `encodeSessionReferenceUri()` and `decodeSessionReferenceUri()` implement `dsh-session:<base64url(JSON.stringify(sessionId))>` so every JavaScript string id round-trips exactly. `formatSessionReferenceMention()` emits `@[label](uri)`, and `parseSessionReferenceText()` replaces Markdown mentions or bare canonical URIs with readable `@label` text while returning structured references. Explicit Markdown mentions reject every malformed URI; bare text is considered a reference only when a non-empty base64url-shaped payload follows the scheme, and a matching noncanonical candidate still fails. Empty or punctuation-only scheme mentions remain ordinary discussion text.
|
||||
|
||||
@@ -21,7 +21,7 @@ The context source is `{ kind: 'session-reference', version: 1, references }`; e
|
||||
| Key | Default | Contract |
|
||||
|---|---:|---|
|
||||
| `maxReferences` | `3` | Maximum distinct source sessions in one prepared message; must be at most `3`. |
|
||||
| `candidateLimit` | `50` | Default metadata candidate count returned to a host. |
|
||||
| `candidateLimit` | `50` | Default candidate count returned to a host. |
|
||||
| `maxReferenceBytes` | `65536` | Maximum serialized JSON bytes for one reference object. |
|
||||
|
||||
Retention applies `maxReferenceBytes` independently to each source, keeps compact checkpoints and the newest message before dropping older non-checkpoint units, and uses `dsh-retention` head/tail truncation with an exact UTF-8 omission notice. If one source's fixed serialized fields cannot fit, preparation fails with `SESSION_REFERENCE_BUDGET_EXCEEDED` instead of returning a partial context.
|
||||
@@ -44,7 +44,7 @@ The snapshot and request are consecutive append-only target messages and preserv
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No title or full-text discovery** — candidates filter by session id and cwd only, although selected rows display the latest title. SQLite FTS may replace discovery later without changing URI, snapshot, or persistence contracts.
|
||||
- **No body discovery** — candidate queries inspect folded titles but do not search message bodies. A non-empty query may inspect every visible persisted session log through the session-query service's bounded, cancellable batch; a dedicated title index may replace that discovery path without changing URI, snapshot, or persistence contracts.
|
||||
- **Trusted caller boundary** — the service assumes its host is authorized to read every session exposed by `ctx.sessionQuery`; it is not a model-facing search tool.
|
||||
- **Text projection only** — non-text user and assistant blocks are not propagated across sessions.
|
||||
- **No live link** — references are snapshots, not forks, resumes, subscriptions, or source-session mutations.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 公开 API
|
||||
|
||||
- `listCandidates(agent, query?, limit?)` 会列出 `agent.id` 之外的会话,按 id 或 cwd 进行不区分大小写的筛选,再按同 cwd、无 cwd、其他 cwd 记录排序,同时保持每组内的 `listSessions()` 创建顺序。每个已选候选会话都使用最新的日志支持标题作为 mention label,并回退到会话 id;不搜索标题与消息主体。
|
||||
- `listCandidates(agent, query?, limit?)` 会列出 `agent.id` 之外的会话,按 id、cwd 或日志中最新的标题进行不区分大小写的筛选,再按同 cwd、无 cwd、其他 cwd 记录排序,同时保持每组内的 `listSessions()` 创建顺序。每个已选候选会话都使用该标题作为 mention label;标题不存在或无法读取时回退到会话 id。不搜索消息主体。
|
||||
- `prepare(agent, content, references, signal?)` 会保留首次 mention 顺序、对 id 去重,并拒绝自引用或超过已配置不同源上限的情况。它会并行读取所有源,返回与输入脱离的内容,外加零个或一个聚合 `UserMessageData` 上下文。任何无效引用、读取失败、取消或预算失败都会在宿主调用 `followup()` 或 `steer()` 之前被拒绝。
|
||||
- `encodeSessionReferenceUri()` 与 `decodeSessionReferenceUri()` 实现 `dsh-session:<base64url(JSON.stringify(sessionId))>`,因此每个 JavaScript 字符串 id 都能精确往返。`formatSessionReferenceMention()` 发出 `@[label](uri)`,`parseSessionReferenceText()` 将 Markdown mention 或裸规范 URI 替换为可读的 `@label` 文本,并返回结构化引用。显式 Markdown mention 会拒绝每个格式错误的 URI;只当 scheme 后跟非空、符合 base64url 形状的 payload 时,裸文本才被视为引用,匹配但非规范的候选项仍会失败。空 scheme mention 或只含标点符号的 scheme mention 仍是普通讨论文本。
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
| Key | 默认值 | 契约 |
|
||||
|---|---:|---|
|
||||
| `maxReferences` | `3` | 一条已准备消息中不同源会话的最大数量;必须不大于 `3`。 |
|
||||
| `candidateLimit` | `50` | 返回给宿主的默认元数据候选数量。 |
|
||||
| `candidateLimit` | `50` | 返回给宿主的默认候选数量。 |
|
||||
| `maxReferenceBytes` | `65536` | 一个引用对象的最大序列化 JSON 字节数。 |
|
||||
|
||||
保留会对每个源独立应用 `maxReferenceBytes`,保留 compact 检查点与最新消息,再丢弃较旧的非检查点单元,并使用 `dsh-retention` 头部/尾部截断和精确 UTF-8 省略通知。如果某个源的固定序列化字段无法容纳,准备会以 `SESSION_REFERENCE_BUDGET_EXCEEDED` 失败,而不返回部分上下文。
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **没有标题或全文发现**:候选会话只按会话 id 与 cwd 筛选,但已选行会显示最新标题。SQLite FTS 未来可以替换发现机制,而不改变 URI、快照或持久化契约。
|
||||
- **不支持正文发现**:候选查询会检查折叠后的标题,但不搜索消息主体。非空查询可能通过 session-query 服务有界、可取消的批处理检查每个可见的持久化会话日志;专用标题索引未来可以替换这条发现路径,而不改变 URI、快照或持久化契约。
|
||||
- **受信任调用方边界**:该服务假设宿主有权读取 `ctx.sessionQuery` 公开的每个会话;它不是面向模型的搜索工具。
|
||||
- **只投影文本**:不会在会话间传播非文本 user 与 assistant 块。
|
||||
- **没有实时链接**:引用是快照,不是 fork、恢复、订阅或源会话变更。
|
||||
|
||||
@@ -10,7 +10,7 @@ import z from 'schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionId, UserMessageData } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query'
|
||||
import type { SessionSurfaceSnapshot, SessionTitleObservationResult } from '@deepseek-ai/dsh-session-query'
|
||||
import {
|
||||
DEFAULT_CANDIDATE_LIMIT,
|
||||
DEFAULT_MAX_REFERENCE_BYTES,
|
||||
@@ -102,7 +102,7 @@ export class SessionReferenceService extends Service {
|
||||
/**
|
||||
* List reference candidates, ranked by working-directory affinity.
|
||||
* @param agent - target agent; self is excluded and its cwd drives ranking.
|
||||
* @param query - optional case-insensitive session-id/cwd substring.
|
||||
* @param query - optional case-insensitive session-id/cwd/title substring.
|
||||
* @param limit - optional positive result cap.
|
||||
* @param signal - optional cancellation boundary for host autocomplete teardown.
|
||||
* @returns candidates labeled by latest title or, when absent, session id.
|
||||
@@ -119,27 +119,42 @@ export class SessionReferenceService extends Service {
|
||||
const needle = query.toLocaleLowerCase()
|
||||
const targetCwd = agent.session.header.cwd
|
||||
assertNotCancelled(signal)
|
||||
const records = (await settleWithCancellation(this.ctx.sessionQuery.listSessions(), signal))
|
||||
const records = (await settleWithCancellation(this.ctx.sessionQuery.listSessions(signal), signal))
|
||||
.filter(record => record.header.id !== agent.id)
|
||||
.filter((record) => {
|
||||
if (needle === '') return true
|
||||
return record.header.id.toLocaleLowerCase().includes(needle)
|
||||
|| record.header.cwd?.toLocaleLowerCase().includes(needle) === true
|
||||
})
|
||||
.map((record, index) => ({ record, index }))
|
||||
.sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd)
|
||||
|| a.index - b.index)
|
||||
.slice(0, limit)
|
||||
const titles = await settleWithCancellation(
|
||||
Promise.all(records.map(({ record }) => this.ctx.sessionQuery.readTitle(record.header.id))),
|
||||
const inspected = needle === ''
|
||||
? records
|
||||
.sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd)
|
||||
|| a.index - b.index)
|
||||
.slice(0, limit)
|
||||
: records
|
||||
const observations = await settleWithCancellation(
|
||||
this.ctx.sessionQuery.readTitleSnapshots(inspected.map(({ record }) => record.header.id), signal),
|
||||
signal,
|
||||
)
|
||||
return records.map(({ record }, index) => ({
|
||||
sessionId: record.header.id,
|
||||
label: titles[index]?.title ?? record.header.id,
|
||||
...record.header.cwd === undefined ? {} : { cwd: record.header.cwd },
|
||||
createdAt: record.header.createdAt,
|
||||
}))
|
||||
return inspected.map(({ record, index }, observationIndex) => {
|
||||
const observation = observations[observationIndex] as SessionTitleObservationResult
|
||||
return {
|
||||
record,
|
||||
index,
|
||||
label: observation.status === 'fulfilled'
|
||||
? observation.value.title?.title ?? record.header.id
|
||||
: record.header.id,
|
||||
}
|
||||
}).filter(({ record, label }) => {
|
||||
if (needle === '') return true
|
||||
return record.header.id.toLocaleLowerCase().includes(needle)
|
||||
|| record.header.cwd?.toLocaleLowerCase().includes(needle) === true
|
||||
|| label.toLocaleLowerCase().includes(needle)
|
||||
}).sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd)
|
||||
|| a.index - b.index)
|
||||
.slice(0, limit)
|
||||
.map(({ record, label }) => ({
|
||||
sessionId: record.header.id,
|
||||
label,
|
||||
...record.header.cwd === undefined ? {} : { cwd: record.header.cwd },
|
||||
createdAt: record.header.createdAt,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -188,7 +188,7 @@ describe('session reference URI and inline mentions', () => {
|
||||
})
|
||||
|
||||
describe('session reference discovery and preparation', () => {
|
||||
it('ranks metadata candidates by cwd without depending on full-text search', async () => {
|
||||
it('matches candidate metadata and titles before ranking by cwd', async () => {
|
||||
const ctx = await harness()
|
||||
const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same', createdAt: 10 } })
|
||||
ctx.sessions.create(SessionId('other'), { meta: { cwd: '/else', createdAt: 40 } })
|
||||
@@ -210,6 +210,9 @@ describe('session reference discovery and preparation', () => {
|
||||
await expect(ctx.sessionReferences.listCandidates(fakeAgent(target), 'els', 1)).resolves.toEqual([
|
||||
{ sessionId: SessionId('other'), label: 'other', cwd: '/else', createdAt: 40 },
|
||||
])
|
||||
await expect(ctx.sessionReferences.listCandidates(fakeAgent(target), 'LATEST', 1)).resolves.toEqual([
|
||||
{ sessionId: SessionId('same-later'), label: 'Latest title', cwd: '/same', createdAt: 25 },
|
||||
])
|
||||
await expect(ctx.sessionReferences.listCandidates(fakeAgent(target), '', 0))
|
||||
.rejects.toThrow(expectCode('SESSION_REFERENCE_INVALID_REFERENCE'))
|
||||
|
||||
@@ -229,6 +232,40 @@ describe('session reference discovery and preparation', () => {
|
||||
listSessions.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps metadata matches when one title observation fails and cancels a stalled title batch', async () => {
|
||||
const ctx = await harness()
|
||||
const target = ctx.sessions.create(SessionId('target'))
|
||||
const source = ctx.sessions.create(SessionId('source'))
|
||||
const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots')
|
||||
readTitles.mockResolvedValueOnce([{
|
||||
sessionId: source.id,
|
||||
status: 'rejected',
|
||||
reason: new Error('broken title log'),
|
||||
}])
|
||||
|
||||
await expect(ctx.sessionReferences.listCandidates(fakeAgent(target), 'source')).resolves.toEqual([
|
||||
{ sessionId: source.id, label: source.id, createdAt: source.header.createdAt },
|
||||
])
|
||||
|
||||
let releaseTitles: (() => void) | undefined
|
||||
let titleSignal: AbortSignal | undefined
|
||||
readTitles.mockImplementationOnce(async (_ids, signal) => {
|
||||
titleSignal = signal
|
||||
await new Promise<void>((resolve) => { releaseTitles = resolve })
|
||||
return []
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.sessionReferences.listCandidates(fakeAgent(target), 'source', undefined, controller.signal)
|
||||
await vi.waitFor(() => { expect(releaseTitles).toBeTypeOf('function') })
|
||||
expect(titleSignal).toBe(controller.signal)
|
||||
const cancelledTitles = expect(pending).rejects.toThrow(expectCode('SESSION_REFERENCE_CANCELLED'))
|
||||
controller.abort('autocomplete superseded')
|
||||
await cancelledTitles
|
||||
releaseTitles?.()
|
||||
await Promise.resolve()
|
||||
readTitles.mockRestore()
|
||||
})
|
||||
|
||||
it('projects only the current user/assistant surface and records snapshot metadata', async () => {
|
||||
const ctx = await harness()
|
||||
const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/target' } })
|
||||
|
||||
@@ -602,7 +602,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
methods: [
|
||||
{
|
||||
signature: 'async listCandidates( agent: Agent, query = \'\', limit = this.config.candidateLimit, signal?: AbortSignal, ): Promise<SessionReferenceCandidate[]>',
|
||||
jsDoc: '/**\n * List reference candidates, ranked by working-directory affinity.\n * @param agent - target agent; self is excluded and its cwd drives ranking.\n * @param query - optional case-insensitive session-id/cwd substring.\n * @param limit - optional positive result cap.\n * @param signal - optional cancellation boundary for host autocomplete teardown.\n * @returns candidates labeled by latest title or, when absent, session id.\n */',
|
||||
jsDoc: '/**\n * List reference candidates, ranked by working-directory affinity.\n * @param agent - target agent; self is excluded and its cwd drives ranking.\n * @param query - optional case-insensitive session-id/cwd/title substring.\n * @param limit - optional positive result cap.\n * @param signal - optional cancellation boundary for host autocomplete teardown.\n * @returns candidates labeled by latest title or, when absent, session id.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise<PreparedReferencedMessage>',
|
||||
|
||||
@@ -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/sdk/sdk-client/README.md
|
||||
README.md: e2aaf08212307bfac0c73b5e838679a7a750a92a
|
||||
README.zh.md: cbefae59d95cc0cb9d89145ad3f2ee3248822714
|
||||
README.md: 33a933e10abfa865cf9ce34b87c377d07081cc68
|
||||
README.zh.md: 9f4453a00efef2685acec0194f83fcec2edf1409
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The TypeScript client SDK for driving a DeepSeek Harness runtime as a subprocess over stdio JSON-RPC — the design twin of the [Python SDK](../../../python/README.md) (`deepseek-harness`), sharing the same runtime peer, protocol, and layering: `DeepSeekHarness` is the high-level turns API, `HarnessClient` the lower-level protocol client. A pure library: it registers nothing on a Cordis context; the runtime process it spawns is a complete harness whose composition its own `cordis.yml` decides.
|
||||
The TypeScript client SDK for driving a DeepSeek Harness runtime as a subprocess over stdio JSON-RPC — the design twin of the [Python SDK](../../../python/README.md) (`deepseek-harness`), sharing the same runtime peer, protocol, and layering: `DeepSeekHarness` is the high-level turns API, `HarnessClient` the lower-level protocol client. The package root enumerates the consumer interface: the two client layers, caller-facing types, and `JsonRpcResponseError`; source modules, normalization helpers, and subscription-delivery machinery are not consumer imports. A pure library: it registers nothing on a Cordis context; the runtime process it spawns is a complete harness whose composition its own `cordis.yml` decides.
|
||||
|
||||
Unlike the Python SDK, the launch spec is fully explicit (`command`/`args`): this package is for repo-adjacent TypeScript consumers — the [`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) backend, tests, automation — which know which runtime they are launching. Bundled-runtime resolution (finding a packaged executable) remains the Python distribution's concern.
|
||||
|
||||
@@ -20,11 +20,11 @@ const result = await harness.run('say hi')
|
||||
console.log(result.status, result.finalResponse)
|
||||
```
|
||||
|
||||
The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; `close()` (or `await using`) is required so the child is always reaped. `start()` memoizes the `initialize` handshake (the workspace cwd — resolved absolute before it crosses the wire — plus the provider/model route); a failed handshake reaps the runtime and swaps in a fresh client, so a later call retries with a new subprocess (until `close()`, which is terminal). `session(id?)` opens a named or fresh session handle; `run(input, { sessionId?, onNotification? })` sends one prompt turn and settles when the paired `session.finished` arrives, returning a `TurnResult`: `status` (`ok`/`error` as the deployment maps it), the structured `reason` (`TurnEndReason`), `finalResponse` (last assistant message text), plus every `session.event` envelope and raw notification observed for that session tree, in wire order. Model-level failure is a `status: 'error'` result, never a rejection; rejections mean transport loss, timeout, or protocol violation.
|
||||
The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; `close()` (or `await using`) is required so the child is always reaped. `start()` memoizes the `initialize` handshake (the workspace cwd — resolved absolute before it crosses the wire — plus the provider/model route); a failed handshake reaps the runtime and swaps in a fresh client, so a later call retries with a new subprocess (until `close()`, which is terminal). `session(id?)` opens a named or fresh session handle; `run(input, { sessionId?, onNotification? })` sends one prompt turn and settles when the paired `session.finished` arrives, returning a `TurnResult`: `status` (`ok`/`error` as the deployment maps it), the structured `reason` (`TurnEndReason`), `finalResponse` (last assistant message text), root-session `events`, and raw `notifications` for that session plus descendants discovered from `subagent.started`, all in wire order. Model-level failure is a `status: 'error'` result, never a rejection; rejections mean transport loss, timeout, or protocol violation.
|
||||
|
||||
## HarnessClient
|
||||
|
||||
The protocol client under the turns API: explicit `start()`/`initialize()`/`prompt()`/`request()`/`close()`, plus notification subscriptions. `subscribe(filter?)` returns a `NotificationSubscription` (awaitable `next()`, non-blocking `tryNext()`, async iteration); `subscribeSessionTree(id)` scopes to one session and the descendants discovered from `subagent.started` lineage edges — the runtime notifies for every session in its context, and scoping is client-side, exactly like the Python SDK. Error surfaces are typed: `JsonRpcResponseError` (wire error response, code/data preserved), `RequestTimeoutError` (a configured bound elapsed; there is no wire-level cancel, so the request keeps running server-side until close), `SdkProtocolError` (a response outside the documented protocol), `TransportClosedError` (the runtime is gone — message carries the exit code and a bounded stderr tail).
|
||||
The protocol client under the turns API: explicit `start()`/`initialize()`/`prompt()`/`request()`/`close()`, plus notification subscriptions. `subscribe(filter?)` returns a `NotificationSubscription` (awaitable `next()`, non-blocking `tryNext()`, async iteration); `subscribeSessionTree(id)` scopes to one session and the descendants discovered from `subagent.started` lineage edges — the runtime notifies for every session in its context, and scoping is client-side, exactly like the Python SDK. Error surfaces are typed and exported from this package: `JsonRpcResponseError` (wire error response, code/data preserved), `RequestTimeoutError` (a configured bound elapsed; there is no wire-level cancel, so the request keeps running server-side until close), `SdkProtocolError` (a response outside the documented protocol), `TransportClosedError` (the runtime is gone — message carries the exit code and a bounded stderr tail).
|
||||
|
||||
`close()` requests protocol `shutdown` (bounded by `shutdownTimeoutMs`, default 1000 ms), then walks a stdin-EOF → SIGTERM → SIGKILL ladder (`disposeEofGraceMs` default 6000, `disposeGraceMs` default 3000) until the process has actually exited. The ladder is private to this client: it runs outside any harness context, so it cannot ride the [`dsh-subprocess`](../../subprocess/README.md) service — the seam's documented exception for SDK-managed transports. It is idempotent, and a closed client refuses reuse.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.md)(`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层回合 API,`HarnessClient` 是低层协议客户端。纯库:不在任何 Cordis 上下文注册;它所生成的运行时进程是一个完整 harness,其组成由自己的 `cordis.yml` 决定。
|
||||
以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.md)(`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层回合 API,`HarnessClient` 是低层协议客户端。包(package)根枚举消费方接口:两层客户端、面向调用方的类型和 `JsonRpcResponseError`;源模块、规范化辅助函数与订阅投递机制不供消费方导入。纯库:不在任何 Cordis 上下文注册;它所生成的运行时进程是一个完整 harness,其组成由自己的 `cordis.yml` 决定。
|
||||
|
||||
与 Python SDK 不同,启动规格完全显式(`command`/`args`):本包面向仓库近旁的 TypeScript 消费者——[`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) 后端、测试、自动化——它们知道自己要启动哪个运行时。捆绑运行时解析(寻找打包可执行文件)仍归 Python 发行版负责。
|
||||
|
||||
@@ -20,11 +20,11 @@ const result = await harness.run('say hi')
|
||||
console.log(result.status, result.finalResponse)
|
||||
```
|
||||
|
||||
子进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须 `close()`(或 `await using`),子进程才总能被收割。`start()` 记忆化 `initialize` 握手(工作区 cwd——在跨越线之前解析为绝对路径——加 provider/model 路由);握手失败会收割运行时并换入全新客户端,后续调用用新子进程重试(直到终结性的 `close()`)。`session(id?)` 打开具名或全新的会话句柄;`run(input, { sessionId?, onNotification? })` 发送一个 prompt 回合,在配对的 `session.finished` 到达时尘埃落定,返回 `TurnResult`:`status`(按部署映射的 `ok`/`error`)、结构化 `reason`(`TurnEndReason`)、`finalResponse`(最后一条助手消息文本),以及该会话树内按线序观察到的全部 `session.event` 封套与原始通知。模型层失败是 `status: 'error'` 的结果,绝不是拒绝;拒绝意味着传输丢失、超时或协议违例。
|
||||
子进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须 `close()`(或 `await using`),子进程才总能被收割。`start()` 记忆化 `initialize` 握手(工作区 cwd——在跨越线之前解析为绝对路径——加 provider/model 路由);握手失败会收割运行时并换入全新客户端,后续调用用新子进程重试(直到终结性的 `close()`)。`session(id?)` 打开具名或全新的会话句柄;`run(input, { sessionId?, onNotification? })` 发送一个 prompt 回合,在配对的 `session.finished` 到达时尘埃落定,返回 `TurnResult`:`status`(按部署映射的 `ok`/`error`)、结构化 `reason`(`TurnEndReason`)、`finalResponse`(最后一条助手消息文本)、根会话的 `events`,以及该会话和通过 `subagent.started` 发现的后代的原始 `notifications`,均按线序排列。模型层失败是 `status: 'error'` 的结果,绝不是拒绝;拒绝意味着传输丢失、超时或协议违例。
|
||||
|
||||
## HarnessClient
|
||||
|
||||
回合 API 之下的协议客户端:显式 `start()`/`initialize()`/`prompt()`/`request()`/`close()`,外加通知订阅。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。错误表面有类型:`JsonRpcResponseError`(线上错误响应,保留 code/data)、`RequestTimeoutError`(配置的时限已到;线上没有取消方法,请求在服务端继续运行直到 close)、`SdkProtocolError`(响应超出文档化协议)、`TransportClosedError`(运行时已消失——消息携带退出码与有界 stderr 尾部)。
|
||||
回合 API 之下的协议客户端:显式 `start()`/`initialize()`/`prompt()`/`request()`/`close()`,外加通知订阅。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。错误表面有类型且由本包导出:`JsonRpcResponseError`(线上错误响应,保留 code/data)、`RequestTimeoutError`(配置的时限已到;线上没有取消方法,请求在服务端继续运行直到 close)、`SdkProtocolError`(响应超出文档化协议)、`TransportClosedError`(运行时已消失——消息携带退出码与有界 stderr 尾部)。
|
||||
|
||||
`close()` 先请求协议 `shutdown`(受 `shutdownTimeoutMs` 约束,默认 1000 毫秒),然后走 stdin-EOF → SIGTERM → SIGKILL 阶梯(`disposeEofGraceMs` 默认 6000,`disposeGraceMs` 默认 3000)直到进程真正退出。该阶梯为本客户端私有:它运行在任何 harness 上下文之外,无法搭乘 [`dsh-subprocess`](../../subprocess/README.md) 服务——即该接缝记载的 SDK 托管传输例外。幂等,已关闭的客户端拒绝复用。
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -71,11 +71,28 @@ interface SubscriptionState {
|
||||
failure: Error | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* One client-side notification stream. Delivery order matches the wire;
|
||||
* {@link close} detaches it from the client, after which {@link next} rejects.
|
||||
*/
|
||||
export class NotificationSubscription implements AsyncIterable<HarnessNotification> {
|
||||
/** One client-side notification stream returned by {@link HarnessClient.subscribe}. */
|
||||
export interface NotificationSubscription extends AsyncIterable<HarnessNotification> {
|
||||
/**
|
||||
* Await the next matching notification.
|
||||
* @returns the notification; after the runtime died, drains what was
|
||||
* already delivered and then rejects; after {@link close}, rejects
|
||||
* immediately (the queue is dropped).
|
||||
*/
|
||||
next(): Promise<HarnessNotification>
|
||||
|
||||
/**
|
||||
* Drain one already-delivered notification without waiting.
|
||||
* @returns the next queued notification, or `undefined` when none is queued.
|
||||
*/
|
||||
tryNext(): HarnessNotification | undefined
|
||||
|
||||
/** Detach from the client; queued items drop and pending waiters reject. */
|
||||
close(): void
|
||||
}
|
||||
|
||||
/** Internal producer side of a public notification subscription. */
|
||||
class NotificationSubscriptionImpl implements NotificationSubscription {
|
||||
constructor(
|
||||
private readonly state: SubscriptionState,
|
||||
private readonly unsubscribe: () => void,
|
||||
@@ -168,7 +185,7 @@ export class HarnessClient {
|
||||
private child: ChildProcess | undefined
|
||||
private transport: JsonRpcLineTransport | undefined
|
||||
private readonly stderrTail: string[] = []
|
||||
private readonly subscriptions = new Map<string, NotificationSubscription>()
|
||||
private readonly subscriptions = new Map<string, NotificationSubscriptionImpl>()
|
||||
private readonly sessionParents = new Map<string, string>()
|
||||
private subscriptionSerial = 0
|
||||
private exitCode: number | null | undefined
|
||||
@@ -324,7 +341,7 @@ export class HarnessClient {
|
||||
subscribe(filter?: NotificationFilter): NotificationSubscription {
|
||||
const id = String(this.subscriptionSerial++)
|
||||
const state: SubscriptionState = { queue: [], waiters: [], filter, failure: undefined }
|
||||
const subscription = new NotificationSubscription(state, () => { this.subscriptions.delete(id) })
|
||||
const subscription = new NotificationSubscriptionImpl(state, () => { this.subscriptions.delete(id) })
|
||||
if (this.closeTask !== undefined || this.exitCode !== undefined || this.spawnError !== undefined) {
|
||||
subscription.fail(this.closedError('DeepSeek Harness runtime closed'))
|
||||
return subscription
|
||||
|
||||
@@ -9,6 +9,21 @@
|
||||
* @module @deepseek-ai/dsh-sdk-client
|
||||
*/
|
||||
|
||||
export * from './api.ts'
|
||||
export * from './client.ts'
|
||||
export type * from './types.ts'
|
||||
export { DeepSeekHarness, HarnessSession } from './api.ts'
|
||||
export type { RunOptions } from './api.ts'
|
||||
export {
|
||||
HarnessClient,
|
||||
RequestTimeoutError,
|
||||
SdkProtocolError,
|
||||
TransportClosedError,
|
||||
} from './client.ts'
|
||||
export type { NotificationSubscription } from './client.ts'
|
||||
export { JsonRpcResponseError } from '@deepseek-ai/dsh-sdk-protocol'
|
||||
export type {
|
||||
ContentBlock,
|
||||
DeepSeekHarnessOptions,
|
||||
HarnessClientOptions,
|
||||
HarnessNotification,
|
||||
NotificationFilter,
|
||||
TurnResult,
|
||||
} from './types.ts'
|
||||
|
||||
@@ -67,9 +67,9 @@ export interface TurnResult {
|
||||
reason: TurnEndReason | undefined
|
||||
/** Concatenated text of the session's last assistant message (empty when none). */
|
||||
finalResponse: string
|
||||
/** Every `session.event` payload for this session tree, in wire order. */
|
||||
/** Every `session.event` payload for the root session, in wire order. */
|
||||
events: SessionEvent[]
|
||||
/** Every notification observed during the turn, in wire order. */
|
||||
/** Every notification for the root session and discovered descendants, in wire order. */
|
||||
notifications: HarnessNotification[]
|
||||
}
|
||||
|
||||
|
||||
@@ -12,15 +12,14 @@ import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DeepSeekHarness,
|
||||
finalResponse,
|
||||
HarnessClient,
|
||||
normalizeInput,
|
||||
JsonRpcResponseError,
|
||||
RequestTimeoutError,
|
||||
SdkProtocolError,
|
||||
TransportClosedError,
|
||||
type HarnessNotification,
|
||||
} from '../src/index.ts'
|
||||
import { JsonRpcResponseError } from '@deepseek-ai/dsh-sdk-protocol'
|
||||
import { finalResponse, normalizeInput } from '../src/api.ts'
|
||||
|
||||
const fakeRuntime = fileURLToPath(new URL('./fake-runtime.ts', import.meta.url))
|
||||
|
||||
@@ -69,7 +68,7 @@ describe('DeepSeekHarness', () => {
|
||||
await harness.close()
|
||||
})
|
||||
|
||||
it('streams notifications to the observer and scopes them to the session tree', async () => {
|
||||
it('keeps events root-scoped while streaming notifications for the session tree', async () => {
|
||||
const harness = harnessWith({ FAKE_SUBAGENT: '1' })
|
||||
const seen: HarnessNotification[] = []
|
||||
const result = await harness.run('delegate', {
|
||||
@@ -83,7 +82,8 @@ describe('DeepSeekHarness', () => {
|
||||
expect(seen.map(n => n.method)).toContain('subagent.finished')
|
||||
const childEvents = seen.filter(n => n.method === 'session.event' && n.params.sessionId === 'parent-1-child')
|
||||
expect(childEvents.length).toBeGreaterThan(0)
|
||||
// Child events do not count as the parent's own turn events.
|
||||
// TurnResult.events is the root session's typed stream; descendants retain
|
||||
// their session ids in the raw notification stream above.
|
||||
expect(result.events.every(event => event.type !== 'assistant/message'
|
||||
|| (event.data as { content: { type: string; text?: string }[] }).content[0]?.text !== 'child says hi')).toBe(true)
|
||||
await harness.close()
|
||||
|
||||
@@ -1,6 +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: 61ffc0e17700d79da14001b389c7c6dcb50ee28d
|
||||
README.zh.md: 9de816dc588354d04194d5eb99f444409456046e
|
||||
# pnpm run verify-translation-pairing --write packages/sdk/sdk-protocol/README.md
|
||||
README.md: 79e6bc36a656ce0d68c8e01ab2f75e26b4ac8ca5
|
||||
README.zh.md: 8322da0f2bf7251f2b958c6a15f1738b9d8c41a4
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The shared wire protocol for the DeepSeek Harness SDK runtime: one newline-delimited JSON-RPC 2.0 transport class plus the named request, result, and notification types both wire ends speak. The server side is the [`dsh-jsonrpc`](../../ui/jsonrpc/README.md) plugin; clients are [`dsh-sdk-client`](../sdk-client/README.md) (TypeScript) and the [Python SDK](../../../python/README.md) (which mirrors these shapes but does not import them). A pure library — no plugin, no Config, no registration.
|
||||
The shared wire protocol for the DeepSeek Harness SDK runtime: one newline-delimited JSON-RPC 2.0 transport class plus the named request, result, and notification types both wire ends speak. The package root enumerates the protocol consumer interface; source modules are not exported as deep imports. The server side is the [`dsh-jsonrpc`](../../ui/jsonrpc/README.md) plugin; clients are [`dsh-sdk-client`](../sdk-client/README.md) (TypeScript) and the [Python SDK](../../../python/README.md) (which mirrors these shapes but does not import them). A pure library — no plugin, no Config, no registration.
|
||||
|
||||
## Transport
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
DeepSeek Harness SDK 运行时的共享线协议:一个按换行分帧的 JSON-RPC 2.0 传输类,加上线两端共同使用的具名请求、结果与通知类型。服务端是 [`dsh-jsonrpc`](../../ui/jsonrpc/README.md) 插件;客户端是 [`dsh-sdk-client`](../sdk-client/README.md)(TypeScript)与 [Python SDK](../../../python/README.md)(后者镜像这些形状但不导入它们)。纯库——无插件、无 Config、无注册。
|
||||
DeepSeek Harness SDK 运行时的共享线协议:一个按换行分帧的 JSON-RPC 2.0 传输类,加上线两端共同使用的具名请求、结果与通知类型。包(package)根枚举协议消费方接口;源模块不以深层导入形式导出。服务端是 [`dsh-jsonrpc`](../../ui/jsonrpc/README.md) 插件;客户端是 [`dsh-sdk-client`](../sdk-client/README.md)(TypeScript)与 [Python SDK](../../../python/README.md)(后者镜像这些形状但不导入它们)。纯库——无插件、无 Config、无注册。
|
||||
|
||||
## 传输
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -8,5 +8,18 @@
|
||||
* @module @deepseek-ai/dsh-sdk-protocol
|
||||
*/
|
||||
|
||||
export * from './transport.ts'
|
||||
export * from './types.ts'
|
||||
export { JsonRpcLineTransport, JsonRpcResponseError } from './transport.ts'
|
||||
export type { JsonRpcTransportPeer } from './transport.ts'
|
||||
export type {
|
||||
HarnessSdkNotificationMap,
|
||||
HarnessSdkRequestMap,
|
||||
InitializeParams,
|
||||
InitializeResult,
|
||||
SdkRunStatus,
|
||||
SessionEventNotification,
|
||||
SessionFinishedNotification,
|
||||
SessionPromptParams,
|
||||
SessionPromptResult,
|
||||
SubagentFinishedNotification,
|
||||
SubagentStartedNotification,
|
||||
} from './types.ts'
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
terminal 96x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=14 viewportRow=8 bufferRow=8
|
||||
viewport
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
8| " dsh > @design "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 14-14 inverse
|
||||
9| " → Session · Searchable design re opaque-source-id · /workspace/project · 1970-01-01T0 "
|
||||
style 7-38 fg=bright-blue
|
||||
10-35| <blank>
|
||||
@@ -8,6 +8,7 @@ import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import { SessionId, type JsonValue, type Session } from '@deepseek-ai/dsh-session'
|
||||
import SessionReferenceService from '@deepseek-ai/dsh-session-reference'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { type ToolDefinition, type ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
type TuiHarnessOptions,
|
||||
} from './harness.ts'
|
||||
import { HeadlessTerminal, type TerminalSnapshotOptions } from './headless-terminal.ts'
|
||||
import { TestSessionQueryService } from './session-query.ts'
|
||||
|
||||
const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
|
||||
const REFRESHING = process.env.DSH_SNAPSHOT === 'refresh'
|
||||
@@ -35,6 +37,7 @@ const CHECKPOINTS = [
|
||||
'retry-exhausted',
|
||||
'banner-gradient',
|
||||
'file-autocomplete',
|
||||
'session-title-autocomplete',
|
||||
'code-mode-pending',
|
||||
'dynamic-workflow-pending',
|
||||
'cordis-tools-pending',
|
||||
@@ -399,6 +402,30 @@ describe('TUI terminal-state snapshots', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('pins session autocomplete discovered through a log-backed title', async () => {
|
||||
const harness = await setupSnapshot({
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
const source = ctx.sessions.create(SessionId('opaque-source-id'), {
|
||||
meta: { cwd: '/workspace/project', createdAt: 1 },
|
||||
})
|
||||
source.append('session/title', {
|
||||
title: 'Searchable design review',
|
||||
messageSeqs: [],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
},
|
||||
})
|
||||
harness.terminal.send('@design')
|
||||
await vi.waitFor(async () => {
|
||||
expect(await harness.terminal.snapshot()).toContain('Session · Searchable design re')
|
||||
})
|
||||
await checkpoint('session-title-autocomplete', harness.terminal)
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('pins Code Mode run_code with its production presenter', async () => {
|
||||
const harness = await setupSnapshot({ configureContext: configureAdvancedTools })
|
||||
const call = {
|
||||
|
||||
@@ -2271,21 +2271,50 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
})
|
||||
|
||||
it('combines session autocomplete with files and prepares send/steer references asynchronously', async () => {
|
||||
let sourceId = SessionId('uninitialized')
|
||||
const sourceId = SessionId('source-session')
|
||||
const sourceHeader: SessionHeader = {
|
||||
version: 0,
|
||||
id: sourceId,
|
||||
cwd: '/workspace',
|
||||
createdAt: 1,
|
||||
}
|
||||
const noCwdHeader: SessionHeader = {
|
||||
version: 0,
|
||||
id: SessionId('no-cwd'),
|
||||
createdAt: 2,
|
||||
}
|
||||
const sourceEvents: SessionEvent[] = [
|
||||
{
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { content: [{ type: 'text', text: 'source background' }], source: { kind: 'user' } },
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'session/title',
|
||||
seq: 1,
|
||||
time: 2,
|
||||
data: {
|
||||
title: 'Source chat',
|
||||
messageSeqs: [0],
|
||||
source: { kind: 'fallback' },
|
||||
},
|
||||
},
|
||||
]
|
||||
const result = await setup({
|
||||
sessionPersistence: {
|
||||
list: async () => [noCwdHeader, sourceHeader],
|
||||
load: async (id) => {
|
||||
if (id === sourceId) return { meta: sourceHeader, events: sourceEvents }
|
||||
if (id === noCwdHeader.id) return { meta: noCwdHeader, events: [] }
|
||||
throw new Error(`unexpected persisted session ${id}`)
|
||||
},
|
||||
},
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
const source = ctx.sessions.create(SessionId('source-session'), { meta: { cwd: process.cwd(), createdAt: 1 } })
|
||||
sourceId = source.id
|
||||
appendUser(source, 'source background')
|
||||
source.append('session/title', {
|
||||
title: 'Source chat',
|
||||
messageSeqs: [0],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
ctx.sessions.create(SessionId('no-cwd'), { meta: { createdAt: 2 } })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2294,7 +2323,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.terminal.output).toContain('(no cwd)')
|
||||
result.terminal.send('\x03')
|
||||
|
||||
result.terminal.send('@source-session')
|
||||
result.terminal.send('@chat')
|
||||
await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · Source chat') })
|
||||
expect(result.terminal.output).toContain('source-session')
|
||||
result.terminal.send('\t')
|
||||
|
||||
Reference in New Issue
Block a user