From 7df3c41ec97301d05db30ec3c5b3650dfaa3d41a Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Mon, 12 Oct 2026 20:36:18 +0800 Subject: [PATCH 01/17] feat(ui-workspace): fold sidebar sessions with a monotonic watermark Each Workspace shows a contiguous prefix of its Sessions up to a watermark. The watermark rises to cover running, waiting, unread-completion, and current Sessions within the first 50 rows, and to a search reveal target at any row. It never falls on its own; Show more raises it past 50, and Show less or folding (including an ancestor) resets it. --- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../src/client/rows/WorkspaceBrowser.tsx | 125 ++++++++--- .../tests/workspace-browser.client.spec.tsx | 201 +++++++++++++----- 5 files changed, 239 insertions(+), 95 deletions(-) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index ed87dba3eb..687f287046 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-workspace/use-this-package: - en: b164014ee565be16 - zh: 6a741647932b93e6 + en: d51507f87484f818 + zh: e2600daa21e35f36 /deepseek-ai-dsh-client-ui-workspace/use-this-package/reordering-and-view-options: en: 8673afc1d72ad077 zh: 9fbb83a4029c5d10 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 116cebbbd7..d97b93b13a 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -27,7 +27,7 @@ This package lets users browse grouped or flat Session lists, choose a Workspace ## Use this package -Use the sidebar to browse Workspaces and their Sessions, reorder them, and start new ones; use the picker in the Session Intent hero to choose a Workspace for a new session. An open Workspace shows five idle, non-blank Sessions by default. Running Sessions, including parents with running children, remain visible in their ordered positions without using that quota; the selected blank **New Session** is also an extra row until its first prompt. Each **Show more** click reveals up to five more idle Sessions; after the final batch, **Show less** restores the initial rows while keeping running Sessions visible. Closing and reopening the Workspace also restores this folded projection. +Use the sidebar to browse Workspaces and their Sessions, reorder them, and start new ones; use the picker in the Session Intent hero to choose a Workspace for a new session. An open Workspace shows its first non-blank Sessions in list order up to a per-Workspace watermark, initially five. The watermark rises to cover the last Session within the first 50 that is running, has a running child, awaits the user, has an unread completion, or is open in the main view; a Session opened from search raises it to that Session at any position. The watermark never falls on its own, so a Session stays visible after its run or notification ends. Each **Show more** click raises it by five, without the 50-row limit, and reveals the rest when five or fewer remain; after the final batch, **Show less** resets it. Folding a Workspace resets its watermark and those of its nested Workspaces; a reset watermark still covers the qualifying Sessions. The selected blank **New Session** is an extra row outside the watermark until its first prompt. Watermarks are not persisted; they reset when the grouped tree unmounts, including reloads, flat grouping, and search results. History rows without a stored title use the localized unnamed label (未命名 / Untitled), rather than a directory name. The current blank row remains New Session; other blank rows remain hidden. Rename drafts use the stored title, or start empty when unnamed; unnamed rows do not offer title copying. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 7be85089f9..d8d8717933 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" ## 使用本包 -用侧边栏浏览 Workspace 及其 Session、重排它们并新建会话;在 Session Intent 主视觉区用选择器为新会话选择 Workspace。打开的 Workspace 默认显示五条空闲的非空白 Session。正在运行的 Session(包括有子会话正在运行的父会话)始终按原顺序显示,不占用这五条配额;当前选中的空白**新会话**在首条提示词落地前也作为额外行。每次点击**展开其余**最多再显示五条空闲 Session;全部显示后,**收起**恢复初始行数,但仍显示正在运行的 Session。关闭再打开 Workspace 也会恢复该折叠投影。 +用侧边栏浏览 Workspace 及其 Session、重排它们并新建会话;在 Session Intent 主视觉区用选择器为新会话选择 Workspace。打开的 Workspace 按列表顺序显示前面的非空白 Session,直到该 Workspace 自己的水位线,水位线初始为五。水位线会升高,以覆盖前 50 条中最后一条正在运行、有子会话正在运行、等待用户、有未读完成提醒或在主视图中打开的 Session;从搜索打开的 Session 会把水位线升高到该 Session,无论其位置。水位线不会自行回落,因此 Session 在运行或通知结束后仍保持可见。每次点击**展开其余**将水位线升高五条,不受 50 行限制;剩余五条或更少时显示全部;最后一批显示后,**收起**重置水位线。折叠 Workspace 会重置其水位线及其嵌套 Workspace 的水位线;重置后的水位线仍覆盖符合条件的 Session。当前选中的空白**新会话**在首条提示词落地前是水位线之外的额外行。水位线不持久化;分组树卸载时会重置,包括重新加载、切换到平铺分组和显示搜索结果时。 未保存标题的历史会话显示本地化名称「未命名 / Untitled」,不使用目录名兜底。当前空会话仍显示「新会话」,其他空会话仍隐藏。重命名草稿使用已保存标题,无标题时留空;无标题行不提供标题复制。 diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 72d1fb79db..3434d8af51 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -51,24 +51,60 @@ const EXPAND_SLIDE_MS = 300 const SEARCH_DEBOUNCE_MS = 250 /** `session.search` wire bound, measured in JavaScript UTF-16 code units. */ const SEARCH_QUERY_MAX_CODE_UNITS = 500 -/** Idle Session rows visible per Workspace before the local overflow control. */ -const COLLAPSED_SESSION_LIMIT = 5 +/** Non-blank Session rows visible per Workspace while no Session raises its watermark. */ +const SESSION_WATERMARK_BASE = 5 +/** Highest watermark that attention-bearing Sessions raise automatically; Show more and search reveals may exceed it. */ +const SESSION_WATERMARK_AUTO_MAX = 50 -/** Keep provisional and running rows outside the idle-session quota, including parents with running children. */ -function collapsedSessionRows(sessions: readonly SessionNode[], limit = COLLAPSED_SESSION_LIMIT): { +/** Show the blank row plus the first `watermark` non-blank rows, in list order. */ +function watermarkRows(sessions: readonly SessionNode[], watermark: number): { rows: readonly SessionNode[] hiddenCount: number } { - let idleCount = 0 + let count = 0 const rows = sessions.filter((session) => { - if (session.blank || session.running || session.runningSubagentCount > 0) return true - if (idleCount >= limit) return false - idleCount += 1 - return true + if (session.blank) return true + count += 1 + return count <= watermark }) return { rows, hiddenCount: sessions.length - rows.length } } +/** + * Smallest watermark that covers every non-blank row within the automatic maximum that is running, has a running + * child, awaits the user, carries an unread completion, or is current, and the reveal target at any position. + */ +function requiredWatermark( + sessions: readonly SessionNode[], + current: SessionId | undefined, + revealSessionId: SessionId | undefined, +): number { + let index = 0 + let required = 0 + for (const session of sessions) { + if (session.blank) continue + index += 1 + const attention = index <= SESSION_WATERMARK_AUTO_MAX && ( + session.running || session.runningSubagentCount > 0 || session.pendingInteraction !== undefined + || session.completed || session.id === current + ) + if (attention || session.id === revealSessionId) required = index + } + return required +} + +/** Raise each listed group's watermark to its requirement; returns `marks` itself when nothing rises. */ +function raiseWatermarks( + marks: Readonly>, + required: ReadonlyMap, +): Readonly> { + let raised = marks + for (const [key, watermark] of required) { + if (watermark > (raised[key] ?? SESSION_WATERMARK_BASE)) raised = { ...raised, [key]: watermark } + } + return raised +} + /** Keep controlled input and RPC payload inside the session.search wire contract. */ function sanitizeSearchQuery(value: string): string { const withoutNul = value.replaceAll('\0', '') @@ -277,7 +313,7 @@ function EmptySessions({ rowState, onLeaveArchivedOnly, t }: Pick>>({}) + // Per-group raised watermarks; an absent key means the base watermark. + const [storedWatermarks, setStoredWatermarks] = useState>>({}) + // Advances on user-driven watermark changes so AnimatedRows settles them; automatic raises animate. + const [watermarkMotionKey, setWatermarkMotionKey] = useState(0) // Transient drag marker state; the selected mode owns the resulting order. const [drag, setDrag] = useState(null) const sessionDropCommitted = useRef(false) @@ -347,13 +386,35 @@ function SessionTree({ } } }, [groupExpansion, parents, revealGroup, setGroupExpanded]) - useEffect(() => { - if (revealSessionId === undefined || revealGroup === undefined) return - const group = groups.find(candidate => candidate.key === revealGroup) - if (group === undefined || !group.expanded || !group.sessions.some(row => row.id === revealSessionId)) return - if (collapsedSessionRows(group.sessions).rows.some(row => row.id === revealSessionId)) return - setSessionLimits(limits => limits[revealGroup] === Infinity ? limits : { ...limits, [revealGroup]: Infinity }) - }, [groups, revealGroup, revealSessionId]) + const baselineWatermark = (group: GroupNode): number => Math.max(SESSION_WATERMARK_BASE, requiredWatermark( + group.sessions, + current, + group.key === revealGroup ? revealSessionId : undefined, + )) + const foldedKeys = new Set(groups.filter(group => !group.expanded).map(group => group.key)) + const underFoldedAncestor = (key: string): boolean => { + for (let owner = parents.get(key); owner !== undefined; owner = parents.get(owner)) { + if (foldedKeys.has(owner)) return true + } + return false + } + // A group hidden by a folded ancestor keeps its own expansion and rows but must not raise its watermark. + const requiredWatermarks = new Map(groups + .filter(group => !underFoldedAncestor(group.key)) + .map(group => [group.key, baselineWatermark(group)])) + const sessionWatermarks = raiseWatermarks(storedWatermarks, requiredWatermarks) + // Raising during render keeps the watermark monotonic without committing a lower frame first. + if (sessionWatermarks !== storedWatermarks) setStoredWatermarks(marks => raiseWatermarks(marks, requiredWatermarks)) + const dropWatermarks = (drop: (key: string) => boolean): void => { + setStoredWatermarks(marks => Object.fromEntries(Object.entries(marks).filter(([key]) => !drop(key)))) + setWatermarkMotionKey(key => key + 1) + } + const withinGroup = (key: string, root: string): boolean => { + for (let owner: string | undefined = key; owner !== undefined; owner = parents.get(owner)) { + if (owner === root) return true + } + return false + } const now = Date.now() const commitSessionDrag = (activeDrag: DragState, over: NonNullable): void => { if (sessionDropCommitted.current) return @@ -366,7 +427,7 @@ function SessionTree({ ? ungroupedSessionIds : workspaces.find(workspace => workspace.workspaceId === activeDrag.accountKey)?.sessionIds if (accountSessionIds === undefined) return - const renderedSessions = collapsedSessionRows(group.sessions, sessionLimits[group.key]).rows + const renderedSessions = watermarkRows(group.sessions, sessionWatermarks[group.key] ?? SESSION_WATERMARK_BASE).rows const nextOrder = sessionDragOrder(accountSessionIds, renderedSessions, activeDrag, over) if (nextOrder !== undefined) setSessionOrder(activeDrag.accountKey, nextOrder) } @@ -416,8 +477,9 @@ function SessionTree({ const workspaceId = group.workspaceId const children = childrenByParent.get(group.key) ?? [] const compatibleDrag = workspaceDrag !== null && parents.get(workspaceDrag.workspaceId) === parents.get(group.key) - const collapsed = collapsedSessionRows(group.sessions) - const visible = collapsedSessionRows(group.sessions, sessionLimits[group.key]) + const watermark = sessionWatermarks[group.key] ?? SESSION_WATERMARK_BASE + const collapsed = watermarkRows(group.sessions, baselineWatermark(group)) + const visible = watermarkRows(group.sessions, watermark) const sessionsExpanded = visible.hiddenCount === 0 rowKeys.push(`workspace:${group.key}`) const childRows = group.expanded ? children.map(child => renderGroup(child, depth + 1)) : [] @@ -500,9 +562,7 @@ function SessionTree({ home={home} t={t} onToggle={() => { - if (group.expanded) { - setSessionLimits(limits => ({ ...limits, [group.key]: COLLAPSED_SESSION_LIMIT })) - } + if (group.expanded) dropWatermarks(key => withinGroup(key, group.key)) setGroupExpanded(group.key, !group.expanded) }} onCreate={() => { @@ -585,14 +645,15 @@ function SessionTree({ data-row-key={`overflow:${group.key}`} aria-expanded={sessionsExpanded} onClick={() => { - setSessionLimits(limits => ({ - ...limits, - [group.key]: sessionsExpanded - ? COLLAPSED_SESSION_LIMIT - : visible.hiddenCount <= COLLAPSED_SESSION_LIMIT - ? Infinity - : (limits[group.key] ?? COLLAPSED_SESSION_LIMIT) + COLLAPSED_SESSION_LIMIT, + if (sessionsExpanded) { + dropWatermarks(key => key === group.key) + return + } + setStoredWatermarks(marks => ({ + ...marks, + [group.key]: visible.hiddenCount <= SESSION_WATERMARK_BASE ? Infinity : watermark + SESSION_WATERMARK_BASE, })) + setWatermarkMotionKey(key => key + 1) }} > {sessionsExpanded @@ -613,7 +674,7 @@ function SessionTree({ label={t('section.sessions')} rowKeys={rowKeys} ready={list.phase === 'ready' && workspaceReady && !nativeDragActive} - resetKey={JSON.stringify([animationResetKey, sessionLimits])} + resetKey={JSON.stringify([animationResetKey, watermarkMotionKey])} > {groups.length === 0 && ( diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 40b9a97a6d..362cd1604d 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -7,7 +7,9 @@ import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-sess import type { WorkspaceId, WorkspaceSnapshot, WorkspaceView, } from '@deepseek-ai/dsh-api-workspace-controller/client' -import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' +import type { + SessionPendingInteraction, SessionStatus, SessionStatusSnapshot, +} from '@deepseek-ai/dsh-client-ui-session/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' @@ -770,63 +772,80 @@ describe('WorkspaceBrowser', () => { expect(screen.getByRole('button', { name: '展开其余 2 个会话' })).toBeTruthy() }) - it('keeps running sessions outside every batch quota and preserves their order after collapse', () => { - const idle = Array.from({ length: 12 }, (_, index) => summary(`idle-${index + 1}`, 12 - index)) - const first = summary('running-first', 14, { running: true }) - const last = summary('running-last', 0, { running: true }) - const blank = summary('blank', 15, { blank: true }) - const items = [blank, first, ...idle, last] - mount({ - useSessions: hook(sessionState(items, { main: blank.id })), - useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])), - }) - const expectRows = (count: number) => { - expect(screen.getAllByRole('treeitem').map(row => - within(row).getByText(/^(alpha|新会话|running-(first|last)|idle-\d+)$/u).textContent)).toEqual([ - 'alpha', '新会话', 'running-first', ...idle.slice(0, count).map(item => item.displayTitle), 'running-last', - ]) - } - expectRows(5) - fireEvent.click(screen.getByRole('button', { name: '展开其余 7 个会话' })) - expectRows(10) - fireEvent.click(screen.getByRole('button', { name: '展开其余 2 个会话' })) - expectRows(12) - fireEvent.click(screen.getByRole('button', { name: '收起' })) - expectRows(5) - fireEvent.click(screen.getByText('alpha')) - fireEvent.click(screen.getByText('alpha')) - expectRows(5) - }) - - it('reveals a hidden session when live running status starts and folds it when the run ends', () => { + it('raises the watermark to the last attention row and keeps it after the attention ends', () => { const items = Array.from({ length: 12 }, (_, index) => summary(`session-${index + 1}`, 12 - index)) const b = mount({ useSessions: hook(sessionState(items)), useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])), }) + const status = (id: string, value: Partial) => + hook(new Map([[sid(id), { + running: false, pendingInteraction: undefined, completionUnread: false, ...value, + }]])) + const expectVisible = (count: number) => { + expect(screen.getAllByRole('treeitem').map(row => within(row).getByText(/^(alpha|session-\d+)$/u).textContent)) + .toEqual(['alpha', ...items.slice(0, count).map(item => item.displayTitle)]) + } fireEvent.click(screen.getByText('alpha')) - expect(screen.queryByText('session-12')).toBeNull() - rerender(b, { - useSessionStatus: hook(new Map([[sid('session-12'), { - running: true, pendingInteraction: undefined, completionUnread: false, - }]])), - }) - expect(screen.getByText('session-12')).toBeTruthy() - expect(screen.queryByText('session-6')).toBeNull() - expect(screen.getByRole('button', { name: '展开其余 6 个会话' })).toBeTruthy() - rerender(b, { - useSessionStatus: hook(new Map([[sid('session-12'), { - running: false, pendingInteraction: undefined, completionUnread: true, - }]])), - }) - expect(screen.queryByText('session-12')).toBeNull() - expect(screen.getByRole('button', { name: '展开其余 7 个会话' })).toBeTruthy() + expectVisible(5) + rerender(b, { useSessionStatus: status('session-9', { running: true }) }) + expectVisible(9) + expect(screen.getByRole('button', { name: '展开其余 3 个会话' })).toBeTruthy() + rerender(b, { useSessionStatus: status('session-9', {}) }) + expectVisible(9) + + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByText('alpha')) + expectVisible(5) + rerender(b, { useSessionStatus: status('session-7', { + pendingInteraction: { key: 'question:1', kind: 'plan-review', sessionId: sid('session-7') } as SessionPendingInteraction, + }) }) + expectVisible(7) + rerender(b, { useSessionStatus: status('session-8', { completionUnread: true }) }) + expectVisible(8) + fireEvent.click(screen.getByRole('button', { name: '展开其余 4 个会话' })) + expectVisible(12) + fireEvent.click(screen.getByRole('button', { name: '收起' })) + expectVisible(8) }) - it('keeps a parent with a running child visible outside the idle-session quota', () => { - const items = Array.from({ length: 7 }, (_, index) => summary(`session-${index + 1}`, 7 - index)) + it('stops automatic raises at 50 rows while Show more continues past them', () => { + const items = Array.from({ length: 60 }, (_, index) => summary(`session-${index + 1}`, 60 - index)) + const b = mount({ + useSessions: hook(sessionState(items)), + useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])), + }) + fireEvent.click(screen.getByText('alpha')) + const running = (...ids: string[]) => hook(new Map(ids.map(id => [sid(id), { + running: true, pendingInteraction: undefined, completionUnread: false, + }]))) + rerender(b, { + useSessions: hook(sessionState(items, { main: sid('session-53') })), + useSessionStatus: hook(new Map([ + [sid('session-55'), { running: true, pendingInteraction: undefined, completionUnread: false }], + [sid('session-56'), { running: false, pendingInteraction: undefined, completionUnread: true }], + [sid('session-57'), { + running: false, completionUnread: false, + pendingInteraction: { key: 'question:1', kind: 'question', sessionId: sid('session-57') } as SessionPendingInteraction, + }], + ])), + }) + expect(screen.queryByText('session-6')).toBeNull() + for (const id of ['session-53', 'session-55', 'session-56', 'session-57']) expect(screen.queryByText(id)).toBeNull() + rerender(b, { useSessionStatus: running('session-50', 'session-55') }) + expect(screen.getByText('session-50')).toBeTruthy() + expect(screen.queryByText('session-51')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '展开其余 10 个会话' })) + expect(screen.getByText('session-55')).toBeTruthy() + expect(screen.queryByText('session-56')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '展开其余 5 个会话' })) + expect(screen.getByText('session-60')).toBeTruthy() + }) + + it('covers the current session and a parent with a running child', () => { + const items = Array.from({ length: 9 }, (_, index) => summary(`session-${index + 1}`, 9 - index)) const child = summary('child', 0, { origin: 'subagent', running: true }) - mount({ + const b = mount({ useSessions: hook(sessionState([...items, child], { projectionsBySession: { [sid('session-7')]: { @@ -839,8 +858,11 @@ describe('WorkspaceBrowser', () => { }) fireEvent.click(screen.getByText('alpha')) expect(screen.getByText('session-7')).toBeTruthy() - expect(screen.queryByText('session-6')).toBeNull() + expect(screen.queryByText('session-8')).toBeNull() expect(screen.queryByText('child')).toBeNull() + rerender(b, { useSessions: hook(sessionState(items, { main: sid('session-8') })) }) + expect(screen.getByText('session-8')).toBeTruthy() + expect(screen.queryByText('session-9')).toBeNull() expect(screen.getByRole('button', { name: '展开其余 1 个会话' })).toBeTruthy() }) @@ -1548,7 +1570,7 @@ describe('WorkspaceBrowser', () => { expect(b.store.getSnapshot().groupExpansion).not.toHaveProperty('stale') }) - it('reveals every session when a search result is beyond the initial five-row quota', () => { + it('raises the watermark to a search result beyond the initial five rows', () => { const items = Array.from({ length: 17 }, (_, index) => summary(`session-${index + 1}`, 17 - index)) const b = mount({ useSessions: hook(sessionState(items)), @@ -1560,16 +1582,28 @@ describe('WorkspaceBrowser', () => { expect(b.props.open).toHaveBeenCalledWith(sid('session-11')) expect(input.value).toBe('') - for (const item of items) expect(screen.getByText(item.displayTitle)).toBeTruthy() - expect(screen.queryByRole('button', { name: /展开其余/ })).toBeNull() + expect(screen.getByText('session-11')).toBeTruthy() + expect(screen.queryByText('session-12')).toBeNull() expect(scrollIntoView.mock.instances.at(-1)).toBe(screen.getByText('session-11').closest('[role="treeitem"]')) - fireEvent.click(screen.getByRole('button', { name: '收起' })) + fireEvent.click(screen.getByRole('button', { name: '展开其余 6 个会话' })) + expect(screen.getByText('session-16')).toBeTruthy() + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByText('alpha')) expect(screen.queryByText('session-6')).toBeNull() - expect(screen.queryByText('session-11')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: '展开其余 12 个会话' })) - expect(screen.getByText('session-10')).toBeTruthy() - expect(screen.queryByText('session-11')).toBeNull() - expect(screen.getByRole('button', { name: '展开其余 7 个会话' })).toBeTruthy() + expect(screen.getByRole('button', { name: '展开其余 12 个会话' })).toBeTruthy() + }) + + it('reveals a search result beyond the automatic 50-row limit', () => { + const items = Array.from({ length: 60 }, (_, index) => summary(`session-${index + 1}`, 60 - index)) + mount({ + useSessions: hook(sessionState(items)), + useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])), + }) + fireEvent.change(screen.getByPlaceholderText('搜索会话名称'), { target: { value: 'session-58' } }) + fireEvent.click(screen.getByRole('treeitem')) + expect(screen.getByText('session-58')).toBeTruthy() + expect(screen.queryByText('session-59')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 2 个会话' })).toBeTruthy() }) it('keeps the bounded group projection when the revealed result is already within it', () => { @@ -2418,6 +2452,55 @@ describe('Workspace tree grouping', () => { expect(within(section('Projects')).getByText('Child')).toBeTruthy() }) + it('folding a parent Workspace resets descendant watermarks', () => { + const rootItems = Array.from({ length: 7 }, (_, index) => summary(`root-${index + 1}`, 30 - index)) + const childItems = Array.from({ length: 7 }, (_, index) => summary(`child-${index + 1}`, 20 - index)) + mount({ + useSessions: hook(sessionState([...rootItems, ...childItems])), + useWorkspaces: hook(workspaceState([ + { ...root, sessionIds: rootItems.map(item => item.id) }, + { ...child, sessionIds: childItems.map(item => item.id) }, + ])), + }) + fireEvent.click(screen.getByText('Child')) + fireEvent.click(within(section('Child')).getByRole('button', { name: '展开其余 2 个会话' })) + fireEvent.click(within(section('Projects')).getAllByRole('button', { name: '展开其余 2 个会话' })[0]!) + expect(screen.getByText('child-7')).toBeTruthy() + expect(screen.getByText('root-7')).toBeTruthy() + fireEvent.click(screen.getByText('Child')) + fireEvent.click(screen.getByText('Child')) + expect(screen.queryByText('child-6')).toBeNull() + expect(screen.getByText('root-7')).toBeTruthy() + fireEvent.click(within(section('Child')).getByRole('button', { name: '展开其余 2 个会话' })) + fireEvent.click(screen.getByText('Projects')) + fireEvent.click(screen.getByText('Projects')) + expect(screen.queryByText('root-6')).toBeNull() + expect(screen.queryByText('child-6')).toBeNull() + }) + + it('does not raise a nested watermark while a folded ancestor hides it', () => { + const rootItems = [summary('root-1', 30)] + const childItems = Array.from({ length: 8 }, (_, index) => summary(`child-${index + 1}`, 20 - index)) + const b = mount({ + useSessions: hook(sessionState([...rootItems, ...childItems])), + useWorkspaces: hook(workspaceState([ + { ...root, sessionIds: rootItems.map(item => item.id) }, + { ...child, sessionIds: childItems.map(item => item.id) }, + ])), + }) + fireEvent.click(screen.getByText('Child')) + fireEvent.click(screen.getByText('Projects')) + const status = (running: boolean) => hook(new Map([[sid('child-8'), { + running, pendingInteraction: undefined, completionUnread: false, + }]])) + rerender(b, { useSessionStatus: status(true) }) + rerender(b, { useSessionStatus: status(false) }) + fireEvent.click(screen.getByText('Projects')) + expect(screen.getByText('child-5')).toBeTruthy() + expect(screen.queryByText('child-6')).toBeNull() + expect(screen.queryByText('child-8')).toBeNull() + }) + it('nests newly registered ancestors and children while retaining each Workspace session', () => { const b = mount({ useSessions: hook(sessionState([summary('root-session', 1), summary('team-session', 2), summary('child-session', 3)])), From 5a7cb9414929699fed71aef5fe8c67c8c0f329ae Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Mon, 12 Oct 2026 22:28:05 +0800 Subject: [PATCH 02/17] fix(ui-workspace): address watermark review - Compute each group's baseline watermark once per render. - Skip the row-motion reset when folding drops no raised watermark. - Document that the watermark is a row count that a reorder can outrun, and that any unmount of the grouped tree resets it; pin both in tests. - Cover raise-to-current, keep-after-leaving, and fold reset in the keyless Web folding scenario. --- .../watermark-kept.expected.md | 16 ++++++++ .../workspace-new-session-folding.e2e.ts | 39 ++++++++++++++++++- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../src/client/rows/WorkspaceBrowser.tsx | 11 +++--- .../tests/workspace-browser.client.spec.tsx | 23 +++++++++++ 7 files changed, 85 insertions(+), 12 deletions(-) create mode 100644 apps/web/tests/expected/workspace-new-session-folding/watermark-kept.expected.md diff --git a/apps/web/tests/expected/workspace-new-session-folding/watermark-kept.expected.md b/apps/web/tests/expected/workspace-new-session-folding/watermark-kept.expected.md new file mode 100644 index 0000000000..153f797a70 --- /dev/null +++ b/apps/web/tests/expected/workspace-new-session-folding/watermark-kept.expected.md @@ -0,0 +1,16 @@ +- tree "Sessions": + - treeitem "{{workspace}} Workspace actions for {{workspace}} New session in {{workspace}}" [expanded]: + - text: {{workspace}} + - button "Workspace actions for {{workspace}}" + - button "New session in {{workspace}}" + - treeitem "New Session" [selected] + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - button "Show 7 more sessions" diff --git a/apps/web/tests/workspace-new-session-folding.e2e.ts b/apps/web/tests/workspace-new-session-folding.e2e.ts index 176af733b0..4a24a46e59 100644 --- a/apps/web/tests/workspace-new-session-folding.e2e.ts +++ b/apps/web/tests/workspace-new-session-folding.e2e.ts @@ -1,4 +1,4 @@ -/** Blank New Session folding through the shipped Web composition. */ +/** Blank New Session folding and the session watermark through the shipped Web composition. */ import { readFile } from 'node:fs/promises' import { basename, join } from 'node:path' @@ -107,7 +107,42 @@ describe('web e2e: blank New Session folding quota', () => { await expect.poll(() => sidebar.getByRole('treeitem').count()).toBe(18) await sidebar.getByRole('button', { name: 'Show less' }).click() await expect.poll(() => sidebar.getByRole('treeitem').count()).toBe(7) - await assertFixtureInventory(EXPECTED_DIR, ['sidebar.expected.md', 'first-batch.expected.md']) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('raises the watermark to the open session, keeps it after leaving, and resets it on fold', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-workspace-session-watermark')) + const sidebar = page.getByRole('tree', { name: 'Sessions' }) + const workspaceTitle = basename(scaffold.workspaceCwd) + const sessions = () => sidebar.getByText('Untitled', { exact: true }).count() + await sidebar.getByRole('button', { name: 'Show 11 more sessions' }).click() + await expect.poll(sessions).toBe(10) + await sidebar.getByText('Untitled', { exact: true }).nth(8).click() + await sidebar.getByRole('button', { name: 'Show 6 more sessions' }).click() + await sidebar.getByRole('button', { name: 'Show 1 more sessions' }).click() + await expect.poll(sessions).toBe(16) + await sidebar.getByRole('button', { name: 'Show less' }).click() + await expect.poll(sessions).toBe(9) + await sidebar.getByRole('button', { name: 'Show 7 more sessions' }).waitFor() + + const workspaceRow = sidebar.getByRole('treeitem').first() + await workspaceRow.hover() + await page.getByRole('button', { name: `New session in ${workspaceTitle}` }).click() + await sidebar.getByText('New Session', { exact: true }).waitFor() + expect(await sessions()).toBe(9) + await workspaceRow.hover() + await compareOrRefreshGolden( + join(EXPECTED_DIR, 'watermark-kept.expected.md'), + await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd), + MODE, + ) + + await workspaceRow.click() + await workspaceRow.click() + await expect.poll(sessions).toBe(5) + await sidebar.getByRole('button', { name: 'Show 11 more sessions' }).waitFor() + await assertFixtureInventory(EXPECTED_DIR, ['sidebar.expected.md', 'first-batch.expected.md', 'watermark-kept.expected.md']) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 687f287046..75a22081cd 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-workspace/use-this-package: - en: d51507f87484f818 - zh: e2600daa21e35f36 + en: d042b4b1dd7e7f56 + zh: c12fa070ef5a02a4 /deepseek-ai-dsh-client-ui-workspace/use-this-package/reordering-and-view-options: en: 8673afc1d72ad077 zh: 9fbb83a4029c5d10 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index d97b93b13a..6cccb558f9 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -27,7 +27,7 @@ This package lets users browse grouped or flat Session lists, choose a Workspace ## Use this package -Use the sidebar to browse Workspaces and their Sessions, reorder them, and start new ones; use the picker in the Session Intent hero to choose a Workspace for a new session. An open Workspace shows its first non-blank Sessions in list order up to a per-Workspace watermark, initially five. The watermark rises to cover the last Session within the first 50 that is running, has a running child, awaits the user, has an unread completion, or is open in the main view; a Session opened from search raises it to that Session at any position. The watermark never falls on its own, so a Session stays visible after its run or notification ends. Each **Show more** click raises it by five, without the 50-row limit, and reveals the rest when five or fewer remain; after the final batch, **Show less** resets it. Folding a Workspace resets its watermark and those of its nested Workspaces; a reset watermark still covers the qualifying Sessions. The selected blank **New Session** is an extra row outside the watermark until its first prompt. Watermarks are not persisted; they reset when the grouped tree unmounts, including reloads, flat grouping, and search results. +Use the sidebar to browse Workspaces and their Sessions, reorder them, and start new ones; use the picker in the Session Intent hero to choose a Workspace for a new session. An open Workspace shows its first non-blank Sessions in list order up to a per-Workspace watermark, initially five. The watermark rises to cover the last Session within the first 50 that is running, has a running child, awaits the user, has an unread completion, or is open in the main view; a Session opened from search raises it to that Session at any position. The watermark is a row count that never falls on its own, so the rows stay visible after a run or notification ends; a reorder that moves a Session past the watermark hides it. Each **Show more** click raises it by five, without the 50-row limit, and reveals the rest when five or fewer remain; after the final batch, **Show less** resets it. Folding a Workspace resets its watermark and those of its nested Workspaces; a reset watermark still covers the qualifying Sessions. The selected blank **New Session** is an extra row outside the watermark until its first prompt. Watermarks are not persisted; any unmount of the grouped tree resets them. History rows without a stored title use the localized unnamed label (未命名 / Untitled), rather than a directory name. The current blank row remains New Session; other blank rows remain hidden. Rename drafts use the stored title, or start empty when unnamed; unnamed rows do not offer title copying. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index d8d8717933..09a05fe996 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" ## 使用本包 -用侧边栏浏览 Workspace 及其 Session、重排它们并新建会话;在 Session Intent 主视觉区用选择器为新会话选择 Workspace。打开的 Workspace 按列表顺序显示前面的非空白 Session,直到该 Workspace 自己的水位线,水位线初始为五。水位线会升高,以覆盖前 50 条中最后一条正在运行、有子会话正在运行、等待用户、有未读完成提醒或在主视图中打开的 Session;从搜索打开的 Session 会把水位线升高到该 Session,无论其位置。水位线不会自行回落,因此 Session 在运行或通知结束后仍保持可见。每次点击**展开其余**将水位线升高五条,不受 50 行限制;剩余五条或更少时显示全部;最后一批显示后,**收起**重置水位线。折叠 Workspace 会重置其水位线及其嵌套 Workspace 的水位线;重置后的水位线仍覆盖符合条件的 Session。当前选中的空白**新会话**在首条提示词落地前是水位线之外的额外行。水位线不持久化;分组树卸载时会重置,包括重新加载、切换到平铺分组和显示搜索结果时。 +用侧边栏浏览 Workspace 及其 Session、重排它们并新建会话;在 Session Intent 主视觉区用选择器为新会话选择 Workspace。打开的 Workspace 按列表顺序显示前面的非空白 Session,直到该 Workspace 自己的水位线,水位线初始为五。水位线会升高,以覆盖前 50 条中最后一条正在运行、有子会话正在运行、等待用户、有未读完成提醒或在主视图中打开的 Session;从搜索打开的 Session 会把水位线升高到该 Session,无论其位置。水位线是一个行数,不会自行回落,因此运行或通知结束后这些行仍保持可见;重排若把某个 Session 移到水位线之后,该 Session 会被隐藏。每次点击**展开其余**将水位线升高五条,不受 50 行限制;剩余五条或更少时显示全部;最后一批显示后,**收起**重置水位线。折叠 Workspace 会重置其水位线及其嵌套 Workspace 的水位线;重置后的水位线仍覆盖符合条件的 Session。当前选中的空白**新会话**在首条提示词落地前是水位线之外的额外行。水位线不持久化;分组树的任何卸载都会重置水位线。 未保存标题的历史会话显示本地化名称「未命名 / Untitled」,不使用目录名兜底。当前空会话仍显示「新会话」,其他空会话仍隐藏。重命名草稿使用已保存标题,无标题时留空;无标题行不提供标题复制。 diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 3434d8af51..22cce48839 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -386,11 +386,11 @@ function SessionTree({ } } }, [groupExpansion, parents, revealGroup, setGroupExpanded]) - const baselineWatermark = (group: GroupNode): number => Math.max(SESSION_WATERMARK_BASE, requiredWatermark( + const baselines = new Map(groups.map(group => [group.key, Math.max(SESSION_WATERMARK_BASE, requiredWatermark( group.sessions, current, group.key === revealGroup ? revealSessionId : undefined, - )) + ))])) const foldedKeys = new Set(groups.filter(group => !group.expanded).map(group => group.key)) const underFoldedAncestor = (key: string): boolean => { for (let owner = parents.get(key); owner !== undefined; owner = parents.get(owner)) { @@ -399,13 +399,12 @@ function SessionTree({ return false } // A group hidden by a folded ancestor keeps its own expansion and rows but must not raise its watermark. - const requiredWatermarks = new Map(groups - .filter(group => !underFoldedAncestor(group.key)) - .map(group => [group.key, baselineWatermark(group)])) + const requiredWatermarks = new Map([...baselines].filter(([key]) => !underFoldedAncestor(key))) const sessionWatermarks = raiseWatermarks(storedWatermarks, requiredWatermarks) // Raising during render keeps the watermark monotonic without committing a lower frame first. if (sessionWatermarks !== storedWatermarks) setStoredWatermarks(marks => raiseWatermarks(marks, requiredWatermarks)) const dropWatermarks = (drop: (key: string) => boolean): void => { + if (!Object.keys(storedWatermarks).some(drop)) return setStoredWatermarks(marks => Object.fromEntries(Object.entries(marks).filter(([key]) => !drop(key)))) setWatermarkMotionKey(key => key + 1) } @@ -478,7 +477,7 @@ function SessionTree({ const children = childrenByParent.get(group.key) ?? [] const compatibleDrag = workspaceDrag !== null && parents.get(workspaceDrag.workspaceId) === parents.get(group.key) const watermark = sessionWatermarks[group.key] ?? SESSION_WATERMARK_BASE - const collapsed = watermarkRows(group.sessions, baselineWatermark(group)) + const collapsed = watermarkRows(group.sessions, baselines.get(group.key) ?? SESSION_WATERMARK_BASE) const visible = watermarkRows(group.sessions, watermark) const sessionsExpanded = visible.hiddenCount === 0 rowKeys.push(`workspace:${group.key}`) diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 362cd1604d..544ddd5140 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -809,6 +809,29 @@ describe('WorkspaceBrowser', () => { expectVisible(8) }) + it('keeps the raised row count, not the Session, when a new Session pushes it down', () => { + const items = Array.from({ length: 12 }, (_, index) => summary(`session-${index + 1}`, 20 - index)) + const b = mount({ + useSessions: hook(sessionState(items)), + useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])), + }) + fireEvent.click(screen.getByText('alpha')) + const running = (value: boolean) => hook(new Map([[sid('session-9'), { + running: value, pendingInteraction: undefined, completionUnread: false, + }]])) + rerender(b, { useSessionStatus: running(true) }) + rerender(b, { useSessionStatus: running(false) }) + expect(screen.getByText('session-9')).toBeTruthy() + const newest = summary('newest', 30) + rerender(b, { + useSessions: hook(sessionState([newest, ...items])), + useWorkspaces: hook(workspaceState([workspace('alpha', [newest.id, ...items.map(item => item.id)])])), + }) + expect(screen.getByText('session-8')).toBeTruthy() + expect(screen.queryByText('session-9')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 4 个会话' })).toBeTruthy() + }) + it('stops automatic raises at 50 rows while Show more continues past them', () => { const items = Array.from({ length: 60 }, (_, index) => summary(`session-${index + 1}`, 60 - index)) const b = mount({ From efdaa86fb84bc1162f5946fdd80b4999c0122c42 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:54:05 +0800 Subject: [PATCH 03/17] feat(typert): unify Remote definitions with lazy method info --- ...ompiler-independent-typert-model.i18n.yaml | 4 +- ...07-27-compiler-independent-typert-model.md | 2 + ...27-compiler-independent-typert-model.zh.md | 2 + ...08-02-typert-remote-method-calls.i18n.yaml | 20 +- .../2026-08-02-typert-remote-method-calls.md | 14 +- ...026-08-02-typert-remote-method-calls.zh.md | 14 +- ...-definitions-and-passive-clients.i18n.yaml | 19 + ...-remote-definitions-and-passive-clients.md | 41 ++ ...mote-definitions-and-passive-clients.zh.md | 41 ++ ...ote-metadata-and-passive-clients.i18n.yaml | 37 ++ ...ert-remote-metadata-and-passive-clients.md | 195 +++++++ ...-remote-metadata-and-passive-clients.zh.md | 195 +++++++ .oxlintrc.json | 1 + apps/web/tests/assembled-boot.ts | 3 + docs/config-catalog.md | 5 + docs/config-catalog.zh.md | 5 + docs/subsystems/typert.i18n.yaml | 8 +- docs/subsystems/typert.md | 22 +- docs/subsystems/typert.zh.md | 22 +- .../passive-remote-clients/guide.i18n.yaml | 16 + .../passive-remote-clients/guide.md | 21 + .../passive-remote-clients/guide.zh.md | 21 + packages/api/gateway/package.json | 5 +- packages/api/gateway/src/client/index.ts | 150 +++-- .../api/gateway/src/client/remote-events.ts | 16 +- .../api/gateway/src/client/remote-stream.ts | 17 +- .../api/gateway/src/client/stream-client.ts | 51 +- packages/api/gateway/src/index.ts | 11 + packages/api/gateway/src/stream-protocol.ts | 3 + .../tests/control-retry.client.spec.ts | 19 + .../gateway/tests/gateway-stream.host.spec.ts | 15 +- .../api/gateway/tests/gateway.client.spec.ts | 100 ++-- packages/api/gateway/tests/node-client.e2e.ts | 57 ++ .../api/gateway/tests/passive.client.spec.ts | 250 ++++++++ .../gateway/tests/strict-source.host.spec.ts | 77 +++ .../tests/client-apply.client.spec.ts | 1 + packages/api/remotes/README.i18n.yaml | 8 +- packages/api/remotes/README.md | 6 +- packages/api/remotes/README.zh.md | 6 +- packages/api/remotes/package.json | 6 + packages/api/remotes/tests/built-lib.e2e.ts | 2 +- .../tests/fixtures/session-client/client.ts | 124 ++++ .../tests/fixtures/session-client/mcp.ts | 82 +++ .../tests/fixtures/session-client/run.mjs | 259 +++++++++ .../fixtures/session-client/tsconfig.json | 15 + .../tests/fixtures/session-client/typing.ts | 28 + .../api/remotes/tests/session-client.e2e.ts | 56 ++ .../src/client/transport.ts | 3 - .../tests/transport.client.spec.ts | 2 +- .../workspace-controller/src/client/index.ts | 3 - .../tests/transport.client.spec.ts | 2 +- packages/bundle/web-app/cordis.patch.yml | 6 + packages/client/connection/README.i18n.yaml | 8 +- packages/client/connection/README.md | 6 +- packages/client/connection/README.zh.md | 6 +- packages/client/connection/package.json | 2 + .../client/connection/src/client/index.ts | 16 + .../client/connection/src/client/socket.ts | 102 ++++ .../connection/tests/socket.client.spec.ts | 134 +++++ .../client/connection/tsconfig.client.json | 1 + .../product-analytics/src/client/index.ts | 1 - .../tests/collection.client.spec.ts | 3 +- .../tests/readiness.client.spec.ts | 2 +- .../src/client/api-catalog.ts | 12 +- .../extensions/tool-cordis/src/api-catalog.ts | 10 +- .../client-runtime/README.i18n.yaml | 4 +- .../test-support/client-runtime/README.md | 2 + .../test-support/client-runtime/README.zh.md | 2 + .../src/assembly/test-client.ts | 12 +- packages/typert/generator/README.i18n.yaml | 7 +- packages/typert/generator/README.md | 15 + packages/typert/generator/README.zh.md | 15 + packages/typert/generator/package.json | 1 + packages/typert/generator/src/analyzer.ts | 69 ++- packages/typert/generator/src/emitter.ts | 137 +++-- packages/typert/generator/src/model.ts | 11 +- .../generator/src/remote-json-schema.ts | 149 +++++ packages/typert/generator/src/workspace.ts | 8 +- .../remote-model/typert-protocol.d.ts | 71 ++- .../generator/tests/remote-metadata.spec.ts | 550 ++++++++++++++++++ .../generator/tests/remote-model.spec.ts | 50 +- .../typert/generator/tests/remote-module.ts | 22 + packages/typert/loader/README.i18n.yaml | 12 +- packages/typert/loader/README.md | 7 +- packages/typert/loader/README.zh.md | 7 +- packages/typert/loader/package.json | 2 + packages/typert/loader/src/index.ts | 46 +- .../typert/loader/tests/lazy-info.spec.ts | 32 + packages/typert/loader/tests/loader.spec.ts | 111 +++- packages/typert/protocol/README.i18n.yaml | 16 +- packages/typert/protocol/README.md | 13 +- packages/typert/protocol/README.zh.md | 13 +- packages/typert/protocol/package.json | 7 +- packages/typert/protocol/src/index.ts | 70 ++- packages/typert/protocol/src/remote-info.ts | 145 +++++ packages/typert/protocol/src/types.ts | 93 ++- .../typert/protocol/tests/protocol.spec.ts | 28 +- .../typert/protocol/tests/remote-info.spec.ts | 190 ++++++ packages/typert/registry/README.i18n.yaml | 12 +- packages/typert/registry/README.md | 6 +- packages/typert/registry/README.zh.md | 6 +- packages/typert/registry/package.json | 1 + packages/typert/registry/tests/typert.spec.ts | 40 ++ pnpm-lock.yaml | 36 +- scripts/check-workspace-constraints.spec.ts | 9 + scripts/check-workspace-constraints.ts | 13 +- scripts/no-unknown-casts.baseline.json | 7 +- scripts/run-gates.ts | 1 + scripts/verify-package-dependencies.spec.ts | 27 + scripts/verify-package-dependencies.ts | 9 +- tsconfig.base.json | 1 + tsconfig.host.json | 2 + 112 files changed, 4085 insertions(+), 393 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.md create mode 100644 .agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.zh.md create mode 100644 .agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.i18n.yaml create mode 100644 .agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.md create mode 100644 .agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.zh.md create mode 100644 docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.md create mode 100644 docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.zh.md create mode 100644 packages/api/gateway/tests/node-client.e2e.ts create mode 100644 packages/api/gateway/tests/passive.client.spec.ts create mode 100644 packages/api/gateway/tests/strict-source.host.spec.ts create mode 100644 packages/api/remotes/tests/fixtures/session-client/client.ts create mode 100644 packages/api/remotes/tests/fixtures/session-client/mcp.ts create mode 100644 packages/api/remotes/tests/fixtures/session-client/run.mjs create mode 100644 packages/api/remotes/tests/fixtures/session-client/tsconfig.json create mode 100644 packages/api/remotes/tests/fixtures/session-client/typing.ts create mode 100644 packages/api/remotes/tests/session-client.e2e.ts create mode 100644 packages/client/connection/src/client/socket.ts create mode 100644 packages/client/connection/tests/socket.client.spec.ts create mode 100644 packages/typert/generator/src/remote-json-schema.ts create mode 100644 packages/typert/generator/tests/remote-metadata.spec.ts create mode 100644 packages/typert/generator/tests/remote-module.ts create mode 100644 packages/typert/loader/tests/lazy-info.spec.ts create mode 100644 packages/typert/protocol/src/remote-info.ts create mode 100644 packages/typert/protocol/tests/remote-info.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml index 50fa27df10..7e093f823b 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml @@ -18,5 +18,5 @@ en: fa503dcfaa6f438f zh: 26052212a124e133 /agent-note-compiler-independent-typert-type-model/consequences: - en: 4a472acf377f8361 - zh: cb83602b123a4aae + en: 62bcfee24881dd11 + zh: 97867e4be4c2e390 diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md index 5083c55176..ae255d08b2 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md @@ -51,3 +51,5 @@ For each supported node kind and literal category, Zod emitter tests run both su New generation targets and static checks can reuse the same TypeGraph, and business categories can extend PackageModel without parsing the AST again. Preserving pre-evaluation types and independent faces makes the model more complex than a flattened schema; emitters must explicitly declare their supported scope and fail on missing capabilities. Explicit package opt-in keeps artifact publication and exports under package ownership. Repository orchestration may still run the whole-workspace Host contract pass for every opted-in package; that pass remains owned by the later Remote Gateway Agent Note. The static Cordis catalogs remain reproducible from the canonical model without coupling `tool-cordis` to runtime registry state. `ctx.typert` reflects only artifacts mounted in the current runtime, and unloading does not control Zod instances that consumers retain after materializing them. + +Ordinary Host and Remote artifacts reuse this model to emit one static method spec per endpoint. A pure factory returns a lazy info instance whose direct method index and descriptor list share objects; the existing Registry retains those descriptors, while `TYPERT_REMOTE.methods` supplies exact static types. The [unified Typert/Remote metadata note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.md) records this delivery mechanism and the incomplete downstream adaptation. Declaration and face-isolation rules remain unchanged; separate Programs and processes do not share object identity. diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md index 70d6954fae..1b45bdb28e 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md @@ -51,3 +51,5 @@ Zod emitter 对支持的节点和各类 literal 逐类执行成功与失败 pars 新增生成目标或静态检查可复用同一 TypeGraph,业务类目也可在 PackageModel 上扩展,而无需再次解析 AST。保留计算前类型和独立 face 的代价是模型比打平后的 schema 更复杂,emitter 必须显式声明支持范围并对缺失能力失败。 包级显式 opt-in 使产物发布与 exports 由各包自行管理。仓库编排仍可为每个已 opt-in 的包运行全仓 Host 约定 pass;该 pass 仍由后续 Remote Gateway Agent Note 负责说明。静态 Cordis catalog 可从标准模型复现,同时不把 `tool-cordis` 与运行时注册表状态耦合。`ctx.typert` 只反映当前运行时中已挂载的产物;对于消费方物化后仍持有的 Zod 实例,卸载流程无法控制。 + +普通 Host 与 Remote 产物复用此模型,为每个端点生成一份静态方法 spec。纯 factory 返回惰性 info 实例,其直接方法索引与描述符列表共享对象;既有 Registry 保留这些描述符,而 `TYPERT_REMOTE.methods` 提供精确静态类型。[统一 Typert/Remote 元数据 note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.zh.md)记录该交付机制及尚未完成的下游适配。声明与编译面隔离规则保持不变;独立 Program 和进程不共享对象身份。 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 7c9a3757f4..7b98e80d31 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -12,14 +12,14 @@ en: 44d946b53496974c zh: 403447c25a5b2d7c /agent-note-typert-gateway-targeted-method-calls/components-and-cordis-services: - en: aa7f509189ade32e - zh: ef2841bc793f31b2 + en: 02c20706e48467e6 + zh: 2b0916ae0cee351b /agent-note-typert-gateway-targeted-method-calls/business-declarations: - en: c6b70d442b137d2e - zh: 6d0dbefe89937ef1 + en: b9394d5ef98cc82a + zh: 1fce07fe150722ee /agent-note-typert-gateway-targeted-method-calls/decorators-and-the-explicit-gateway-facet: - en: 155afbe4b8af600b - zh: 14705698a9cdbd84 + en: 84090e2490059b9f + zh: dfad2c936cdb6463 /agent-note-typert-gateway-targeted-method-calls/lookup-and-remote-scope-registration: en: 9f7448898cf5cadc zh: 419573c589ed6f7d @@ -48,8 +48,8 @@ en: 5d14e1b7afc53efb zh: e2f4b5814083738f /agent-note-typert-gateway-targeted-method-calls/src-and-lib-operating-modes: - en: 1e366dcfab74831b - zh: 14199f25f20a9352 + en: f1b8426d1cfaed35 + zh: a370376923c98d6e /agent-note-typert-gateway-targeted-method-calls/host-gateway-resolution: en: 88a95d7ab4621caf zh: 89b63fb6440fca79 @@ -66,8 +66,8 @@ en: b1c0c3dd77c56ce8 zh: 769ffd4aeefd949c /agent-note-typert-gateway-targeted-method-calls/alternatives-considered: - en: 0d68c499b7780f4f - zh: bdd1e57232b996e2 + en: eaa3ffe5ca14005c + zh: 1f0b455e30ade872 /agent-note-typert-gateway-targeted-method-calls/verification: en: 7433bdb522bb2083 zh: 545c17613b636f00 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index f8eb1a8f42..6ae801dc28 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -28,7 +28,7 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T | Component | Cordis service | Responsibility | |---|---|---| -| `@deepseek-ai/dsh-typert-protocol` | Declares only the minimal `ctx.typert` protocol | `TypertRemoteService`, decorators, binding fallback, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser | +| `@deepseek-ai/dsh-typert-protocol` | Declares only the minimal `ctx.typert` protocol | `TypertRemoteService`, decorators, binding fallback, descriptors, lookup/Context, the Remote map, and Zod-backed lazy info assembly; no dependency on the compiler, Connection, or Browser | | Typert registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers | | Typert generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` | | API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and projects unary results into carrier-neutral fields and attachments | @@ -81,16 +81,18 @@ export class ScopedGoalService extends TypertRemoteService { An endpoint selects exactly one invocation mode. A flow that needs an explicit `Agent` parameter uses `@Remote`. A flow that first switches to an Agent Context and then resolves a scoped receiver uses `@RemoteScope('agent')`. Typert does not infer either mode from the method body or from a missing parameter. -Business packages depend only on the lightweight `@deepseek-ai/dsh-typert-protocol`. It provides `TypertRemoteService` and declaration protocols for decorators, the binding fallback, lookup, Remote Scope, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime. +Business packages depend only on `@deepseek-ai/dsh-typert-protocol`. It provides `TypertRemoteService` and declaration protocols for decorators, the binding fallback, lookup, Remote Scope, and descriptors, without depending on the TypeScript compiler, HTTP, or the Client runtime. Its pure `./remote-info` entry uses Zod to assemble lazy named-input schemas without importing Cordis; the root entry also re-exports it alongside Cordis-backed declarations. A method that cooperatively supports cancellation declares `signal: AbortSignal` as its final Host parameter. This reserved parameter is not a business value, lookup, or JSON field. The generated consumer method exposes it as a final optional parameter so ordinary calls remain unchanged while callers that own cancellation can pass a signal. ## Decorators and the explicit Gateway facet -A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteScope('agent', 'create')` are external method names; the decorated member may be the business method itself or an adapter such as `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `TypertRemoteService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertGateway` field keeps the binding visible on the runtime instance. +A decorator states Remote participation, an optional export name, stream delivery, and whether generated request-object codecs reject unknown fields. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteScope('agent', 'create')` are external method names; the decorated member may be the business method itself or an adapter such as `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `TypertRemoteService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertRemote` field keeps the binding visible on the runtime instance. In SRC mode, the decorator records the method name and invocation mode in a versioned descriptor on the Service prototype. The descriptor uses a stable string property name, so `remoteMethods()` can read markers produced by another installed copy of `dsh-typert-protocol`; it writes nothing to the Service instance, constructor, or method function. +Ordinary artifacts construct a lazy Remote info instance from one static method spec per endpoint. Its direct `TypertRemoteMethod` objects retain descriptor fields and add named `input` and `invoke`; `result` is the only output codec. Existing Registry queries retain those objects, while `TYPERT_REMOTE.methods` supplies exact static types. Strict source startup prebuilds and loads ordinary Typert artifacts; fallback cannot recover erased schemas. The [unified metadata note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.md) records this delivery mechanism and the incomplete consumer adaptation. Forwarded-event participation remains an explicit composition choice. + In LIB mode, the Typert compiler performs strict method discovery, type resolution, and descriptor generation. It accepts a literal service key in `TypertRemoteService`'s direct `super()` call or the explicit binding fallback; generation neither rewrites business source nor injects hidden registration metadata. ## Lookup and Remote Scope registration @@ -349,9 +351,9 @@ The Web already depends on build artifacts such as `lib/client.js`, so it requir ## SRC and LIB operating modes -SRC supports local source startup. The versioned prototype descriptors created by `@Remote` and `@RemoteScope()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor. +SRC supports local source startup. For methods without registered strict definitions, the versioned prototype descriptors created by `@Remote` and `@RemoteScope()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor. -For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object. +For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. The conservative fallback does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object. A signature that SRC cannot resolve unambiguously fails on the first invocation that resolves its descriptor; Service mounting records only the decorator marker and does not inspect the JavaScript signature. SRC does not guess at object destructuring, ambiguity caused by default parameters, rest parameters, nested lookups, or complex types. @@ -474,7 +476,7 @@ The package topology is `api/remotes → api/gateway → client/connection → h **Perform strict reflection through decorators at runtime.** JavaScript decorators cannot recover erased TypeScript types, public symbol identity, or complete Zod codecs. Injecting a compiler-private symbol into a constructor would also hide the business class's real dependencies, so Typert generates strict information at compile time. -**Use a preload, loader hook, or complete `ts.Program` during SRC startup.** This could reuse LIB analysis but would add requirements to every source startup entry. SRC needs only a usable permissive descriptor, so it uses decorator markers, function parameter names, and explicit providers; strict checks remain in the LIB contract pass. +**Use a preload, loader hook, or complete `ts.Program` during SRC startup.** This could reuse LIB analysis but would add requirements to every source startup entry. Source startup can load ordinary prebuilt artifacts or use conservative markers without loading a TypeScript Program. **Hand-write the Client interface.** A hand-written interface cannot guarantee that it contains only Remote-marked methods and can drift from Host signatures, lookup IDs, and Zod schemas. Client types are therefore projected automatically from the Host Program. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 53edd345a7..8d7eca4a96 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -28,7 +28,7 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 | 组件 | Cordis 服务 | 职责 | |---|---|---| -| `@deepseek-ai/dsh-typert-protocol` | 只声明 `ctx.typert` 的最小协议 | `TypertRemoteService`、decorator、binding 回退、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser | +| `@deepseek-ai/dsh-typert-protocol` | 只声明 `ctx.typert` 的最小协议 | `TypertRemoteService`、decorator、binding 回退、descriptor、lookup/Context、Remote map 和基于 Zod 的惰性 info 组装;不依赖 compiler、Connection 或 Browser | | Typert registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider | | Typert generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` | | API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法,并将一元结果投影为载体无关的字段与附件 | @@ -81,16 +81,18 @@ export class ScopedGoalService extends TypertRemoteService { 同一个 endpoint 只能选择一种调用模式。需要显式 `Agent` 参数的流程使用 `@Remote`;需要切换到 Agent Context 再解析 scoped receiver 的流程使用 `@RemoteScope('agent')`,两者不会由 Typert 根据方法体或参数缺失自动猜测。 -业务包只依赖轻量的 `@deepseek-ai/dsh-typert-protocol`。它提供 `TypertRemoteService`,以及 decorator、binding 回退、lookup、Remote Scope 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。 +业务包只依赖 `@deepseek-ai/dsh-typert-protocol`。它提供 `TypertRemoteService`,以及 decorator、binding 回退、lookup、Remote Scope 和 descriptor 的声明协议,不依赖 TypeScript compiler、HTTP 或 Client runtime。其纯 `./remote-info` 入口使用 Zod 组装惰性具名输入 schema,不导入 Cordis;根入口在提供依赖 Cordis 的声明之余,也重新导出该能力。 支持协作式取消的方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。这个保留参数不是业务值、lookup 或 JSON 字段。生成的消费方方法将其暴露为最后一个可选参数,因此普通调用保持不变,而拥有取消控制权的调用方可以传入 signal。 ## Decorator 与显式 Gateway facet -Decorator 只表达“该方法参与 Remote 约定”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteScope('agent', 'create')` 的参数是外部方法名;被装饰成员既可以是业务方法本身,也可以是 `remoteExportCreate` 这样的适配器。未给别名时才使用成员名作为外部方法名。继承 `TypertRemoteService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertGateway` 字段使运行时实例上的绑定保持可见。 +Decorator 表达 Remote 参与、可选导出名称、流式投递,以及生成的请求对象 codec 是否拒绝未知字段,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteScope('agent', 'create')` 的参数是外部方法名;被装饰成员既可以是业务方法本身,也可以是 `remoteExportCreate` 这样的适配器。未给别名时才使用成员名作为外部方法名。继承 `TypertRemoteService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertRemote` 字段使运行时实例上的绑定保持可见。 SRC 模式下,decorator 把方法名和调用模式记录在 Service prototype 上的带版本描述符中。描述符使用稳定的字符串属性名,因此 `remoteMethods()` 可以读取 `dsh-typert-protocol` 另一个已安装副本生成的标记;它不会向 Service 实例、constructor 或方法函数写入任何内容。 +普通产物从每个端点唯一的静态方法 spec 构造惰性 Remote info 实例。其直接 `TypertRemoteMethod` 对象保留描述符字段,并增加具名 `input` 和 `invoke`;`result` 是唯一输出 codec。既有 Registry 查询保留这些对象,`TYPERT_REMOTE.methods` 则提供精确静态类型。严格源码启动预构建并加载普通 Typert 产物;fallback 无法恢复被擦除的 schema。[统一元数据 note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.zh.md)记录该交付机制及尚未完成的消费方适配。转发事件参与仍是显式的组合选择。 + LIB 的严格方法发现、类型解析和 descriptor 生成由 Typert compiler 完成。它接受 `TypertRemoteService` 直接 `super()` 调用中的字面量 service key,或显式 binding 回退;生成过程不改写业务源码,也不注入隐藏注册元数据。 ## Lookup 与 Remote Scope 注册 @@ -349,9 +351,9 @@ Web 本身依赖 `lib/client.js` 等构建产物,因此启动 Web 前要求完 ## SRC 与 LIB 运行模式 -SRC 面向本地源码启动。`@Remote` 和 `@RemoteScope()` 创建的带版本 prototype 描述符给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。 +SRC 面向本地源码启动。对于没有已注册严格定义的方法,`@Remote` 和 `@RemoteScope()` 创建的带版本 prototype 描述符给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。 -例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。 +例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。保守回退不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。 SRC 无法明确解析的签名会在首次调用解析其 descriptor 时失败;Service 挂载只记录 decorator 标记,不检查 JavaScript 签名。SRC 不会猜测对象解构、默认参数造成的歧义、rest 参数、嵌套 lookup 或复杂类型。 @@ -474,7 +476,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS **让 decorator 在运行时完成严格反射。** JavaScript decorator 无法恢复擦除后的 TypeScript 类型、公共符号身份和完整 Zod codec;向 constructor 注入 compiler 私有 symbol 又会隐藏业务类的真实依赖,因此严格信息由 Typert compiler 生成。 -**SRC 启动时使用 preload、loader hook 或完整 `ts.Program`。** 这能复用 LIB 分析,但增加所有源码启动入口的要求。SRC 只需要可用的弱 descriptor,因此采用 decorator 标记、函数参数名和显式 provider;严格检查留给 LIB 约定 pass。 +**SRC 启动时使用 preload、loader hook 或完整 `ts.Program`。** 这能复用 LIB 分析,但增加所有源码启动入口的要求。源码启动可加载普通预构建产物或使用保守标记,而无需加载 TypeScript Program。 **手写 Client interface。** 手写接口不能保证只包含 Remote 标记的方法,也会与 Host 签名、lookup ID 和 Zod schema 漂移,因此 Client 类型从 Host Program 自动投影。 diff --git a/.agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.i18n.yaml new file mode 100644 index 0000000000..aadb6a5f08 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-20-committed-remote-definitions-and-passive-clients.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.md +/agent-note-committed-remote-definitions-and-passive-clients: + en: 0452289f77050c14 + zh: e2ac3a0abd0589b1 +/agent-note-committed-remote-definitions-and-passive-clients/problem: + en: d945dbb724377079 + zh: 9a3a490e17571299 +/agent-note-committed-remote-definitions-and-passive-clients/decision: + en: 5c22c7c9b2895efd + zh: 3fc9345ec1e8bf72 +/agent-note-committed-remote-definitions-and-passive-clients/alternatives-considered: + en: c47d0cf858e28d83 + zh: 871dafca226bd544 +/agent-note-committed-remote-definitions-and-passive-clients/consequences: + en: 8072aa586c42dfab + zh: 3bae173fa86f5604 diff --git a/.agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.md b/.agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.md new file mode 100644 index 0000000000..70ba370e60 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.md @@ -0,0 +1,41 @@ +# Agent Note: Committed Remote definitions and passive clients + +Status: implemented + +English | [中文](2026-09-20-committed-remote-definitions-and-passive-clients.zh.md) + +## Problem + +A command or tool needs discoverable input documentation and validation before connecting to a Host. Recovering that information from running Services makes offline help depend on application boot, while a separate command schema or invocation dispatcher creates another authority for the same operation. Source launches also need the same selected request validation as published builds. + +A consumer that only invokes methods must not implicitly join forwarded user-interaction waterfalls. Joining those deliveries can delegate or settle interactions even when that consumer has no user interface. + +## Decision + +The [Typert method-call design](2026-08-02-typert-remote-method-calls.md) retains ownership of dispatch, generated signatures, lookup resolution, and carrier-independent invocation. Selected methods declare `@Remote({ emitSourceDef: true })`; the existing compiler-independent model emits committed source definitions, published definitions, and consumer metadata from their owning method and type declarations. The Host binding carries generated definitions explicitly, and a selected source method without its definition fails rather than using JSON-only validation. Closing request objects is a separate `rejectUnknownFields: true` choice, accepted only with `emitSourceDef`, so selecting a method for offline consumers does not change the request policy of its existing callers. + +The committed module contains parsers, method and field documentation, supported tool JSON Schemas, and typed calls to the ordinary Client Remote methods. Consumer selection and presentation do not redefine business schemas. Methods that also declare `rejectUnknownFields` reject unknown fields in declared request objects at the Host and in their tool schemas; explicit dictionaries and other methods keep their own policy. Unsupported input schemas fail generation; outputs retain their codec and omit optional JSON Schema when lossless projection is unavailable. Semantic constraints absent from TypeScript remain with their operation owner. + +Generation verification compares committed source against current owner declarations. Normal artifact publication also performs that check, so a build cannot silently publish a different definition from the source launch. Type-only owner imports keep metadata loading independent of Host Service activation. The [compiler-independent model](2026-07-27-compiler-independent-typert-model.md) remains the extraction and projection authority. + +Connection owns authenticated HTTP and physical WebSocket access. Gateway owns Remote framing and method invocation. A Gateway Client receives forwarded Host events only with `forwardedEvents: true`; without it, `$on()` and `$stream()` fail instead of waiting for deliveries or generations that never start. The Web bundle sets the Host Gateway row's `forwardedEvents`, which publishes the choice to served pages. Node compositions use the existing Client entries and generated contributions, with instance-owned carrier hooks and awaited disposal, and stay passive unless they configure the option. + +## Alternatives considered + +**Handwritten CLI/tool schemas and dispatch.** Every operation change would update two authorities. + +**Runtime TypeScript analysis.** Help and request validation would depend on compiler availability and application startup. + +**Weak source fallback for selected methods.** Source launches would accept requests that the corresponding published Host rejects. + +**Automatic event participation.** Subscribing every Remote client to forwarded events would couple passive observation to interactive answer ownership. + +## Consequences + +Owners regenerate committed metadata when selected signatures or documentation change. Generated files increase the reviewed diff, but an executed freshness check rejects drift. The tool projection intentionally supports a bounded JSON Schema language and refuses unsupported types without weakening them. + +Generation keeps the face's contributing packages in one stable TypeScript program: limiting program roots to emitted packages would omit externally declared members and make focused metadata disagree with the complete build. + +Generated schema tables and calls repeat emitter templates. Generator and owner integration suites verify their behavior, while repository per-file coverage and clone detection omit the emitted code. The coverage exclusion and source inventory share one exact path pattern; the inventory rejects unowned files, and byte-for-byte verification rejects handwritten additions to owned files. These checks retain the coverage requirements for authored sources and the typechecking and lint requirements for generated sources. + +Verification compares source and published parsers, rejects unknown and missing inputs, typechecks generated calls, rejects stale artifacts before publication, and observes passive and interactive clients over real HTTP and WebSocket carriers. diff --git a/.agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.zh.md b/.agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.zh.md new file mode 100644 index 0000000000..d2265bf6ab --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 提交到源码树的 Remote 定义与被动客户端 + +Status: implemented + +[English](2026-09-20-committed-remote-definitions-and-passive-clients.md) | 中文 + +## Problem + +命令或工具需要在连接 Host 前取得输入文档并验证输入。从运行中的 Service 恢复这些信息会使离线帮助依赖应用启动;单独的命令 schema 或调用分发器则会为同一操作建立第二个权威来源。源码启动也需要与发布构建相同的选中请求验证。 + +仅调用方法的消费方不得隐式加入转发的用户交互 waterfall。即使消费方没有用户界面,加入这些投递也可能委托或结束交互。 + +## Decision + +[Typert 方法调用设计](2026-08-02-typert-remote-method-calls.zh.md)继续拥有分发、生成签名、查找解析以及独立于载体的调用。选中的方法声明 `@Remote({ emitSourceDef: true })`;现有的编译器无关模型从其拥有者的方法和类型声明生成提交到源码树的定义、发布定义和消费方元数据。Host 绑定显式携带生成定义;选中的源码方法缺少定义时会失败,而不会退回仅验证 JSON。关闭请求对象是单独的 `rejectUnknownFields: true` 选择,且只能与 `emitSourceDef` 一起使用,因此为离线消费方选中某个方法不会改变其现有调用方的请求策略。 + +提交的模块包含解析器、方法与字段文档、支持的工具 JSON Schema,以及对普通 Client Remote 方法的类型化调用。消费方的选择与呈现不重新定义业务 schema。同时声明 `rejectUnknownFields` 的方法在 Host 与其工具 schema 中拒绝已声明请求对象的未知字段;显式字典与其他方法保留各自策略。不支持的输入 schema 会使生成失败;无法无损投影时,输出保留 codec 并省略可选 JSON Schema。TypeScript 未表达的语义约束仍由操作拥有者负责。 + +生成验证将已提交源码与当前拥有者声明比较。正常产物发布也执行此检查,因此构建不能静默发布与源码启动不同的定义。仅类型的拥有者导入使元数据加载独立于 Host Service 激活。[编译器无关模型](2026-07-27-compiler-independent-typert-model.zh.md)继续作为提取与投影的权威。 + +Connection 拥有经过认证的 HTTP 与物理 WebSocket 访问。Gateway 拥有 Remote 帧和方法调用。Gateway Client 仅在 `forwardedEvents: true` 时接收转发的 Host 事件;未开启时,`$on()` 与 `$stream()` 直接失败,而不会等待永不开始的投递或 generation。Web bundle 设置 Host Gateway 行的 `forwardedEvents`,由它把这一选择发布给已服务的页面。Node 组合使用现有 Client 入口与生成贡献,载体钩子由实例拥有,并等待释放完成;未配置该选项时保持被动。 + +## Alternatives considered + +**手写 CLI/工具 schema 与分发。** 每次操作变更都要更新两个权威来源。 + +**运行时 TypeScript 分析。** 帮助与请求验证会依赖编译器可用性及应用启动。 + +**为选中方法保留弱源码回退。** 源码启动会接受对应发布 Host 拒绝的请求。 + +**自动参与事件。** 让每个 Remote 客户端订阅转发事件会把被动观察与交互回答所有权耦合。 + +## Consequences + +选中签名或文档变化时,拥有者重新生成已提交元数据。生成文件增加了需要审阅的差异,但执行的新鲜度检查会拒绝漂移。工具投影刻意支持有界的 JSON Schema 语言,并拒绝不支持的类型而不弱化它们。 + +生成过程将该 face 的贡献包保留在同一个稳定的 TypeScript 程序中:若只把生成产物的包作为程序根,就会漏掉外部声明的成员,导致局部元数据与完整构建不一致。 + +生成的 schema 表和调用会重复发射器模板。生成器与拥有者的集成测试验证其行为,仓库的逐文件覆盖率和重复代码检测则省略生成代码。覆盖率排除项与源码清单共享同一个精确路径模式;清单拒绝无生成拥有者的文件,逐字节验证拒绝在有效生成文件中添加手写内容。这些检查保留了手写源码的覆盖率要求,以及生成源码的类型检查和 lint 要求。 + +验证比较源码与发布解析器,拒绝未知和缺失输入,对生成调用进行类型检查,在发布前拒绝过期产物,并通过真实 HTTP 和 WebSocket 载体观察被动与交互客户端。 diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.i18n.yaml new file mode 100644 index 0000000000..c8e46c4a48 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.i18n.yaml @@ -0,0 +1,37 @@ +# Bilingual-pair consistency record for 2026-09-20-unified-typert-remote-metadata-and-passive-clients.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.md +/agent-note-unified-typert-remote-metadata-and-passive-clients: + en: 4b9c7921ba23adf9 + zh: 24015b341a7befa3 +/agent-note-unified-typert-remote-metadata-and-passive-clients/problem: + en: 154cf4633d54dbfe + zh: 50755266546a873f +/agent-note-unified-typert-remote-metadata-and-passive-clients/technical-architecture: + en: 2b2f20ece33959dd + zh: 3179782919f858ee +/agent-note-unified-typert-remote-metadata-and-passive-clients/proposal: + en: 5e8a34a91fb50448 + zh: ec5a7a4f3b1fcc2e +/agent-note-unified-typert-remote-metadata-and-passive-clients/proposal/shared-generation-two-access-modes: + en: e6ca44352c040f21 + zh: 385236cd488fddf2 +/agent-note-unified-typert-remote-metadata-and-passive-clients/proposal/downstream-migration-targets: + en: bcaaab505630d7cb + zh: 799eb1cd2627ae8c +/agent-note-unified-typert-remote-metadata-and-passive-clients/proposal/paired-usage-examples: + en: 778a32f7d7e08245 + zh: f07c79ff93de45eb +/agent-note-unified-typert-remote-metadata-and-passive-clients/proposal/bounded-reproduction-on-this-branch: + en: c735bfb5da7ead9a + zh: 4e9d1c5728bbbc10 +/agent-note-unified-typert-remote-metadata-and-passive-clients/alternatives-considered: + en: 7ec48c492b1e4859 + zh: a751968903a7f711 +/agent-note-unified-typert-remote-metadata-and-passive-clients/acceptance-criteria: + en: 42cdcc600e85ad8c + zh: 6e34ee25cdc55aae +/agent-note-unified-typert-remote-metadata-and-passive-clients/risks: + en: 74f3a5b36d5609b2 + zh: 0ecc15d505511e8a diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.md b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.md new file mode 100644 index 0000000000..b48cb493bc --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.md @@ -0,0 +1,195 @@ +# Agent Note: Unified Typert/Remote metadata and passive clients + +Status: proposed + +English | [中文](2026-09-20-unified-typert-remote-metadata-and-passive-clients.zh.md) + +## Problem + +CLI help, argument parsing, and MCP tool discovery need local method descriptions, named-input parsers, tool JSON Schemas, and result metadata before connecting to a controlled Host. Browser, Node, and independent consumers also need typed invocations, while strict Hosts must enforce the same generated constraints. These requirements do not imply runtime TypeScript analysis, Host Service activation, or another business schema maintained by each consumer. + +The original #4644 [source-definition decision](../../implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.md) delivers this information through `emitSourceDef`, committed generated files, and `./remote-source`. Host bindings carry those definitions; source generation has its own inventory, freshness, publication, and exclusion rules. This note supersedes only that delivery mechanism. The original note remains intact because its passive-client and carrier-ownership decisions still apply. + +Both delivery paths derive from the same [compiler-independent model](../../implemented/architecture/2026-07-27-compiler-independent-typert-model.md). Separate Host and Client Programs require generated cross-face declarations, not committed generated source. An explicit prebuild is acceptable when exact schemas are needed; ordinary ESM artifacts can supply offline metadata without a live Host. The [targeted-call decision](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) still owns receiver lookup, Gateway dispatch, and contribution lifetimes. + +## Technical architecture + +| Export | Responsibility | Registration | +|---|---|---| +| Host `./typert` | `TYPERT`: local reflection, schemas, and an `invocations` getter exposing a private info instance's descriptors. | Loader imports and validates the contribution, then calls `ctx.typert.register(TYPERT)`. | +| Host-for-consumer `./remote` | Default export and `TYPERT_REMOTE`: a `TypertRemoteInfo` with descriptors and typed `methods`, plus generated Remote declarations/maps. | Harness metadata consumers call `ctx.typert.remotes.register(TYPERT_REMOTE)`; live consumers use `ctx.remote.$mount(TYPERT_REMOTE)`, which already registers it. Independent readers need no Registry. | +| Client `./client/typert` | The Client face's own local reflection and schemas. | Its local Registry; this is not the Host Remote projection. | + +[Registry](../../../../packages/typert/registry/src/service.ts) instances belong to their Cordis Context and process. Host and Client entries share implementation, not state. Existing local/Remote `get()` and `list()` preserve the registered descriptor objects; registration retains atomic duplicate checks, notifications, tombstones, and fiber-owned disposal. No Registry API, store, subscription, metadata RPC, or cross-process synchronization is added. Separate Programs and processes do not share object identity. + +The [emitter](../../../../packages/typert/generator/src/emitter.ts) writes one static `TypertRemoteMethodSpec` per endpoint and calls `createTypertRemoteInfo({ package, methods })` from the published pure `@deepseek-ai/dsh-typert-protocol/remote-info` entry. The endpoint key supplies `namespace` and `method`, and `id` combines it with the package. Each spec keeps the service, receiver, ordered parameters, single `result` codec, and optional stream, scope, uplink, cancellation, documentation, and source-location fields. Parameter `wire` defaults to `name`; parameter documentation stays on `parameters[].description`. There is no nested `metadata`, separate `output` or `parameterDescriptions`, or top-level `REMOTE_METHODS` export. + +The [factory](../../../../packages/typert/protocol/src/remote-info.ts) returns an actual class instance, not a class constructor or eager object literal. Construction retains the spec only. The first `descriptors` or `methods` read, including mounting or querying, builds and caches one shared index. Each `methods[endpoint]` aliases the exact direct descriptor object; Context-only receivers remain in descriptors and scoped declarations. Direct methods with scoped projections retain their scope fields but their generic named invoker requires an unscoped Remote. + +A direct method derives its named `input` from ordered parameter codecs and uses `result` for successful unary values or stream items, including binary `encode`/`decode`. The named Zod schema is built and cached at first `input.create()`; its JSON Schema projection is assembled and cached only when read. Individual generated Zod factories are also lazy and success-cached. The raw spec requires Zod factories internally, while consumer-facing `TypertSchema` promises only `parse(value: unknown): T`. JSON Schema remains an optional generator projection, not runtime `z.toJSONSchema()` conversion. Imports and metadata/schema access never invoke the network; the protocol root re-exports the factory too but imports Cordis. + +Registry queries return base descriptors. `isTypertRemoteMethod(descriptor)` from the pure leaf or protocol root narrows direct capabilities with `object`/`unknown` types; exact endpoint types come from `TYPERT_REMOTE.methods`, not dynamic lookup. Unary `mode` is omitted; streaming `mode` is `'stream'`. `invoke(remote, input, signal?)` resolves the mounted namespace method at call time, preserving its `this`, optional positional holes, and signal. It forwards the same full `Promise>` or `RemoteStreamHandle` without an async wrapper; stream iteration, `send`, `end`, and `dispose` remain available. + +Documentation and JSON Schema are optional. Unsupported projection records `jsonSchemaError` while preserving parsers and independent documentation; only the dedicated projection error is caught. A consumer explicitly selecting a tool requires its description and supported input JSON Schema. Output parsing remains required even when optional output JSON Schema is absent. Native `dsh-tools` registration additionally requires `output.schema`: a parser alone does not satisfy it. These consumer requirements must not prevent an otherwise valid Remote method from being generated. + +## Proposal + +Complete the ordinary-artifact migration in the downstream Session-client, CLI, MCP, and native-tool packages. This note remains proposed because the bounded integration does not complete that migration. + +### Shared generation, two access modes + +Framework consumers load ordinary metadata into their own Registry. Independent consumers may import `./remote` directly without Cordis, Host Service activation, or a connection. Both read the same generated business definition. Keep separate Host/Client Programs and analyze all contributors of each face so focused emission retains external declaration augmentations. Registration does not expose every reflected method as a callable endpoint or tool. + +Strict source startup explicitly prebuilds and loads ordinary `./typert` through Loader. Limited descriptor-free fallback still supports basic arguments, signals, cancellation, streams, JSON conversion, and unary byte attachments, but cannot recover erased fields, constraints, or tool documentation. Once a strict local descriptor has been observed, its withdrawal must refuse invocation rather than downgrade to fallback. `rejectUnknownFields` is an independent generation-only request-codec policy, not a runtime marker or a tool-selection flag. + +Host metadata readiness remains composition-owned. A Host requiring strict validation must await local registration before serving calls; `hasSeen()` does not protect an endpoint before its first registration. Returning a lazy info instance neither completes asynchronous Loader discovery nor establishes readiness. Loader validation must also avoid forcing derived input projections merely to register a contribution. + +Connection owns authenticated HTTP and physical WebSockets; Gateway owns framing and dispatch. `forwardedEvents: true` explicitly opts into Host event forwarding and interaction waterfalls. Otherwise `$on()` and event-supervised `$stream()` fail promptly, while generated streaming methods remain available. Importing, registering, listing tools, or calling methods does not acquire interaction-answer ownership. Node hooks are instance-owned and disposal is awaited. + +### Downstream migration targets + +The inspected #4111 tree contains Session-client, CLI, MCP, and native-tool consumers that are not present as complete packages on this branch. Its Session client selects 13 Session methods plus Workspace listing from source exports. Migrating that tree requires updating selection, connection-time mounts, offline CLI initialization, MCP presentation, and native-tool registration together; deleting source exports alone is insufficient. + +| Consumer | Metadata adaptation | Execution retained | +|---|---|---| +| Browser / Node Remote callers | Import ordinary `./remote`; mount once in the live Context. | Generated namespaces, Gateway, authenticated Connection, and target controllers. | +| Offline CLI help / parsing | Register before target discovery in the CLI profile's metadata Context. | No connection for help; a separate live Context mounts when calling. | +| MCP discovery / calls | Read selected Registry entries; retain consumer-owned tool descriptions and envelopes. | `tools/list` is offline; `tools/call` uses the same Session client. External MCP clients receive protocol JSON, not Registry modules. | +| Native tools | Read Host local method info; require the tool registration's `output.schema`. | Parse at the tool boundary, then call same-process controllers directly. | +| Independent adapters | Read typed `TYPERT_REMOTE.methods` directly. | Use their chosen Remote runtime only when invoking. | + +Current-session selection and attachment preparation remain consumer-owned; validate completed business arguments with generated parsers. The downstream `follow` adapter's `activate: false` policy applies where that API exists; this branch's current `follow` has no such option. Aborting observation does not cancel accepted Session work. Native direct dispatch also does not remove unrelated business dependencies such as the downstream controller's pending-interaction access through Gateway. + +### Paired usage examples + +The old examples are historical comparisons of source-export consumers in the dependent changes. The new examples use ordinary artifacts. These fragments omit surrounding plugin declarations and include removed APIs; the linked consumer test owns compilation of the complete ordinary-artifact adapter. `raw`, `request`, `signal`, and an authenticated, mounted, unscoped `remote` are caller inputs, not new framework APIs. + +**Operation owner and strict Host.** Preserve the business signature; leave generated registration to ordinary Typert Loader instead of the Service constructor. + +```ts ignore-check +// Old source delivery. +import { TYPERT_REMOTE } from './remote.generated.ts' +// Constructor: super(ctx, 'sessionController', { namespace: 'session', definitions: TYPERT_REMOTE }) +@Remote({ name: 'page', emitSourceDef: true, rejectUnknownFields: true }) +page(request: SessionPageRequest, signal: AbortSignal): Promise { + return this.history.page(request, signal) +} + +// Ordinary delivery; no generated-source import or constructor definitions. +// Constructor: super(ctx, 'sessionController', { namespace: 'session' }) +@Remote({ name: 'page', rejectUnknownFields: true }) +page(request: SessionPageRequest, signal: AbortSignal): Promise { + return this.history.page(request, signal) +} +``` + +**Browser and Node invocation.** A source-export consumer changes `/remote-source` to `/remote`; existing ordinary consumers already use this path. The live Context mounts once, without separate Registry registration. + +```ts ignore-check +import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' +await ctx.remote.$mount(TYPERT_REMOTE) +const result = await ctx.remote.session.page(request, signal) +const stream = ctx.remote.session.follow(followRequest, signal) +try { + for await (const frame of stream) consume(frame) +} finally { + await stream.dispose() +} +``` + +**Offline CLI and framework metadata.** The old selection reads a source table; the replacement reads the profile-owned Registry before target discovery. The two snippets are alternatives, not simultaneous declarations. + +```ts ignore-check +// Old selection. +import { REMOTE_METHODS } from '@deepseek-ai/dsh-api-session-controller/remote-source' +const page = REMOTE_METHODS['session/page'] +``` + +```ts ignore-check +// Metadata plugin: inject: ['typert']; no Gateway, Connection, or Host controller. +import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' +import { isTypertRemoteMethod } from '@deepseek-ai/dsh-typert-protocol/remote-info' +ctx.typert.remotes.register(TYPERT_REMOTE) +const page = ctx.typert.remotes.get('session/page') +if (page === undefined || !isTypertRemoteMethod(page)) { + throw new Error('session/page generated direct method is not loaded') +} +``` + +The registration belongs to that plugin's fiber. Help uses `description` and `input`; the typed facade can retain type-only imports from ordinary generated declarations. Explicit tool selection does not mutate registered metadata. + +**CLI input and dispatch.** After local `current` resolution and attachment preparation, either metadata access path uses the same invocation sequence: + +```ts ignore-check +if (page.mode === 'stream') throw new Error('session/page must be unary') +const arguments_ = page.input.create().parse(raw) +const result = await page.invoke(remote, arguments_, signal) +if (!result.ok) throw result.error +const output = page.result.create().parse(result.value) +``` + +**MCP discovery and calls.** Selection validates tool-specific requirements; generation itself does not require them. + +```ts ignore-check +if (!page.description) throw new Error('session/page has no tool description') +if (page.input.jsonSchema === undefined) throw new Error('session/page has no supported tool input schema') +const tool = { + name: 'remote_session_page', + description: page.description, + inputSchema: page.input.jsonSchema, + ...(page.result.jsonSchema === undefined ? {} : { outputSchema: page.result.jsonSchema }), +} +``` + +This compact object shows only the metadata dependency. The real MCP adapter retains its `{ arguments, current, content }` input and `{ ok, value }` / failure output envelopes. Listing requires no connection; calling performs intake, local preparation, generated parsing, invocation, and output parsing. Optional output projection does not remove output validation. + +**Native tools.** Query `ctx.typert.local.get('session/page')` and narrow it with `isTypertRemoteMethod` instead of reading the source table. Require supported input and result projections for native registration, including its mandatory `output.schema`; optional MCP output projection is not sufficient here. Once the facade obtains a typed request, both designs execute the same local call: + +```ts ignore-check +const value = await ctx.sessionController.page(request, signal) +``` + +**Independent consumers.** Read the ordinary info instance's typed index; no Registry is required: + +```ts ignore-check +import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' +const page = TYPERT_REMOTE.methods['session/page'] +const arguments_ = page.input.create().parse(raw) +const result = await page.invoke(remote, arguments_, signal) +``` + +**Strict source startup.** Build ordinary artifacts, then use the supported source launcher: + +```sh +pnpm run build +pnpm dsh --profile +``` + +### Bounded reproduction on this branch + +The test-local [Session client](../../../../packages/api/remotes/tests/fixtures/session-client/client.ts) and [MCP adapter](../../../../packages/api/remotes/tests/fixtures/session-client/mcp.ts) extract the downstream access pattern for current `session/page` and `session/follow`. They use Registry metadata, current-session injection, explicit unsupported-content rejection, generic parsers, and ordinary invocation. They do not add future controller operations, a public package/bin, an alternate application launcher, or the complete downstream application. + +The [artifact test](../../../../packages/api/remotes/tests/session-client.e2e.ts) compiles only its local consumer fixture against ordinary declarations, then runs plain Node. Its [runtime fixture](../../../../packages/api/remotes/tests/fixtures/session-client/run.mjs) composes the real SessionController, live Session/query services, Gateway, and Connection over HTTP/WebSocket. This is a manually assembled bounded integration, not a Loader/profile composition or the complete CLI/MCP protocol server. The separate Loader test uses an authored artifact fixture; the strict-source Gateway test uses an authored ordinary contribution. Neither is represented as generated full-application evidence. + +## Alternatives considered + +- **Committed source:** meets offline needs but adds a redundant delivery and maintenance path; explicit ordinary prebuild is sufficient. +- **Nested descriptor metadata and a separate method table:** duplicate result and parameter facts and add another identity to track. One spec derives direct helpers on the descriptor itself; `TypertRemoteInfo` extends the existing contribution and retains `descriptors` so Gateway and Registry keep their current registration APIs. +- **Mandatory Registry for every importer:** adds lifecycle requirements without information for standalone consumers. +- **Fallback only:** cannot supply erased validation or tool documentation; retain it only for its supported subset. +- **Handwritten adapter schemas or runtime TypeScript analysis:** duplicate ownership or introduce runtime compiler requirements already handled by build-time extraction. +- **Automatic forwarded events:** conflates method calls with willingness to answer user interactions. + +## Acceptance criteria + +- Ordinary artifacts emit one static method spec per endpoint and return lazy class instances; direct method indexes alias descriptor objects, with full stream/binary typing, separate compiler faces, and full-face augmentation retention. +- Descriptor indexes, named-input schemas, and derived projections materialize only when requested and cache their results; Loader validation does not force derived input projections. +- Registry identity, atomic registration, withdrawal, and disposal remain intact; metadata inspection and method calls stay passive. +- Missing documentation or unsupported optional projection does not disable generation; selected tools reject missing requirements and still parse outputs. +- Strict source/artifact invocation and limited fallback have separate tests; a withdrawn strict descriptor never enables fallback. +- The bounded downstream fixture exercises offline selection, real unary/stream calls, input rejection, cancellation, and teardown with current artifacts. +- Source static checks do not consume stale generated declarations; artifact checks declare and rebuild their inputs. No source-definition opt-in, source export, inventory, or freshness rule remains in production. + +## Risks + +Full downstream profile composition remains follow-up work in those packages; the bounded reproduction is not evidence that every #4111 operation or CLI/MCP application has migrated. The deliberate trade-off is an explicit prebuild for full source-runtime metadata. Missing or stale artifacts require regeneration, not a validation downgrade or a second handwritten schema. diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.zh.md b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.zh.md new file mode 100644 index 0000000000..b8b9713133 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.zh.md @@ -0,0 +1,195 @@ +# Agent Note: 统一 Typert/Remote 元数据与被动客户端 + +Status: proposed + +[English](2026-09-20-unified-typert-remote-metadata-and-passive-clients.md) | 中文 + +## Problem + +CLI 帮助、参数解析和 MCP 工具发现需要在连接受控 Host 前取得本地方法说明、具名输入解析器、工具 JSON Schema 和结果元数据。Browser、Node 与独立消费方还需要类型化调用,而严格 Host 必须执行相同的生成约束。这些需求不意味着运行时 TypeScript 分析、Host Service 激活,或每个消费方另行维护业务 schema。 + +#4644 原有的[源码定义决策](../../implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.zh.md)通过 `emitSourceDef`、提交的生成文件和 `./remote-source` 交付这些信息。Host 绑定携带定义;源码生成另有清单、新鲜度、发布和排除规则。本 note 只替代该交付机制。原 note 保持不变,因为其中的被动客户端与载体所有权决策仍然适用。 + +两种交付路径均来自同一个[编译器无关模型](../../implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md)。独立的 Host 与 Client Program 要求生成跨编译面声明,而非提交生成源码。需要精确 schema 时,显式预构建是可接受的;普通 ESM 产物无需运行中的 Host 即可提供离线元数据。[定向调用决策](../../implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)仍负责接收者查找、Gateway 分发和贡献生命周期。 + +## 技术架构 + +| 导出 | 职责 | 注册方式 | +|---|---|---| +| Host `./typert` | `TYPERT`:本地反射、schema,以及暴露私有 info 实例描述符的 `invocations` getter。 | Loader 导入并验证 contribution,再调用 `ctx.typert.register(TYPERT)`。 | +| 面向消费方的 Host `./remote` | 默认导出和 `TYPERT_REMOTE`:带有描述符和类型化 `methods` 的 `TypertRemoteInfo`,以及生成的 Remote 声明/映射。 | Harness 元数据消费方调用 `ctx.typert.remotes.register(TYPERT_REMOTE)`;实际调用方使用 `ctx.remote.$mount(TYPERT_REMOTE)`,它已经包含注册。独立读取方无需 Registry。 | +| Client `./client/typert` | Client 编译面自己的本地反射与 schema。 | 本地 Registry;这不是 Host Remote 投影。 | + +[Registry](../../../../packages/typert/registry/src/service.ts) 实例属于各自的 Cordis Context 和进程。Host 与 Client 入口共享实现,而非状态。既有本地/Remote `get()` 与 `list()` 保留已注册的描述符对象;注册保留原子重复检查、通知、tombstone 和 fiber 管理的释放。不新增 Registry API、存储、订阅、元数据 RPC 或跨进程同步。独立 Program 和进程不共享对象身份。 + +[发射器](../../../../packages/typert/generator/src/emitter.ts)为每个端点写入一份静态 `TypertRemoteMethodSpec`,并调用已发布纯入口 `@deepseek-ai/dsh-typert-protocol/remote-info` 提供的 `createTypertRemoteInfo({ package, methods })`。端点键提供 `namespace` 和 `method`,`id` 由包名与端点键组合。每份 spec 保留 service、接收者、有序参数、唯一的 `result` codec,以及可选的流模式、scope、uplink、取消、文档和源码位置字段。参数 `wire` 默认使用 `name`;参数文档保留在 `parameters[].description`。不存在嵌套 `metadata`、独立 `output` 或 `parameterDescriptions`,也不导出顶层 `REMOTE_METHODS`。 + +[factory](../../../../packages/typert/protocol/src/remote-info.ts)返回真正的类实例,而非类构造函数或立即求值的对象字面量。构造时只保留 spec。首次读取 `descriptors` 或 `methods` 时(包括挂载或查询时)才构建并缓存一个共享索引。每个 `methods[endpoint]` 就是同一个直接方法描述符对象;仅使用 Context 接收者的方法保留在描述符和作用域声明中。带作用域投影的直接方法保留 scope 字段,但其通用具名调用器要求无作用域 Remote。 + +直接方法从有序参数 codec 派生具名 `input`,并使用 `result` 表示成功一元值或流条目,包括二进制 `encode`/`decode`。具名 Zod schema 在首次 `input.create()` 时构建并缓存;其 JSON Schema 投影只在读取时组装并缓存。生成的独立 Zod factory 同样惰性求值且只缓存成功结果。原始 spec 内部要求 Zod factory,而面向消费方的 `TypertSchema` 只承诺 `parse(value: unknown): T`。JSON Schema 仍是可选的生成器投影,而非运行时 `z.toJSONSchema()` 转换。导入和元数据/schema 读取绝不发起网络调用;协议根入口也重新导出 factory,但会导入 Cordis。 + +Registry 查询返回基础描述符。纯叶入口或协议根入口的 `isTypertRemoteMethod(descriptor)` 以 `object`/`unknown` 类型收窄直接调用能力;精确端点类型来自 `TYPERT_REMOTE.methods`,而非动态查询。一元 `mode` 省略;流式 `mode` 为 `'stream'`。`invoke(remote, input, signal?)` 在调用时解析已挂载的命名空间方法,保留其 `this`、可选位置参数空位和 signal。它不使用 async 包装,直接转发同一个完整 `Promise>` 或 `RemoteStreamHandle`;流迭代、`send`、`end` 和 `dispose` 均保留。 + +文档和 JSON Schema 可选。不支持的投影记录 `jsonSchemaError`,同时保留解析器及独立文档;只捕获专用投影错误。显式选择工具的消费方要求工具说明和受支持的输入 JSON Schema。即使缺少可选输出 JSON Schema,输出解析仍然必需。原生 `dsh-tools` 注册额外要求 `output.schema`:仅有解析器并不满足要求。这些消费方要求不得阻止本来有效的 Remote 方法生成。 + +## Proposal + +在下游 Session-client、CLI、MCP 和原生工具包中完成普通产物迁移。本 note 保持 proposed,因为有限集成并未完成该迁移。 + +### 共享生成,两种访问方式 + +框架消费方将普通元数据加载到自己的 Registry。独立消费方可以直接导入 `./remote`,无需 Cordis、Host Service 激活或连接。两者读取同一份生成业务定义。保留独立的 Host/Client Program,并分析每个编译面的所有贡献方,使局部生成保留外部声明合并。注册不会将每个反射方法都暴露为可调用 endpoint 或工具。 + +严格源码启动显式预构建,并通过 Loader 加载普通 `./typert`。有限的无 descriptor fallback 仍支持基本参数、signal、取消、流式调用、JSON 转换和一元字节附件,但无法恢复被擦除的字段、约束或工具文档。一旦观察到严格的本地 descriptor,撤销后必须拒绝调用,而不是降级到 fallback。`rejectUnknownFields` 是独立的、仅生成期使用的请求 codec 策略,不是运行时 marker 或工具选择标志。 + +Host 元数据就绪仍由组合负责。要求严格校验的 Host 必须等待本地注册完成后再接受调用;`hasSeen()` 不会保护首次注册之前的端点。返回惰性 info 实例既不完成异步 Loader 发现,也不建立就绪保证。Loader 校验同样不得仅为注册贡献就强制计算派生输入投影。 + +Connection 拥有认证 HTTP 和物理 WebSocket;Gateway 拥有帧和分发。`forwardedEvents: true` 显式选择 Host 事件转发和交互 waterfall(瀑布式事件)。否则 `$on()` 和由事件监督的 `$stream()` 立即失败,而生成的流式方法仍然可用。导入、注册、列举工具或调用方法不会取得交互回答所有权。Node 钩子由实例拥有,释放必须等待完成。 + +### 下游迁移目标 + +已检查的 #4111 树包含 Session-client、CLI、MCP 和原生工具消费方,当前分支并不包含这些完整 package。其 Session client 从源码导出选择 13 个 Session 方法及 Workspace 列举。迁移该树需要一并更新选择、连接时挂载、离线 CLI 初始化、MCP 呈现及原生工具注册;仅删除源码导出并不足够。 + +| 消费方 | 元数据适配 | 保留的执行方式 | +|---|---|---| +| Browser / Node Remote 调用方 | 导入普通 `./remote`;在实际调用 Context 中挂载一次。 | 生成 namespace、Gateway、认证 Connection 和目标 controller。 | +| 离线 CLI 帮助 / 解析 | 在 CLI profile 的元数据 Context 中,于目标发现之前注册。 | 帮助不连接;调用时由独立的实际调用 Context 挂载。 | +| MCP 发现 / 调用 | 读取选中的 Registry 条目;保留消费方自有工具说明和 envelope。 | `tools/list` 离线;`tools/call` 使用相同 Session client。外部 MCP 客户端收到协议 JSON,而不是 Registry 模块。 | +| 原生工具 | 读取 Host 本地方法信息;要求工具注册的 `output.schema`。 | 在工具边界解析,然后直接调用同进程 controller。 | +| 独立适配器 | 直接读取类型化 `TYPERT_REMOTE.methods`。 | 仅调用时使用其选择的 Remote 运行时。 | + +当前 Session 选择和附件准备仍由消费方拥有;用生成解析器验证补全后的业务参数。下游 `follow` 适配器的 `activate: false` 策略只适用于具有该 API 的版本;当前分支的 `follow` 没有此选项。中止观察不等于取消已接受的 Session 工作。原生直接分发也不会移除无关的业务依赖,例如下游 controller 通过 Gateway 访问待处理交互。 + +### 新旧接入示例 + +旧示例是依赖改动中源码导出消费方的历史对照。新示例使用普通产物。这些片段省略外围插件声明,且包含已移除的 API;下方链接的消费方测试负责完整普通产物适配器的编译。`raw`、`request`、`signal` 和已认证、已挂载、无作用域的 `remote` 是调用方输入,不是新增框架 API。 + +**操作拥有者与严格 Host。** 保留业务签名;将生成定义注册交给普通 Typert Loader,而非 Service 构造函数。 + +```ts ignore-check +// Old source delivery. +import { TYPERT_REMOTE } from './remote.generated.ts' +// Constructor: super(ctx, 'sessionController', { namespace: 'session', definitions: TYPERT_REMOTE }) +@Remote({ name: 'page', emitSourceDef: true, rejectUnknownFields: true }) +page(request: SessionPageRequest, signal: AbortSignal): Promise { + return this.history.page(request, signal) +} + +// Ordinary delivery; no generated-source import or constructor definitions. +// Constructor: super(ctx, 'sessionController', { namespace: 'session' }) +@Remote({ name: 'page', rejectUnknownFields: true }) +page(request: SessionPageRequest, signal: AbortSignal): Promise { + return this.history.page(request, signal) +} +``` + +**Browser 与 Node 调用。** 源码导出消费方将 `/remote-source` 改为 `/remote`;已有普通消费方本来就使用此路径。实际调用 Context 挂载一次,不额外向 Registry 注册。 + +```ts ignore-check +import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' +await ctx.remote.$mount(TYPERT_REMOTE) +const result = await ctx.remote.session.page(request, signal) +const stream = ctx.remote.session.follow(followRequest, signal) +try { + for await (const frame of stream) consume(frame) +} finally { + await stream.dispose() +} +``` + +**离线 CLI 与框架元数据。** 旧选择读取源码方法表;替代方式在目标发现之前读取 profile 拥有的 Registry。两个片段是不同选择,不是同时声明。 + +```ts ignore-check +// Old selection. +import { REMOTE_METHODS } from '@deepseek-ai/dsh-api-session-controller/remote-source' +const page = REMOTE_METHODS['session/page'] +``` + +```ts ignore-check +// Metadata plugin: inject: ['typert']; no Gateway, Connection, or Host controller. +import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' +import { isTypertRemoteMethod } from '@deepseek-ai/dsh-typert-protocol/remote-info' +ctx.typert.remotes.register(TYPERT_REMOTE) +const page = ctx.typert.remotes.get('session/page') +if (page === undefined || !isTypertRemoteMethod(page)) { + throw new Error('session/page generated direct method is not loaded') +} +``` + +注册属于该插件的 fiber。帮助使用 `description` 和 `input`;类型化 facade 可以保留对普通生成声明的 type-only 导入。显式工具选择不修改已注册元数据。 + +**CLI 输入与分发。** 本地 `current` 解析和附件准备之后,两种元数据访问方式使用同样的调用步骤: + +```ts ignore-check +if (page.mode === 'stream') throw new Error('session/page must be unary') +const arguments_ = page.input.create().parse(raw) +const result = await page.invoke(remote, arguments_, signal) +if (!result.ok) throw result.error +const output = page.result.create().parse(result.value) +``` + +**MCP 发现与调用。** 选择时验证工具特有要求;生成过程本身不要求它们。 + +```ts ignore-check +if (!page.description) throw new Error('session/page has no tool description') +if (page.input.jsonSchema === undefined) throw new Error('session/page has no supported tool input schema') +const tool = { + name: 'remote_session_page', + description: page.description, + inputSchema: page.input.jsonSchema, + ...(page.result.jsonSchema === undefined ? {} : { outputSchema: page.result.jsonSchema }), +} +``` + +这个简化对象只展示元数据依赖。真实 MCP 适配器保留 `{ arguments, current, content }` 输入及 `{ ok, value }` / 失败输出 envelope。列举无需连接;调用执行输入接收、本地准备、生成解析、调用及输出解析。可选输出投影不会移除输出验证。 + +**原生工具。** 查询 `ctx.typert.local.get('session/page')`,并用 `isTypertRemoteMethod` 收窄,而非读取源码方法表。原生注册要求受支持的输入与结果投影,包括必需的 `output.schema`;MCP 的可选输出投影在这里并不足够。facade 取得类型化请求后,两种设计执行同样的本地调用: + +```ts ignore-check +const value = await ctx.sessionController.page(request, signal) +``` + +**独立消费方。** 读取普通 info 实例的类型化索引,不要求 Registry: + +```ts ignore-check +import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' +const page = TYPERT_REMOTE.methods['session/page'] +const arguments_ = page.input.create().parse(raw) +const result = await page.invoke(remote, arguments_, signal) +``` + +**严格源码启动。** 构建普通产物,再使用受支持的源码启动器: + +```sh +pnpm run build +pnpm dsh --profile +``` + +### 当前分支上的有限复现 + +测试专用 [Session client](../../../../packages/api/remotes/tests/fixtures/session-client/client.ts) 和 [MCP 适配器](../../../../packages/api/remotes/tests/fixtures/session-client/mcp.ts)为当前 `session/page` 与 `session/follow` 提取下游访问方式。它们使用 Registry 元数据、当前 Session 注入、显式拒绝不支持的 content、通用解析器和普通调用。不添加未来 controller 操作、公开 package/bin、另一套应用启动器或完整下游应用。 + +[产物测试](../../../../packages/api/remotes/tests/session-client.e2e.ts)仅将本地消费方 fixture 对普通声明编译,再使用普通 Node 运行。[运行时 fixture](../../../../packages/api/remotes/tests/fixtures/session-client/run.mjs)将真实 SessionController、活 Session/query 服务、Gateway 和 Connection 通过 HTTP/WebSocket 组合。这是手动组装的有限集成,不是 Loader/profile 组合或完整 CLI/MCP 协议服务器。独立的 Loader 测试使用手写产物 fixture;严格源码 Gateway 测试使用手写普通 contribution。两者都不被表述为生成的完整应用验证。 + +## Alternatives considered + +- **提交源码:** 满足离线需求,但增加冗余交付和维护路径;显式普通预构建已经足够。 +- **嵌套描述符元数据与独立方法表:** 重复结果和参数事实,并增加需要跟踪的对象身份。一份 spec 直接在描述符上派生调用辅助能力;`TypertRemoteInfo` 扩展既有 contribution 并保留 `descriptors`,让 Gateway 与 Registry 沿用现有注册 API。 +- **所有导入方强制使用 Registry:** 为独立消费方增加生命周期要求,却不增加信息。 +- **仅用 fallback:** 无法提供被擦除的验证或工具文档;仅为受支持子集保留。 +- **手写适配器 schema 或运行时 TypeScript 分析:** 重复承担所有权,或引入构建期提取已解决的运行时编译器要求。 +- **自动转发事件:** 将方法调用与愿意回答用户交互混为一谈。 + +## Acceptance criteria + +- 普通产物为每个端点生成一份静态方法 spec,并返回惰性类实例;直接方法索引引用描述符对象,保留完整流式/二进制类型、独立编译面及完整编译面的声明合并。 +- 描述符索引、具名输入 schema 和派生投影仅在请求时物化并缓存结果;Loader 校验不强制计算派生输入投影。 +- Registry 身份、原子注册、撤销和释放保持不变;元数据检视和方法调用保持被动。 +- 缺少文档或不支持可选投影不阻止生成;选中的工具拒绝缺少必需信息的条目,并仍然解析输出。 +- 严格源码/产物调用与有限 fallback 分别测试;撤销严格 descriptor 绝不启用 fallback。 +- 有限下游 fixture 使用当前产物覆盖离线选择、真实一元/流式调用、输入拒绝、取消和释放。 +- 源码静态检查不消费过期生成声明;产物检查声明并重建输入。生产代码不保留源码定义 opt-in、源码导出、清单或新鲜度规则。 + +## Risks + +完整下游 profile 组合仍需在相应 package 中继续完成;有限复现不代表每个 #4111 操作或 CLI/MCP 应用都已迁移。明确接受的取舍是完整源码运行时元数据需要显式预构建。缺失或过期产物要求重新生成,而不是降低验证或另建一套手写 schema。 diff --git a/.oxlintrc.json b/.oxlintrc.json index f52fdb1e84..89c2805c1b 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -21,6 +21,7 @@ "**/.oxlint-contract-*/**", // Scratch files created by the executable lint-contract tests. "**/oxlint-contract-*", // Flat probes use real TypeScript project include paths. "packages/typert/generator/tests/fixtures/type-model/**", // tsgolint rejects this fixture's preserved project shapes before rules run. + "packages/api/remotes/tests/fixtures/session-client/**", // Artifact-only consumer; compiled by the build-dependent smoke, not source static gates. "website/.generated/**", "vendor/**", // Vendored source keeps upstream style and idioms. "packages/experimental/claude-code-mods/examples/*/{hooks,tests,types}/**", // Claude Code's published example mods and their tests keep the upstream style; see the headers. diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index e58ee67be0..621169710c 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -16,6 +16,7 @@ import { afterEach, beforeEach, vi } from 'vitest' import { bootInjections, orderByModuleGraph } from '@deepseek-ai/dsh-client-modules' import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '@deepseek-ai/dsh-client-modules/client' import type { RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import { GATEWAY_CLIENT_CONFIG_GLOBAL } from '@deepseek-ai/dsh-api-gateway/stream-protocol' import { AppWebEntry } from '@deepseek-ai/dsh-client-web' import { createAssembledRemote, type AssembledRemote, type AssembledRemoteOptions, @@ -281,6 +282,8 @@ export function mountAssembledApp(options: AssembledBootOptions = {}): Assembled const remote = createAssembledRemote(options.remote) mountedRemote = remote.mock win.__DSH_TRANSPORT__ = { rpc: remote.mock.rpc } + // The fixture supplies the Web Host's explicit forwarded-event opt-in. + vi.stubGlobal(GATEWAY_CLIENT_CONFIG_GLOBAL, { forwardedEvents: true }) history.replaceState(null, '', '/') const root = document.createElement('div') root.id = 'root' diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d04b96eaa4..358315fc64 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -191,6 +191,11 @@ export interface Config { readonly websocketHeartbeatIntervalMs?: number /** Buffered uplink frame bytes one logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */ readonly streamInboxBytes?: number + /** + * Served pages receive forwarded Host events, including Agent-scoped approval and question + * requests; their Gateway Client then answers or delegates those requests. @default false + */ + readonly forwardedEvents?: boolean } ``` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index e9586906a6..eb53e67501 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -193,6 +193,11 @@ export interface Config { readonly websocketHeartbeatIntervalMs?: number /** Buffered uplink frame bytes one logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */ readonly streamInboxBytes?: number + /** + * Served pages receive forwarded Host events, including Agent-scoped approval and question + * requests; their Gateway Client then answers or delegates those requests. @default false + */ + readonly forwardedEvents?: boolean } ``` diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index a74d05084d..9b9832b652 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.i18n.yaml @@ -9,8 +9,8 @@ en: 8725fa62f36364d7 zh: 0f2ff4bdf13ff55b /typert-remote-calls/invocation-descriptors: - en: 6417ffb792fd5385 - zh: c012707075a0b21f + en: 47102028a89bcaf3 + zh: 4e1692c92cd7906a /typert-remote-calls/typert-registry: en: 29f578ba733cfd5a zh: c486ff4ac2d1e3b4 @@ -18,5 +18,5 @@ en: 90fab88fe51d17bb zh: 2d4c3849cf2dc7d3 /typert-remote-calls/consumer-remote: - en: 4836f8f44fef8fd3 - zh: 51f3ab2462a08ff4 + en: 37f48bfcc9e3e0d1 + zh: 45509a69a67143ad diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index ae2d32df49..461852b7ac 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -40,27 +40,31 @@ interface TypertLookupDefinition { An `InvocationDescriptor` is local reflection, not a wire message. Host and consumer builds generate corresponding descriptors; the request sends only the endpoint and named `args`. Strict codecs carry generated schema factories, while SRC codecs enforce JSON-safe inputs without structural type recovery. Unary result codecs can supply `encode()` for byte-containing subtrees and `decode()` for recursive native-byte validation; Client declarations narrow every `Uint8Array` to `ArrayBuffer` backing. Pure JSON results need neither byte detection nor Client decoding. Cancellation is an out-of-band carrier signal injected after business parameters and never enters `args`. +`createTypertRemoteInfo` returns a lazy class instance implementing `TypertRemoteInfo`, which extends the mountable `TypertRemoteContribution`. Its `methods` index aliases the direct objects in `descriptors`: each `TypertRemoteMethod` adds named `input` and `invoke`, retains documentation on the method and its parameters, and uses `result` as its only output codec. Unary `mode` is absent; stream `mode` is `'stream'`. Context-only receivers remain in descriptors and scoped declarations. Registry queries return base descriptors; `isTypertRemoteMethod` narrows generated direct capabilities without recovering endpoint-specific generics. + +`TypertSchema` guarantees parsing, not arbitrary Zod operations, although the raw factory spec requires Zod factories. Missing documentation or an unsupported optional JSON Schema projection does not invalidate an ordinary method. The info index, named-input schema, and derived projections are cached at their respective first accesses; no metadata or schema access invokes the network. The [protocol reference](<../../packages/typert/protocol/README.md>) defines these APIs, and the [generator reference](<../../packages/typert/generator/README.md#ordinary-remote-metadata>) defines their separate Host `./typert`, consumer `./remote`, and Client `./client/typert` artifacts. + +`TypertRemoteSpec` holds a package and endpoint-keyed `TypertRemoteMethodSpec` values. Ordered `TypertRemoteParameterSpec` entries contain strict `TypertRemoteCodecSpec` factories; the runtime derives identities, wire-name defaults, and named-call views from these facts. `TypertRemoteValue` exposes parsing and optional documentation/projection, while `RemoteDecoded` describes carrier-reconstructed unary results without narrowing arbitrary parser outputs. + ```ts type-equiv /** Codec attached to one invocation parameter or result. */ -type TypertCodec = - | { +type TypertCodec = + | TypertRemoteValue & { readonly mode: 'strict' readonly typeSymbol: string - /** Materialize and return the process-realm schema on first boundary use. */ - readonly create: () => TypertSchema /** * Decode a unary result whose fields require type-specific handling. * @param value - result reconstructed by the RPC carrier. * @returns the validated result, retaining native byte views. */ - readonly decode?: (value: unknown) => unknown + readonly decode?: ((value: unknown) => unknown) | undefined /** * Project typed binary fields into RPC result attachments. * @param value - native unary result. * @param writeBytes - records a byte view at its result-relative path and returns its JSON placeholder. * @returns JSON metadata with untouched JSON subtrees retained. */ - readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown + readonly encode?: ((value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown) | undefined } | { readonly mode: 'src-json' @@ -72,6 +76,8 @@ type TypertCodec = interface InvocationParameterDescriptor { /** Source-level parameter name. */ readonly name: string + /** Source parameter documentation, independent of JSON Schema support. */ + readonly description?: string /** Required key in the wire `args` object. */ readonly wire: string /** Whether the value is JSON or requires a registered Host lookup. */ @@ -135,6 +141,8 @@ interface InvocationDescriptor { } /** Codec for the unary result or each yielded stream item. */ readonly result: TypertCodec + /** Source-owned method documentation; ordinary methods need not have JSDoc. */ + readonly description?: string /** Source declaration used only for diagnostics. */ readonly sourceLocation?: InvocationSourceLocation } @@ -364,6 +372,8 @@ interface TypertGateway { `ctx.remote` exposes only namespaces contributed by imported `/remote` artifacts. `$mount()` installs generated descriptors and concrete methods as one fiber-owned operation. Each namespace is a traced `remote.` Cordis child Service whose lifetime spans its mounted methods; no JavaScript Proxy or Host business Service type enters the consumer. +Metadata ownership is separate from connection ownership; the [registry reference](<../../packages/typert/registry/README.md>) covers offline registration and mount lifetimes. Importing, registering, listing, or calling Remote methods does not acquire interaction-waterfall ownership. Gateway Clients remain passive unless the connection opts into Host events with `forwardedEvents: true`; the Web bundle publishes that choice to served pages through the Host Gateway row. + ```ts type-equiv /** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */ interface TypertClientRemote extends TypertRemoteNamespaceMap { diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index 18a46c7083..d3ace78010 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -40,27 +40,31 @@ interface TypertLookupDefinition { `InvocationDescriptor` 是本地反射信息,不是 wire message。Host 与消费方构建会生成彼此对应的 descriptor;请求只发送 endpoint 与具名 `args`。strict codec 携带生成的 schema factory,SRC codec 则在不恢复结构类型的前提下强制要求输入为 JSON 安全值。一元结果 codec 可提供 `encode()` 处理含字节的子树,并通过 `decode()` 递归校验原生字节;Client 声明将每个 `Uint8Array` 限定为以 `ArrayBuffer` 为底层缓冲区。纯 JSON 结果无需字节识别或 Client 解码。取消通过带外 carrier signal 表达:它在业务参数之后注入,绝不进入 `args`。 +`createTypertRemoteInfo` 返回实现 `TypertRemoteInfo` 的惰性类实例,后者扩展可挂载的 `TypertRemoteContribution`。其 `methods` 索引引用 `descriptors` 中的直接方法对象:每个 `TypertRemoteMethod` 增加具名 `input` 和 `invoke`,在方法及参数上保留文档,并以 `result` 作为唯一输出 codec。一元 `mode` 缺省;流式 `mode` 为 `'stream'`。仅使用 Context 接收器的方法保留在描述符和作用域声明中。Registry 查询返回基础描述符;`isTypertRemoteMethod` 收窄生成的直接调用能力,但不会恢复各端点的精确泛型。 + +`TypertSchema` 保证解析能力,不保证任意 Zod 操作,尽管原始 factory spec 要求 Zod factory。缺少文档或不支持可选的 JSON Schema 投影,不会使普通方法失效。info 索引、具名输入 schema 和派生投影分别在首次访问时缓存;读取元数据或 schema 绝不发起网络调用。[协议参考](<../../packages/typert/protocol/README.zh.md>)定义这些 API,[生成器参考](<../../packages/typert/generator/README.zh.md#ordinary-remote-metadata>)定义它们彼此分离的 Host `./typert`、消费方 `./remote` 和 Client `./client/typert` 产物。 + +`TypertRemoteSpec` 保存包名与按端点键控的 `TypertRemoteMethodSpec`。有序的 `TypertRemoteParameterSpec` 条目包含严格的 `TypertRemoteCodecSpec` factory;运行时从这些事实派生标识、默认 wire 名称和具名调用视图。`TypertRemoteValue` 提供解析及可选文档/投影,`RemoteDecoded` 则描述 carrier 重建的一元结果,不会收窄任意解析器输出。 + ```ts type-equiv /** Codec attached to one invocation parameter or result. */ -type TypertCodec = - | { +type TypertCodec = + | TypertRemoteValue & { readonly mode: 'strict' readonly typeSymbol: string - /** Materialize and return the process-realm schema on first boundary use. */ - readonly create: () => TypertSchema /** * Decode a unary result whose fields require type-specific handling. * @param value - result reconstructed by the RPC carrier. * @returns the validated result, retaining native byte views. */ - readonly decode?: (value: unknown) => unknown + readonly decode?: ((value: unknown) => unknown) | undefined /** * Project typed binary fields into RPC result attachments. * @param value - native unary result. * @param writeBytes - records a byte view at its result-relative path and returns its JSON placeholder. * @returns JSON metadata with untouched JSON subtrees retained. */ - readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown + readonly encode?: ((value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown) | undefined } | { readonly mode: 'src-json' @@ -72,6 +76,8 @@ type TypertCodec = interface InvocationParameterDescriptor { /** Source-level parameter name. */ readonly name: string + /** Source parameter documentation, independent of JSON Schema support. */ + readonly description?: string /** Required key in the wire `args` object. */ readonly wire: string /** Whether the value is JSON or requires a registered Host lookup. */ @@ -135,6 +141,8 @@ interface InvocationDescriptor { } /** Codec for the unary result or each yielded stream item. */ readonly result: TypertCodec + /** Source-owned method documentation; ordinary methods need not have JSDoc. */ + readonly description?: string /** Source declaration used only for diagnostics. */ readonly sourceLocation?: InvocationSourceLocation } @@ -364,6 +372,8 @@ interface TypertGateway { `ctx.remote` 只暴露由已导入 `/remote` 产物贡献的 namespace。`$mount()` 会把生成的 descriptor 与具体方法作为一项由 fiber 持有的操作统一注册。每个 namespace 都是可追踪的 `remote.` Cordis 子服务,其生命周期覆盖已挂载的方法;JavaScript Proxy 与 Host 业务服务类型都不会进入消费方。 +元数据所有权与连接所有权相互独立;[注册表参考](<../../packages/typert/registry/README.zh.md>)说明离线注册与挂载生命周期。导入、注册、列出或调用 Remote 方法都不会取得交互 waterfall 的所有权。Gateway Client 保持被动,除非连接通过 `forwardedEvents: true` 选择加入 Host 事件;Web bundle 通过 Host Gateway 行把这一选择发布给已服务的页面。 + ```ts type-equiv /** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */ interface TypertClientRemote extends TypertRemoteNamespaceMap { diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.i18n.yaml new file mode 100644 index 0000000000..a358e86095 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.md +/: + en: ee01b7afc9465afa + zh: 7e83f087647e1d3b +/opt-into-forwarded-host-events: + en: 85c99343771f874d + zh: 9afc05d4d458d2a3 +/opt-into-forwarded-host-events/change: + en: 80a18e69025c04ad + zh: c306bdf66601f0dd +/opt-into-forwarded-host-events/migration: + en: e40890c6de2584e9 + zh: 9a16d86b2f4e1436 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.md new file mode 100644 index 0000000000..b617d7b2b7 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: Custom Remote Client compositions must explicitly enable forwarded Host events for subscriptions and interaction handling. +--- + +# Opt into forwarded Host events + +English | [中文](guide.zh.md) + +## Change + +Gateway Clients are passive by default. Generated unary and streaming methods remain callable, but `$on()` subscriptions and the event-supervised `$stream()` API require `forwardedEvents: true`. Passive callers do not join approval/question waterfalls or start the forwarded-event Connection recovery loop. + +The shipped Web profile enables forwarding explicitly. Custom profiles and direct Client compositions that relied on automatic forwarding must choose whether to participate. + +## Migration + +1. For a custom Web profile that handles interactive Host events, set `forwardedEvents: true` in the Host `@deepseek-ai/dsh-api-gateway` entry's `config`. Gateway publishes this choice to served pages; see the [shipped Web patch](../../../../packages/bundle/web-app/cordis.patch.yml). +2. For a direct Client composition, pass `{ forwardedEvents: true }` to the `@deepseek-ai/dsh-api-gateway/client` plugin only when it owns subscriptions or interaction handling. CLI/MCP callers that only invoke generated methods should remain passive. An explicit Client choice overrides the serving page's choice. +3. Keep Connection transport hooks instance-owned and await Context disposal. Forwarding does not replace authentication, and generated method streams do not require forwarding. +4. Verify that interactive clients receive subscribed events and can answer interactions. Verify that passive clients can call generated methods while `$on()` and `$stream()` reject immediately; see the [Gateway runtime documentation](../../../../packages/api/gateway/README.md). diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.zh.md new file mode 100644 index 0000000000..a34c9a7c78 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.zh.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: 自定义 Remote Client 组合必须显式启用转发的 Host 事件,才能订阅事件和处理交互。 +--- + +# 显式选择转发 Host 事件 + +[English](guide.md) | 中文 + +## 变更 + +Gateway Client 默认被动运行。生成的一元和流式方法仍可调用,但 `$on()` 订阅和由事件监督的 `$stream()` API 要求 `forwardedEvents: true`。被动调用方不加入审批/提问 waterfall(瀑布式事件),也不启动转发事件的 Connection 恢复循环。 + +随附的 Web profile 显式启用转发。依赖自动转发的自定义 profile 和直接 Client 组合必须选择是否参与。 + +## 迁移 + +1. 对需要处理交互 Host 事件的自定义 Web profile,在 Host `@deepseek-ai/dsh-api-gateway` 条目的 `config` 中设置 `forwardedEvents: true`。Gateway 将该选择发布到所服务的页面;参见[随附的 Web patch](../../../../packages/bundle/web-app/cordis.patch.yml)。 +2. 对直接 Client 组合,仅在其拥有订阅或交互处理职责时向 `@deepseek-ai/dsh-api-gateway/client` 插件传入 `{ forwardedEvents: true }`。仅调用生成方法的 CLI/MCP 调用方应保持被动。显式 Client 选择覆盖所服务页面的选择。 +3. 保持 Connection 传输钩子由实例拥有,并等待 Context 释放。事件转发不替代认证,生成的方法流也不要求事件转发。 +4. 验证交互客户端能收到订阅事件并回答交互。验证被动客户端能调用生成方法,同时 `$on()` 和 `$stream()` 立即拒绝;参见 [Gateway 运行时文档](../../../../packages/api/gateway/README.zh.md)。 diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index d4469cf8cd..f5d3b16a82 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -20,6 +20,7 @@ }, "./client": { "types": "./lib/types/client/index.d.ts", + "node": "./lib/types/client/index.js", "default": "./lib/client.js" }, "./types": { @@ -61,7 +62,8 @@ "@deepseek-ai/dsh-timeout": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", "ws": "^8.21.3", - "@deepseek-ai/dsh-typert-protocol": "workspace:*" + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-util-crypto": "workspace:*" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", @@ -74,7 +76,6 @@ "@deepseek-ai/dsh-host-webserver": "workspace:*", "@deepseek-ai/dsh-remote-mock": "workspace:*", "@deepseek-ai/dsh-typert-registry": "workspace:*", - "@deepseek-ai/dsh-util-crypto": "workspace:*", "@types/ws": "^8.18.1", "@deepseek-ai/cordis": "workspace:~", "zod": "4.4.3" diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 9a6cb07d16..9b4b62d9f6 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -4,6 +4,7 @@ * participates in method lookup, invocation, or type exposure. */ +import z from '@deepseek-ai/schemastery' import { Service } from '@deepseek-ai/cordis' import { RemoteError, isRemoteUplinkItem, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' export type { TypertGatewayFaultDetails } from '../remote-error-codes.ts' @@ -33,6 +34,7 @@ import { RemoteStream, type RemoteStreamOptions, } from './remote-stream.ts' +import { GATEWAY_CLIENT_CONFIG_GLOBAL, REMOTE_STREAM_MUX_PATH } from '../stream-protocol.ts' export { RemoteStreamCarrierError } from './stream-client.ts' export { RemoteJournalStream } from './journal-stream.ts' @@ -47,11 +49,6 @@ export type { RemoteStreamItem, RemoteStreamOptions } from './remote-stream.ts' export { RemoteSnapshotStream } from './snapshot-stream.ts' export type { RemoteSnapshotStreamOptions } from './snapshot-stream.ts' -interface MountToken { - active: boolean - readonly abort: AbortController -} - interface ScopedProjection { readonly context: string readonly wire: string @@ -60,7 +57,7 @@ interface ScopedProjection { interface DirectMethod { readonly descriptor: InvocationDescriptor - readonly token: MountToken + readonly token: AbortController } interface ScopedMethod extends DirectMethod { @@ -94,7 +91,7 @@ interface LoaderReadiness { /** One descriptor's mounted variants, for the group disposer to unwind. */ interface InstalledMethod { readonly descriptor: InvocationDescriptor - readonly token: MountToken + readonly token: AbortController direct: boolean scoped: boolean } @@ -102,9 +99,11 @@ interface InstalledMethod { /** Typed Remote service augmented by generated direct namespaces and Gateway stream supervision. */ export interface ClientRemote extends TypertClientRemote { /** - * Create one independently cancellable, reconnecting logical stream. + * Create one independently cancellable, reconnecting logical stream. Reconnection follows + * Connection generations, so the service must participate in forwarded events. * @param options - domain-owned opener and generation-end classification. * @returns a single-consumer stream annotated with physical generation ids. + * @throws when this service does not participate in forwarded events. */ $stream(options: RemoteStreamOptions): RemoteStream /** @@ -133,12 +132,31 @@ declare module '@deepseek-ai/cordis' { /** Required Client services: the Typert registry and the existing Connection carrier. */ export const inject = ['typert', 'connection'] +/** Client Remote runtime configuration. */ +export interface Config { + /** + * Receive application-forwarded Host events, including Agent-scoped approval and question + * requests, and run Connection generation recovery. Off by default: generated unary and + * stream methods work without it, while `$on()` and `$stream()` require it. + */ + forwardedEvents?: boolean +} + +/** Validated Client Remote runtime configuration. */ +export const Config: z = z.object({ + forwardedEvents: z.boolean(), +}) + /** * Install the typed Client Remote service. * @param ctx - Client Cordis root. + * @param config - explicit choices override the serving Host page configuration; omitted choices + * otherwise leave the client passive. */ -export function apply(ctx: Context): void { - new ClientRemoteService(ctx) +export function apply(ctx: Context, config?: Config): void { + const page = (globalThis as Partial>)[GATEWAY_CLIENT_CONFIG_GLOBAL] + const resolved = Config({ forwardedEvents: config?.forwardedEvents ?? page?.forwardedEvents ?? false }) + new ClientRemoteService(ctx, resolved) } class ClientRemoteService extends Service implements ClientRemote { @@ -146,45 +164,56 @@ class ClientRemoteService extends Service implements ClientRemote { private readonly connection: ConnectionHandle private readonly namespaces = new Map() private hostFacts: RemoteHostFacts | undefined - private readonly streams = new RemoteStreamMuxClient() - private readonly events: ClientRemoteEvents + private readonly streams: RemoteStreamMuxClient + private readonly events: ClientRemoteEvents | undefined private mutations = Promise.resolve() + private readonly invocations = new Map>, AbortController>() + private disposed = false - constructor(ctx: Context) { + constructor(ctx: Context, config: Config) { super(ctx, 'remote') this.ownerCtx = ctx const connection = ctx.get('connection') as ConnectionHandle this.connection = connection - this.events = new ClientRemoteEvents( - ctx, - connection, - (endpoint, payload, signal) => this.openRemoteStream(endpoint, payload, signal), - ) - if (connection.rpc.open === undefined) this.streams.start() - let disposed = false + this.streams = new RemoteStreamMuxClient(() => connection.openWebSocket(REMOTE_STREAM_MUX_PATH)) let loop: ReturnType | undefined - const start = (): void => { - if (disposed) return + if (config.forwardedEvents === true) { + this.events = new ClientRemoteEvents( + ctx, + connection, + (endpoint, payload, signal) => this.openRemoteStream(endpoint, payload, signal), + ) + this.events.start() if (connection.rpc.open === undefined) this.streams.start() - loop = connection.start({ - onConnected: () => { this.ownerCtx.emit('connection/reset') }, - onReconnectRequested: () => { - if (connection.rpc.open === undefined) this.streams.reconnect() - }, - }) + const start = (): void => { + if (this.disposed) return + if (connection.rpc.open === undefined) this.streams.start() + loop = connection.start({ + onConnected: () => { this.ownerCtx.emit('connection/reset') }, + onReconnectRequested: () => { + if (connection.rpc.open === undefined) this.streams.reconnect() + }, + }) + } + const loader = ctx.get('loader') as LoaderReadiness | undefined + if (loader === undefined) start() + else void loader.await().then(start, () => {}) } - const loader = ctx.get('loader') as LoaderReadiness | undefined - if (loader === undefined) start() - else void loader.await().then(start, () => {}) ctx.effect(() => async () => { - disposed = true + this.disposed = true loop?.stop() - await this.events.dispose() + const pending = [...this.invocations] + for (const [, token] of pending) token.abort() + await Promise.allSettled(pending.map(([invocation]) => invocation)) + await this.events?.dispose() await this.streams.close() }, 'api-gateway.client.transport') } $stream(options: RemoteStreamOptions): RemoteStream { + if (this.events === undefined) { + throw new Error(`client api: ${options.name} needs forwardedEvents; passive clients call generated stream methods directly`) + } return new RemoteStream(this.connection, options) } @@ -213,6 +242,9 @@ class ClientRemoteService extends Service implements ClientRemote { event: Event, listener: TypertClientEventListener, ): () => void { + if (this.events === undefined) { + throw new Error(`client api: $on(${JSON.stringify(event)}) needs forwardedEvents; this client does not receive forwarded Host events`) + } return this.events.subscribe(this.ctx, event, listener) } @@ -227,6 +259,7 @@ class ClientRemoteService extends Service implements ClientRemote { const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined if (connection === undefined) throw new Error(noConnection) const local = connection.rpc.open?.('/api', endpoint, payload, signal, uplink) + if (local === undefined && this.events?.started !== true) this.streams.start() return local === undefined ? this.streams.open(endpoint, payload, signal, uplink) : normalizeConnectionStream(local) @@ -334,13 +367,15 @@ class ClientRemoteService extends Service implements ClientRemote { const handle = namespace return async () => { for (const method of [...installed].reverse()) { - /* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */ - if (!method.token.active) continue - method.token.active = false - method.token.abort.abort() + // Transport disposal aborts pending tokens before group cleanup. + if (method.token.signal.aborted) continue + method.token.abort() if (method.scoped) handle.service.remove('scoped', method.descriptor.method, method.token) if (method.direct) handle.service.remove('direct', method.descriptor.method, method.token) } + const tokens = new Set(installed.map(method => method.token)) + await Promise.allSettled([...this.invocations] + .filter(([, token]) => tokens.has(token)).map(([invocation]) => invocation)) await this.disposeNamespace(name, handle) } } @@ -417,7 +452,7 @@ class ClientRemoteService extends Service implements ClientRemote { private invokeSelected( descriptor: InvocationDescriptor, projection: ScopedProjection | undefined, - token: MountToken, + token: AbortController, callerCtx: Context, values: readonly unknown[], boundIdentity?: BoundContextIdentity, @@ -425,19 +460,23 @@ class ClientRemoteService extends Service implements ClientRemote { if (descriptor.mode !== undefined) { return this.invokeStream(descriptor, projection, token, callerCtx, values, boundIdentity) } - return this.invoke(descriptor, projection, token, callerCtx, values, boundIdentity) + const invocation = this.invoke(descriptor, projection, token, callerCtx, values, boundIdentity) + this.invocations.set(invocation, token) + const settled = (): void => { this.invocations.delete(invocation) } + void invocation.then(settled, settled) + return invocation } private async invoke( descriptor: InvocationDescriptor, projection: ScopedProjection | undefined, - token: MountToken, + token: AbortController, callerCtx: Context, values: readonly unknown[], boundIdentity?: BoundContextIdentity, ): Promise> { const endpoint = endpointOf(descriptor) - if (!token.active) return withdrawn(endpoint) + if (this.disposed || token.signal.aborted) return withdrawn(endpoint) const prepared = this.prepareInvocation(descriptor, projection, token, callerCtx, values, boundIdentity) const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`) @@ -464,13 +503,13 @@ class ClientRemoteService extends Service implements ClientRemote { private invokeStream( descriptor: InvocationDescriptor, projection: ScopedProjection | undefined, - token: MountToken, + token: AbortController, callerCtx: Context, values: readonly unknown[], boundIdentity?: BoundContextIdentity, ): RemoteStreamHandle { const endpoint = endpointOf(descriptor) - if (!token.active) throw new Error(withdrawn(endpoint).error.message) + if (this.disposed || token.signal.aborted) throw new Error(withdrawn(endpoint).error.message) const prepared = this.prepareInvocation(descriptor, projection, token, callerCtx, values, boundIdentity) const generation = new AbortController() const uplink = new ClientUplinkQueue(endpoint) @@ -486,7 +525,7 @@ class ClientRemoteService extends Service implements ClientRemote { private prepareInvocation( descriptor: InvocationDescriptor, projection: ScopedProjection | undefined, - token: MountToken, + token: AbortController, callerCtx: Context, values: readonly unknown[], boundIdentity?: BoundContextIdentity, @@ -527,8 +566,8 @@ class ClientRemoteService extends Service implements ClientRemote { }) const callerSignal = hasCallerSignal ? values[expected] as AbortSignal | undefined : undefined const signal = callerSignal === undefined - ? token.abort.signal - : AbortSignal.any([token.abort.signal, callerSignal]) + ? token.signal + : AbortSignal.any([token.signal, callerSignal]) return { endpoint, args, signal } } } @@ -551,7 +590,7 @@ class ClientStreamHandle implements RemoteStreamHandle { downlink: AsyncIterable, private readonly uplink: ClientUplinkQueue, private readonly generation: AbortController, - private readonly token: MountToken, + private readonly token: AbortController, ) { this.downlink = downlink[Symbol.asyncIterator]() // The carrier opens the logical stream on the first pull; pulling now puts @@ -601,7 +640,7 @@ class ClientStreamHandle implements RemoteStreamHandle { const next = await (this.primed ?? this.downlink.next()) this.primed = undefined if (this.disposed || next.done === true) return - if (!mountActive(this.token)) throw new Error(withdrawn(this.endpoint).error.message) + if (this.token.signal.aborted) throw new Error(withdrawn(this.endpoint).error.message) yield next.value } } catch (error) { @@ -656,11 +695,11 @@ class RemoteNamespaceService extends Service { return this.methods.get(method)?.[kind] !== undefined } - installDirect(descriptor: InvocationDescriptor, token: MountToken): void { + installDirect(descriptor: InvocationDescriptor, token: AbortController): void { this.install(descriptor.method, 'direct', { descriptor, token }) } - installScoped(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void { + installScoped(descriptor: InvocationDescriptor, projection: ScopedProjection, token: AbortController): void { this.install(descriptor.method, 'scoped', { descriptor, projection, token }) } @@ -691,7 +730,7 @@ class RemoteNamespaceService extends Service { else record.scoped = value as ScopedMethod } - remove(kind: 'direct' | 'scoped', method: string, token: MountToken): void { + remove(kind: 'direct' | 'scoped', method: string, token: AbortController): void { const record = this.methods.get(method) const current = record?.[kind] /* v8 ignore next -- duplicate live variants are rejected before installation, so no newer token can replace this one. */ @@ -720,7 +759,7 @@ function installMethods( for (const descriptor of descriptors) { const method: InstalledMethod = { descriptor, - token: { active: true, abort: new AbortController() }, + token: new AbortController(), direct: false, scoped: false, } @@ -737,8 +776,7 @@ function installMethods( } } catch (error) { for (const method of [...installed].reverse()) { - method.token.active = false - method.token.abort.abort() + method.token.abort() if (method.scoped) service.remove('scoped', method.descriptor.method, method.token) if (method.direct) service.remove('direct', method.descriptor.method, method.token) } @@ -757,8 +795,8 @@ function endpointOf(descriptor: Pick void + private unregisterGeneration: (() => void) | undefined private activeGeneration: Promise | undefined /** @@ -74,8 +74,16 @@ export class ClientRemoteEvents { private readonly ownerCtx: Context, private readonly connection: ConnectionHandle, private readonly openStream: RemoteEventStreamOpener, - ) { - this.unregisterGeneration = connection.registerGenerationSource(this.runGeneration) + ) {} + + /** Whether this client participates in forwarded-event delivery. */ + get started(): boolean { + return this.unregisterGeneration !== undefined + } + + /** Register the generation source; the owning service calls this once when `forwardedEvents` is on. */ + start(): void { + this.unregisterGeneration = this.connection.registerGenerationSource(this.runGeneration) } /** @@ -99,7 +107,7 @@ export class ClientRemoteEvents { /** Withdraw the generation source and wait for active listener work to quiesce. */ async dispose(): Promise { - this.unregisterGeneration() + this.unregisterGeneration?.() await Promise.allSettled([this.activeGeneration]) } diff --git a/packages/api/gateway/src/client/remote-stream.ts b/packages/api/gateway/src/client/remote-stream.ts index 00b398afd7..cd5934a4ba 100644 --- a/packages/api/gateway/src/client/remote-stream.ts +++ b/packages/api/gateway/src/client/remote-stream.ts @@ -22,8 +22,12 @@ export interface RemoteStreamOptions { readonly name: string /** Open one physical generation of the logical stream. */ readonly open: (signal: AbortSignal) => AsyncIterable - /** Classify a normal generation end after or before its opening item was accepted. */ - readonly ended: (accepted: boolean) => Error + /** + * Classify a normal generation end after or before its opening item was accepted. Omitted: an + * end after acceptance is retryable like a carrier loss (a Host reload ends every stream), and an + * end before acceptance is a terminal failure. + */ + readonly ended?: (accepted: boolean) => Error /** Observe a retryable carrier loss before the supervisor waits or reopens. */ readonly carrierFailed?: (error: RemoteStreamCarrierError) => void } @@ -43,6 +47,7 @@ export class RemoteStream implements AsyncIterable> private closing: Promise | undefined private revision = 0 private taken = false + private readonly ended: (accepted: boolean) => Error /** * @param connection - observable Host generation source used to pace retries. @@ -51,7 +56,11 @@ export class RemoteStream implements AsyncIterable> constructor( private readonly connection: Pick, private readonly options: RemoteStreamOptions, - ) {} + ) { + this.ended = options.ended ?? (accepted => accepted + ? new RemoteStreamCarrierError(`${options.name} ended without a terminal result`) + : new Error(`${options.name} ended before its opening item`)) + } /** Cancellation lifetime shared by the stream and sibling page requests. */ get signal(): AbortSignal { @@ -125,7 +134,7 @@ export class RemoteStream implements AsyncIterable> } if (isAborted(this.lifetime.signal)) return if (revision !== this.revision) continue - throw this.options.ended(accepted) + throw this.ended(accepted) } catch (error) { if (isAborted(this.lifetime.signal)) return if (revision !== this.revision) continue diff --git a/packages/api/gateway/src/client/stream-client.ts b/packages/api/gateway/src/client/stream-client.ts index 92255a10ee..3b3a58576a 100644 --- a/packages/api/gateway/src/client/stream-client.ts +++ b/packages/api/gateway/src/client/stream-client.ts @@ -1,14 +1,16 @@ import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -/** Browser owner for the Gateway multiplexed Remote stream socket. */ +/** Gateway framing over a Connection-owned WebSocket. */ import { parseRemoteStreamServerMessage, - REMOTE_STREAM_MUX_PATH, type RemoteStreamClientMessage, type RemoteStreamServerMessage, } from '../stream-protocol.ts' import { Deque } from '@deepseek-ai/dsh-deque' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import type { ConnectionWebSocket } from '@deepseek-ai/dsh-client-connection/client' + +const WEBSOCKET_OPEN = 1 /** Physical Remote stream socket failure that may be retried by a domain transport. */ export class RemoteStreamCarrierError extends Error { @@ -24,7 +26,7 @@ export class RemoteStreamCarrierError extends Error { interface SocketWaiter { readonly revision: number - resolve(socket: WebSocket): void + resolve(socket: ConnectionWebSocket): void reject(error: unknown): void } @@ -49,7 +51,7 @@ const UPLINK_DONE: IteratorReturnResult = { value: undefined, done: t * starts one. */ export class RemoteStreamMuxClient { - private socket: WebSocket | undefined + private socket: ConnectionWebSocket | undefined private cancelCandidate: ((error: Error) => void) | undefined private keepAlive: Promise | undefined private revision = 0 @@ -58,11 +60,14 @@ export class RemoteStreamMuxClient { private running = false private disposed = false + /** @param openSocket - physical socket factory owned by the active Connection. */ + constructor(private readonly openSocket: () => ConnectionWebSocket) {} + /** Ensure a physical attempt exists, following the current attempt once if needed. */ start(): void { if (this.disposed) return this.running = true - if (this.socket?.readyState === WebSocket.OPEN) return + if (this.socket?.readyState === WEBSOCKET_OPEN) return const pending = this.keepAlive if (pending === undefined) this.maintain() else void pending.then(() => { this.maintain() }) @@ -106,7 +111,7 @@ export class RemoteStreamMuxClient { const streamId = randomUUID() const inbox = new StreamInbox() const stream: LogicalStream = { inbox, pump: undefined } - let carrier: WebSocket | undefined + let carrier: ConnectionWebSocket | undefined let opened = false let terminal = false const abort = (): void => { inbox.fail(signal.reason) } @@ -136,7 +141,7 @@ export class RemoteStreamMuxClient { signal.removeEventListener('abort', abort) this.streams.delete(streamId) stream.pump?.stop() - if (opened && !terminal && carrier?.readyState === WebSocket.OPEN) { + if (opened && !terminal && carrier?.readyState === WEBSOCKET_OPEN) { this.send(carrier, { type: 'cancel', streamId }) } // The old generation's pump has stopped before a supervisor reopens the next one. @@ -152,7 +157,7 @@ export class RemoteStreamMuxClient { * generator blocked in `next()` only completes it once it yields. */ private pumpUplink( - socket: WebSocket, + socket: ConnectionWebSocket, streamId: string, uplink: AsyncIterable, signal: AbortSignal, @@ -223,9 +228,9 @@ export class RemoteStreamMuxClient { await this.keepAlive } - private connect(): Promise { - const socket = new WebSocket(remoteStreamUrl()) - const connecting = new Promise((resolve, reject) => { + private async connect(): Promise { + const socket = this.openSocket() + const connecting = new Promise((resolve, reject) => { let settled = false const rejectCandidate = (error: Error): void => { settled = true @@ -264,7 +269,7 @@ export class RemoteStreamMuxClient { } this.lost(socket) } - const received = (event: MessageEvent): void => { this.receive(socket, event.data) } + const received = (event: { readonly data: unknown }): void => { this.receive(socket, event.data) } this.cancelCandidate = rejectCandidate socket.addEventListener('open', opened, { once: true }) socket.addEventListener('error', failed, { once: true }) @@ -274,9 +279,9 @@ export class RemoteStreamMuxClient { return connecting } - private waitForSocket(signal: AbortSignal): Promise { + private waitForSocket(signal: AbortSignal): Promise { signal.throwIfAborted() - if (this.socket?.readyState === WebSocket.OPEN) return Promise.resolve(this.socket) + if (this.socket?.readyState === WEBSOCKET_OPEN) return Promise.resolve(this.socket) if (this.disposed) return Promise.reject(new Error('api gateway: Remote stream client disposed')) if (!this.running) return Promise.reject(new Error('api gateway: Remote stream client not started')) return new Promise((resolve, reject) => { @@ -303,7 +308,7 @@ export class RemoteStreamMuxClient { }) } - private receive(socket: WebSocket, data: unknown): void { + private receive(socket: ConnectionWebSocket, data: unknown): void { if (socket !== this.socket) return try { if (typeof data !== 'string') throw new Error('api gateway: Remote stream WebSocket requires text messages') @@ -322,7 +327,7 @@ export class RemoteStreamMuxClient { } private lost( - socket: WebSocket, + socket: ConnectionWebSocket, error: RemoteStreamCarrierError = new RemoteStreamCarrierError( 'api gateway: Remote stream WebSocket closed', ), @@ -334,7 +339,7 @@ export class RemoteStreamMuxClient { private maintain(): void { if (!this.running || this.disposed) return - if (this.socket?.readyState === WebSocket.OPEN || this.keepAlive !== undefined) return + if (this.socket?.readyState === WEBSOCKET_OPEN || this.keepAlive !== undefined) return const revision = this.revision const task = this.connect().then( () => undefined, @@ -358,7 +363,7 @@ export class RemoteStreamMuxClient { } } - private send(socket: WebSocket, message: RemoteStreamClientMessage): void { + private send(socket: ConnectionWebSocket, message: RemoteStreamClientMessage): void { socket.send(JSON.stringify(message)) } } @@ -468,13 +473,3 @@ export class ClientUplinkQueue implements AsyncIterable, AsyncIterator< wake?.() } } - -function remoteStreamUrl(): string { - // The mux route is registered absolute; a page resolves its document-relative - // form against its own document base. A shell-owned Host on another origin - // supplies that base through the transport. - const globals = globalThis as { __DSH_TRANSPORT__?: { streamBaseUrl?: string } } - const url = new URL(REMOTE_STREAM_MUX_PATH.slice(1), globals.__DSH_TRANSPORT__?.streamBaseUrl ?? document.baseURI) - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - return url.href -} diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 877dd83194..4eb96330bc 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -46,6 +46,7 @@ import { rejectRemoteStreamUpgrade, } from './stream-server.ts' import { + GATEWAY_CLIENT_CONFIG_GLOBAL, REMOTE_EVENT_STREAM_ENDPOINT, REMOTE_EVENT_STREAM_READY, REMOTE_EVENT_RESULT_ENDPOINT, @@ -149,11 +150,17 @@ export interface Config { readonly websocketHeartbeatIntervalMs?: number /** Buffered uplink frame bytes one logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */ readonly streamInboxBytes?: number + /** + * Served pages receive forwarded Host events, including Agent-scoped approval and question + * requests; their Gateway Client then answers or delegates those requests. @default false + */ + readonly forwardedEvents?: boolean } interface ResolvedConfig extends Config { readonly websocketHeartbeatIntervalMs: number readonly streamInboxBytes: number + readonly forwardedEvents: boolean } /** @@ -203,6 +210,7 @@ export class TypertGatewayService extends Service implements TypertGateway { websocketHeartbeatIntervalMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS) .default(DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS), streamInboxBytes: z.number().step(1).min(1).default(DEFAULT_STREAM_INBOX_BYTES), + forwardedEvents: z.boolean().default(false), }) /** Carrier adapter shared by the WebSocket mux and local Host transports. */ @@ -239,6 +247,9 @@ export class TypertGatewayService extends Service implements TypertGateway { ) }) ctx.inject(['connection', 'webServer'], (webCtx) => { + webCtx.on('webserver/index-inject', (table) => { + table.push({ kind: 'global', name: GATEWAY_CLIENT_CONFIG_GLOBAL, value: { forwardedEvents: resolved.forwardedEvents } }) + }) const listen = (): void => { const mux = new RemoteStreamMuxServer( (endpoint, payload, uplink, peer, control, window) => diff --git a/packages/api/gateway/src/stream-protocol.ts b/packages/api/gateway/src/stream-protocol.ts index 610fc07780..65819083b8 100644 --- a/packages/api/gateway/src/stream-protocol.ts +++ b/packages/api/gateway/src/stream-protocol.ts @@ -6,6 +6,9 @@ import { isRemoteJsonValue } from '@deepseek-ai/dsh-typert-protocol' /** Exact WebSocket route carrying every Typert Remote stream. */ export const REMOTE_STREAM_MUX_PATH = '/api/remote.mux' +/** Page global through which the serving Host supplies the Gateway Client's Config to browser pages. */ +export const GATEWAY_CLIENT_CONFIG_GLOBAL = '__DSH_GATEWAY_CLIENT__' + /** Gateway-internal logical stream carrying application-selected Cordis events. */ export const REMOTE_EVENT_STREAM_ENDPOINT = '$events' diff --git a/packages/api/gateway/tests/control-retry.client.spec.ts b/packages/api/gateway/tests/control-retry.client.spec.ts index c818d507e7..b3c7317a10 100644 --- a/packages/api/gateway/tests/control-retry.client.spec.ts +++ b/packages/api/gateway/tests/control-retry.client.spec.ts @@ -97,6 +97,25 @@ describe('RemoteStream', () => { await stream.dispose() }) + it('reopens after an accepted generation ends normally and fails one that ends before its opening item', async () => { + const source = hostSource(true) + const reopened = new RemoteStream(source.connection, { name: 'default stream', open: scripted([ + { values: ['first'] }, + { values: ['second'], hold: true }, + ]) }) + const iterator = reopened[Symbol.asyncIterator]() + const first = await iterator.next() + if (first.done) throw new Error('fixture generation ended early') + first.value.accept() + expect(await iterator.next()).toMatchObject({ done: false, value: { generation: 2, value: 'second' } }) + await reopened.dispose() + + const bare = new RemoteStream(source.connection, { name: 'default stream', open: scripted([{ values: [] }]) }) + await expect(bare[Symbol.asyncIterator]().next()).rejects.toMatchObject({ + isDSHRemoteError: true, code: 'gateway/internal', message: 'default stream ended before its opening item', + }) + }) + it('permits one isolated retry while the Host remains available', async () => { const source = hostSource(true) const first = new RemoteStreamCarrierError('first carrier failure') diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index 2efcb950e0..e7932ecbe3 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -415,9 +415,11 @@ describe('Typert Remote streams', () => { }) it('validates the WebSocket heartbeat timer range and the stream inbox bound', () => { - expect(TypertGatewayService.Config({})).toEqual({ websocketHeartbeatIntervalMs: 2_000, streamInboxBytes: 262_144 }) - expect(TypertGatewayService.Config({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS, streamInboxBytes: 1 })) - .toEqual({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS, streamInboxBytes: 1 }) + expect(TypertGatewayService.Config({})).toEqual({ + websocketHeartbeatIntervalMs: 2_000, streamInboxBytes: 262_144, forwardedEvents: false, + }) + expect(TypertGatewayService.Config({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS, streamInboxBytes: 1, forwardedEvents: true })) + .toEqual({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS, streamInboxBytes: 1, forwardedEvents: true }) for (const websocketHeartbeatIntervalMs of [0, 1.5, MAX_TIMER_DELAY_MS + 1]) { expect(() => TypertGatewayService.Config({ websocketHeartbeatIntervalMs })).toThrow() } @@ -426,6 +428,13 @@ describe('Typert Remote streams', () => { } }) + it.each([false, true])('publishes the served-page forwarded-event choice %s', async (forwardedEvents) => { + const { ctx } = await setup(true, TypertGatewayService.Config({ forwardedEvents })) + expect(ctx.webServer.collectIndexInjections()).toContainEqual({ + kind: 'global', name: '__DSH_GATEWAY_CLIENT__', value: { forwardedEvents }, + }) + }) + it('opens decoded carrier payloads through the in-process wire adapter', async () => { const { ctx } = await setup(false) const source = await ctx.typertGateway.wireStream.open( diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index b0c7d4f390..ce40decf81 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -1,5 +1,6 @@ import { queryObjects } from 'node:v8' import { RemoteError, typertOwnedValue } from '@deepseek-ai/dsh-typert-protocol' +import type { ConnectionWebSocketEventMap } from '@deepseek-ai/dsh-client-connection/client' import { Context, Service } from '@deepseek-ai/cordis' import type { Fiber } from '@deepseek-ai/cordis' import { describe, expect, expectTypeOf, it, vi } from 'vitest' @@ -25,7 +26,7 @@ import type { } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { ClientRemote } from '../src/client/index.ts' -import { apply, inject, isRemoteFailure, RemoteStream } from '../src/client/index.ts' +import { apply, Config, inject, isRemoteFailure, RemoteStream } from '../src/client/index.ts' import { ClientUplinkQueue, RemoteStreamCarrierError, @@ -255,6 +256,27 @@ function attachDescriptor(): InvocationDescriptor { type WebSocketGlobal = { WebSocket?: typeof WebSocket } class FakeWebSocket extends EventTarget { + override addEventListener( + type: E, listener: (event: ConnectionWebSocketEventMap[E]) => void, options?: { readonly once?: boolean }, + ): void + override addEventListener( + type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions, + ): void + override addEventListener(type: string, listener: unknown, options?: boolean | AddEventListenerOptions): void { + // This fixture dispatches MessageEvent for messages and Event for other socket events. + super.addEventListener(type, listener as EventListenerOrEventListenerObject | null, options) + } + + override removeEventListener( + type: E, listener: (event: ConnectionWebSocketEventMap[E]) => void, + ): void + override removeEventListener( + type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions, + ): void + override removeEventListener(type: string, listener: unknown, options?: boolean | EventListenerOptions): void { + super.removeEventListener(type, listener as EventListenerOrEventListenerObject | null, options) + } + static readonly CONNECTING = 0 static readonly OPEN = 1 static readonly CLOSING = 2 @@ -348,11 +370,16 @@ async function benchFiber( const generation = new GenerationHarness() const start = vi.fn(() => ({ stop: () => {} })) ctx.provide('connection', { + isLoopback: false, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, + state: { getSnapshot: () => undefined, subscribe: () => () => {} }, + reconnect: () => { throw new Error('fixture reconnect is not driven here') }, rpc, registerGenerationSource: generation.register, + openWebSocket: (path: string) => new FakeWebSocket(`wss://harness.example${path}`), start, - } as unknown as ConnectionHandle) - const client = ctx.plugin({ inject, apply }) + }) + const client = ctx.plugin({ inject, apply, Config }, { forwardedEvents: true }) await client return { ctx, client, generation, start } } @@ -451,12 +478,17 @@ async function loaderReadinessBench( const start = vi.fn(() => ({ stop })) const call = vi.fn() ctx.provide('connection', { + isLoopback: false, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, + state: { getSnapshot: () => undefined, subscribe: () => () => {} }, + reconnect: () => { throw new Error('fixture reconnect is not driven here') }, rpc: carrier === 'web' ? { call } : { call, open: () => unexpectedInProcessStream() }, registerGenerationSource: generation.register, + openWebSocket: (path: string) => new FakeWebSocket(`wss://harness.example${path}`), start, - } as unknown as ConnectionHandle) + }) ctx.provide('loader', { await: () => readiness }) - const client = ctx.plugin({ inject, apply }) + const client = ctx.plugin({ inject, apply, Config }, { forwardedEvents: true }) await client return { client, start, stop } } @@ -598,7 +630,7 @@ describe('Client Remote transport readiness', () => { start: () => ({ stop: () => {} }), } as unknown as ConnectionHandle const withdraw = ctx.provide('connection', handle) - const client = ctx.plugin({ inject, apply }) + const client = ctx.plugin({ inject, apply, Config }, { forwardedEvents: true }) await client const remote = ctx.remote @@ -649,7 +681,7 @@ describe('Client Remote transport readiness', () => { try { await ctx.plugin(TypertRegistry) await ctx.plugin({ inject: [], apply: applyConnection }) - await ctx.plugin({ inject, apply }) + await ctx.plugin({ inject, apply, Config }, { forwardedEvents: true }) await vi.advanceTimersByTimeAsync(0) const connection = ctx.get('connection') as ConnectionHandle expect(FakeWebSocket.sockets).toHaveLength(1) @@ -1239,8 +1271,10 @@ describe('Client Typert API', () => { const dispose = await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [first, second] }) const invocation = ctx.remote.probe.create('agent-1', { objective: 'ship' }) await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) - await dispose() + const disposal = dispose() + await vi.waitFor(() => { expect(call.mock.calls[0]?.[3]?.aborted).toBe(true) }) resolveCall({ ok: true, value: { ref: 'goal-1' } }) + await disposal await expect(invocation).resolves.toMatchObject({ ok: false, @@ -2099,7 +2133,7 @@ describe('Client Typert API', () => { try { await ctx.plugin(TypertRegistry) await ctx.plugin({ inject: [], apply: applyConnection }) - await ctx.plugin({ inject, apply }) + await ctx.plugin({ inject, apply, Config }, { forwardedEvents: true }) const connection = ctx.get('connection') as ConnectionHandle | undefined if (connection === undefined) throw new Error('injected Connection service is unavailable') @@ -2807,7 +2841,7 @@ describe('Client Typert API', () => { describe('Remote stream client carrier lifecycle', () => { it('does not pull another uplink item when sending ends the stream', async () => { await withFakeWebSocket('https://harness.example', async () => { - const client = new RemoteStreamMuxClient() + const client = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) client.start() const socket = FakeWebSocket.sockets[0]! const send = socket.send.bind(socket) @@ -2854,7 +2888,7 @@ describe('Remote stream client carrier lifecycle', () => { }, }), } - const client = new RemoteStreamMuxClient() + const client = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) client.start() const iterator = client.open('probe/attach', {}, new AbortController().signal, uplink)[Symbol.asyncIterator]() const first = iterator.next() @@ -2880,10 +2914,9 @@ describe('Remote stream client carrier lifecycle', () => { }) }) - it('connects to the shell-owned Host while the document uses a local asset origin', async () => { + it('uses the selected physical carrier independently of the document origin', async () => { await withFakeWebSocket('dsh-app://app', async () => { - vi.stubGlobal('__DSH_TRANSPORT__', { streamBaseUrl: 'http://127.0.0.1:43210' }) - const client = new RemoteStreamMuxClient() + const client = new RemoteStreamMuxClient(() => new FakeWebSocket('ws://127.0.0.1:43210/api/remote.mux')) try { client.start() expect(FakeWebSocket.sockets[0]!.url).toBe('ws://127.0.0.1:43210/api/remote.mux') @@ -2896,7 +2929,7 @@ describe('Remote stream client carrier lifecycle', () => { it('fails the downlink and cancels when an uplink iterable throws, and swallows a rejecting uplink return()', async () => { await withFakeWebSocket('https://harness.example', async () => { - const client = new RemoteStreamMuxClient() + const client = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) client.start() const socket = FakeWebSocket.sockets[0]! const exploding = (async function* (): AsyncGenerator { @@ -2929,7 +2962,7 @@ describe('Remote stream client carrier lifecycle', () => { }) it('requires the transport owner to start the physical carrier', async () => { - const client = new RemoteStreamMuxClient() + const client = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) await expect(client.open('feed/follow', {}, new AbortController().signal) [Symbol.asyncIterator]().next()).rejects.toThrow('Remote stream client not started') await client.close() @@ -2938,7 +2971,7 @@ describe('Remote stream client carrier lifecycle', () => { it('connects without a logical stream, waits for owner-driven retries, and stops permanently', async () => { await withFakeWebSocket('https://harness.example', async () => { FakeWebSocket.autoOpen = false - const client = new RemoteStreamMuxClient() + const client = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) client.start() client.start() expect(FakeWebSocket.sockets).toHaveLength(1) @@ -2973,7 +3006,7 @@ describe('Remote stream client carrier lifecycle', () => { expect(FakeWebSocket.sockets).toHaveLength(3) expect(final.closedWith).toContainEqual({ code: 1000, reason: 'disposed' }) - const stopping = new RemoteStreamMuxClient() + const stopping = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) stopping.start() const racing = FakeWebSocket.sockets[3]! racing.open() @@ -2985,7 +3018,7 @@ describe('Remote stream client carrier lifecycle', () => { it('mints a new wire stream id when the same endpoint opens on a replacement socket', async () => { await withFakeWebSocket('https://harness.example', async () => { - const client = new RemoteStreamMuxClient() + const client = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) client.start() const first = client.open('feed/follow', { label: 'same' }, new AbortController().signal) [Symbol.asyncIterator]() @@ -3014,7 +3047,7 @@ describe('Remote stream client carrier lifecycle', () => { it('replaces an in-flight candidate and an open socket on reconnect', async () => { await withFakeWebSocket('https://harness.example', async () => { FakeWebSocket.autoOpen = false - const client = new RemoteStreamMuxClient() + const client = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) client.start() const candidate = FakeWebSocket.sockets[0]! @@ -3047,7 +3080,7 @@ describe('Remote stream client carrier lifecycle', () => { it('coalesces repeated candidate replacements and drops one queued after close', async () => { await withFakeWebSocket('https://harness.example', async () => { FakeWebSocket.autoOpen = false - const client = new RemoteStreamMuxClient() + const client = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) client.start() const first = FakeWebSocket.sockets[0]! @@ -3063,10 +3096,10 @@ describe('Remote stream client carrier lifecycle', () => { }) }) - it('shares an in-flight connection and resolves the mux route against the document base', async () => { + it('shares an in-flight physical connection', async () => { await withFakeWebSocket('https://harness.example/mounted/app/', async () => { FakeWebSocket.autoOpen = false - const client = new RemoteStreamMuxClient() + const client = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) client.start() const first = client.open('feed/follow', { label: 'first' }, new AbortController().signal) [Symbol.asyncIterator]() @@ -3076,7 +3109,6 @@ describe('Remote stream client carrier lifecycle', () => { const secondPending = second.next() expect(FakeWebSocket.sockets).toHaveLength(1) const socket = FakeWebSocket.sockets[0]! - expect(socket.url).toBe('wss://harness.example/mounted/app/api/remote.mux') socket.open() await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) }) @@ -3092,7 +3124,7 @@ describe('Remote stream client carrier lifecycle', () => { it('fails waiters with one socket attempt and lets the owner start the next attempt', async () => { await withFakeWebSocket('http://harness.example/', async () => { FakeWebSocket.autoOpen = false - const closedClient = new RemoteStreamMuxClient() + const closedClient = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) closedClient.start() const closed = closedClient.open('feed/follow', {}, new AbortController().signal) [Symbol.asyncIterator]().next() @@ -3111,27 +3143,26 @@ describe('Remote stream client carrier lifecycle', () => { await expect(replacementStream).resolves.toEqual({ done: true, value: undefined }) await closedClient.close() - const disposedClient = new RemoteStreamMuxClient() + const disposedClient = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) disposedClient.start() const disposed = disposedClient.open('feed/follow', {}, new AbortController().signal) [Symbol.asyncIterator]().next() await disposedClient.close() await expect(disposed).rejects.toThrow('Remote stream client disposed') - const abortedClient = new RemoteStreamMuxClient() + const abortedClient = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) abortedClient.start() const abort = new AbortController() const aborted = abortedClient.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next() abort.abort('cancelled while connecting') await expect(aborted).rejects.toBe('cancelled while connecting') await abortedClient.close() - expect(FakeWebSocket.sockets[3]?.url).toBe('ws://harness.example/api/remote.mux') }) }) it('fails active streams on an invalid frame and ignores later frames', async () => { await withFakeWebSocket('https://harness.example', async () => { - const client = new RemoteStreamMuxClient() + const client = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) client.start() const stream = client.open('feed/follow', {}, new AbortController().signal)[Symbol.asyncIterator]() const pending = stream.next() @@ -3153,7 +3184,7 @@ describe('Remote stream client carrier lifecycle', () => { it('completes a stream and drops a frame racing with cancellation', async () => { await withFakeWebSocket('https://harness.example', async () => { - const client = new RemoteStreamMuxClient() + const client = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) client.start() const completed = client.open('feed/follow', {}, new AbortController().signal) [Symbol.asyncIterator]() @@ -3178,7 +3209,7 @@ describe('Remote stream client carrier lifecycle', () => { it('contains non-Error cancellation reasons and late socket close events', async () => { await withFakeWebSocket('http://harness.example', async () => { - const cancelledClient = new RemoteStreamMuxClient() + const cancelledClient = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) cancelledClient.start() const abort = new AbortController() const cancelled = cancelledClient.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next() @@ -3188,7 +3219,7 @@ describe('Remote stream client carrier lifecycle', () => { await cancelledClient.close() FakeWebSocket.dispatchClose = false - const disposedClient = new RemoteStreamMuxClient() + const disposedClient = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) disposedClient.start() const disposed = disposedClient.open('feed/follow', {}, new AbortController().signal) [Symbol.asyncIterator]().next() @@ -3311,12 +3342,15 @@ describe('Remote result decoders', () => { const abort = new AbortController() const pending = ctx.remote.probe.bytes(abort.signal) expect(call).toHaveBeenCalledOnce() + // Unmounting waits for the in-flight call, so the late result settles it before disposal completes. + const disposal = action === 'cancel' ? undefined : dispose() if (action === 'cancel') abort.abort() - else await dispose() + else await vi.waitFor(() => { expect(call.mock.calls[0]?.[3]?.aborted).toBe(true) }) response.resolve({ ok: true, value: { data: new Uint8Array([7]), offset: 0 } }) expect(await pending).toMatchObject({ ok: false, error: { code: action === 'cancel' ? 'gateway/cancelled' : 'gateway/internal' }, }) + await disposal } finally { await client.dispose() } diff --git a/packages/api/gateway/tests/node-client.e2e.ts b/packages/api/gateway/tests/node-client.e2e.ts new file mode 100644 index 0000000000..1f671b732d --- /dev/null +++ b/packages/api/gateway/tests/node-client.e2e.ts @@ -0,0 +1,57 @@ +/** Published Client entries expose ordinary ESM for passive Node compositions. */ + +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { describe, expect, it } from 'vitest' + +const packageDirectory = fileURLToPath(new URL('..', import.meta.url)) +const built = ['../../client/connection', '../../typert/registry', '.'] + .every(path => existsSync(fileURLToPath(new URL(`${path}/lib/types/client/index.js`, new URL('../', import.meta.url))))) + +describe.skipIf(!built)('Node Client package entries', () => { + it('mounts typed Remotes without a browser or forwarded-event subscription', async () => { + const { stdout } = await promisify(execFile)(process.execPath, ['--input-type=module', '-e', ` + import { Context } from '@deepseek-ai/cordis' + import { installConnection } from '@deepseek-ai/dsh-client-connection/client' + import * as Registry from '@deepseek-ai/dsh-typert-registry/client' + import * as Gateway from '@deepseek-ai/dsh-api-gateway/client' + import { z } from 'zod' + + if ('window' in globalThis || 'document' in globalThis) throw new Error('smoke requires plain Node') + const calls = [] + const ctx = new Context() + try { + await ctx.plugin(Registry) + installConnection(ctx, { transport: { rpc: { + async call(channel, endpoint) { + calls.push(endpoint) + return { ok: true, value: 7 } + }, + async *open(channel, endpoint) { + calls.push(endpoint) + yield 8 + }, + } } }) + await ctx.plugin(Gateway) + const result = { mode: 'strict', typeSymbol: 'nodeProbe#number', create: () => z.number() } + const common = { service: 'nodeProbe', namespace: 'nodeProbe', invocation: { kind: 'direct' }, parameters: [], result } + await ctx.remote.$mount({ package: '@fixture/node-probe', descriptors: [ + { ...common, id: 'nodeProbe#read', method: 'read' }, + { ...common, id: 'nodeProbe#follow', method: 'follow', mode: 'stream' }, + ] }) + const read = await ctx.remote.nodeProbe.read() + const items = [] + for await (const item of ctx.remote.nodeProbe.follow()) items.push(item) + console.log(JSON.stringify({ read, items, calls, generation: ctx.connection.generation.getSnapshot() ?? null })) + } finally { + await ctx.fiber.dispose() + } + `], { cwd: packageDirectory, encoding: 'utf8', timeout: 30_000 }) + expect(JSON.parse(stdout.trim())).toEqual({ + read: { ok: true, value: 7 }, items: [8], + calls: ['nodeProbe/read', 'nodeProbe/follow'], generation: null, + }) + }) +}) diff --git a/packages/api/gateway/tests/passive.client.spec.ts b/packages/api/gateway/tests/passive.client.spec.ts new file mode 100644 index 0000000000..f4ad4e91b4 --- /dev/null +++ b/packages/api/gateway/tests/passive.client.spec.ts @@ -0,0 +1,250 @@ +/** Passive generated Remotes and explicit forwarded-event participation over Node carriers. */ + +import { Context } from '@deepseek-ai/cordis' +import { createServer } from 'node:http' +import { setImmediate } from 'node:timers/promises' +import { afterEach, describe, expect, it, vi } from 'vitest' +import WebSocket, { WebSocketServer } from 'ws' +import { z } from 'zod' +import { + installConnection, type ConnectionHandle, type ConnectionWebSocketFactory, type RpcFetch, +} from '@deepseek-ai/dsh-client-connection/client' +import * as TypertRegistry from '@deepseek-ai/dsh-typert-registry/client' +import type { RemoteResult, TypertRemoteContribution, TypertRemoteNamespace } from '@deepseek-ai/dsh-typert-protocol' +import { apply, Config, inject, isRemoteFailure } from '../src/client/index.ts' +import { GATEWAY_CLIENT_CONFIG_GLOBAL, parseRemoteStreamClientMessage, type RemoteStreamClientMessage } from '../src/stream-protocol.ts' + +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * Test-only forwarded Host event used to probe passive subscription refusal. + * @param value - marker payload never delivered. + */ + 'passive-probe/changed'(value: string): void + } +} + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteEventSelection extends Record<'passive-probe/changed', true> {} + interface TypertRemoteMap { + 'passiveProbe/echo': (value: string) => Promise> + 'passiveProbe/follow': (signal?: AbortSignal) => AsyncIterable + } + interface TypertRemoteNamespaceMap { + passiveProbe: TypertRemoteNamespace<'passiveProbe'> + } +} + +const contexts: Context[] = [] +const cleanup: (() => Promise)[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await Promise.all(cleanup.splice(0).map(dispose => dispose())) + vi.unstubAllGlobals() +}) + +function contribution(): TypertRemoteContribution { + const codec = { mode: 'strict' as const, typeSymbol: 'passiveProbe#text', create: () => z.string() } + return { + package: '@fixture/passive-probe', + descriptors: [ + { + id: 'passiveProbe#echo', namespace: 'passiveProbe', service: 'passiveProbe', method: 'echo', + invocation: { kind: 'direct' }, + parameters: [{ name: 'value', wire: 'value', source: 'json', codec }], + result: codec, + }, + { + id: 'passiveProbe#follow', namespace: 'passiveProbe', service: 'passiveProbe', method: 'follow', + mode: 'stream', invocation: { kind: 'direct' }, parameters: [], + cancellation: { parameter: 'signal' }, result: codec, + }, + ], + } +} + +async function host() { + const requests: { readonly url: string | undefined; readonly cookie: string | undefined }[] = [] + const frames: RemoteStreamClientMessage[] = [] + const upgrades: (string | undefined)[] = [] + const server = createServer((request, response) => { + const chunks: Buffer[] = [] + request.on('data', (chunk: Buffer) => { chunks.push(chunk) }) + request.on('end', () => { + requests.push({ url: request.url, cookie: request.headers.cookie }) + const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as { + readonly rpcId: string + readonly payload: { readonly args: { readonly value: string } } + } + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ + type: 'server-response', rpcId: body.rpcId, + result: { ok: true, value: body.payload.args.value }, + })) + }) + }) + const sockets = new WebSocketServer({ noServer: true }) + server.on('upgrade', (request, socket, head) => { + upgrades.push(request.headers.cookie) + sockets.handleUpgrade(request, socket, head, (client) => { + client.on('message', (data) => { + const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data) + const frame = parseRemoteStreamClientMessage(bytes.toString('utf8')) + frames.push(frame) + if (frame.type !== 'open') return + client.send(JSON.stringify({ + type: 'item', streamId: frame.streamId, + value: frame.endpoint === '$events' + ? { type: 'ready', clientId: 'fixture-events', host: { home: '/fixture/home' } } + : 'opening', + })) + }) + }) + }) + cleanup.push(async () => { + for (const socket of sockets.clients) socket.terminate() + await new Promise((resolve, reject) => { + sockets.close((error) => { + if (error === undefined) resolve() + else reject(error) + }) + }) + if (server.listening) await new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined) resolve() + else reject(error) + }) + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('fixture server has no TCP address') + return { origin: `http://127.0.0.1:${address.port}`, requests, frames, upgrades, sockets } +} + +async function client(origin: string, doFetch?: RpcFetch, forwardedEvents: boolean | 'unconfigured' | 'page' = 'unconfigured') { + const ctx = new Context() + contexts.push(ctx) + const cookie = 'session=fixture' + const webSocket: ConnectionWebSocketFactory = url => new WebSocket(url, { headers: { cookie } }) + await ctx.plugin(TypertRegistry) + installConnection(ctx, { + transport: { + streamBaseUrl: origin, + webSocket, + fetch: doFetch ?? ((input, init) => fetch(new URL(input, origin), { + ...init, headers: { ...Object.fromEntries(new Headers(init.headers)), cookie }, + })), + }, + }) + if (forwardedEvents === 'page') vi.stubGlobal(GATEWAY_CLIENT_CONFIG_GLOBAL, { forwardedEvents: true }) + const gateway = typeof forwardedEvents === 'boolean' + ? ctx.plugin({ inject, apply, Config }, { forwardedEvents }) + : ctx.plugin({ inject, apply, Config }) + await gateway + const unmount = await ctx.remote.$mount(contribution()) + return { ctx, gateway, unmount, connection: ctx.get('connection') as ConnectionHandle } +} + +describe('passive Remote composition', () => { + it.each(['contribution', 'gateway', 'context'] as const)('aborts and joins a pending response body when its %s closes', async (owner) => { + const reading = Promise.withResolvers() + const aborted = Promise.withResolvers() + const release = Promise.withResolvers() + const { ctx, gateway, unmount } = await client('http://127.0.0.1:1', async (_input, init) => { + const signal = init.signal + if (signal === undefined || signal === null) throw new Error('Remote request has no cancellation signal') + signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true }) + return new Response(new ReadableStream({ + async pull(controller) { + reading.resolve(undefined) + await release.promise + controller.error(new Error('response body stopped')) + }, + }, { highWaterMark: 0 })) + }) + const retained = ctx.remote.passiveProbe.echo + const invocation = retained('pending body') + await reading.promise + let disposed = false + const disposal = (owner === 'contribution' ? unmount() : owner === 'gateway' ? gateway.dispose() : ctx.fiber.dispose()) + .then(() => { disposed = true }) + try { + expect(await Promise.race([aborted.promise.then(() => true), disposal.then(() => false)])).toBe(true) + // The next event-loop turn observes disposal after its ready microtasks, while the body owner remains held. + await setImmediate() + expect(disposed).toBe(false) + } finally { + release.resolve(undefined) + await disposal + } + const outcome = await invocation + expect(outcome).toMatchObject({ ok: false, error: { code: 'gateway/cancelled' } }) + if (!outcome.ok) expect(isRemoteFailure(outcome.error)).toBe(true) + expect(await retained('after disposal')).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) + }) + + it('calls generated methods and cancels a stream without joining forwarded events', async () => { + const server = await host() + const { ctx, unmount, connection } = await client(server.origin) + expect(server.requests).toEqual([]) + expect(server.upgrades).toEqual([]) + expect(connection.generation.getSnapshot()).toBeUndefined() + + await expect(ctx.remote.passiveProbe.echo('typed value')).resolves.toEqual({ ok: true, value: 'typed value' }) + expect(server.requests).toEqual([{ url: '/api/passiveProbe/echo', cookie: 'session=fixture' }]) + expect(server.upgrades).toEqual([]) + + const stream = ctx.remote.passiveProbe.follow()[Symbol.asyncIterator]() + await expect(stream.next()).resolves.toEqual({ done: false, value: 'opening' }) + const waiting = stream.next() + const aborted = expect(waiting).rejects.toThrow() + await unmount() + await aborted + await expect.poll(() => server.frames.filter(frame => frame.type === 'cancel')).toHaveLength(1) + expect(server.frames.filter(frame => frame.type === 'open').map(frame => frame.endpoint)) + .toEqual(['passiveProbe/follow']) + expect(server.upgrades).toEqual(['session=fixture']) + expect(connection.generation.getSnapshot()).toBeUndefined() + + await ctx.fiber.dispose() + await expect.poll(() => server.sockets.clients.size).toBe(0) + }) + + it('stays passive unless forwarded events are explicitly configured', async () => { + const server = await host() + const opened = () => server.frames.filter(frame => frame.type === 'open').map(frame => frame.endpoint) + const passive = [await client(server.origin), await client(server.origin, undefined, false)] + for (const { ctx, connection } of passive) { + await expect(ctx.remote.passiveProbe.echo('passive')).resolves.toEqual({ ok: true, value: 'passive' }) + expect(connection.generation.getSnapshot()).toBeUndefined() + expect(() => ctx.remote.$on('passive-probe/changed', () => {})).toThrow('needs forwardedEvents') + expect(() => ctx.remote.$stream({ name: 'fixture stream', open: () => ctx.remote.passiveProbe.follow() })) + .toThrow('needs forwardedEvents') + } + expect(opened()).toEqual([]) + + const configured = await client(server.origin, undefined, true) + await expect.poll(() => configured.connection.generation.getSnapshot()?.host.home).toBe('/fixture/home') + expect(opened()).toEqual(['$events']) + await expect(configured.ctx.remote.passiveProbe.echo('joined')).resolves.toEqual({ ok: true, value: 'joined' }) + expect(() => configured.ctx.remote.$on('passive-probe/changed', () => {})).not.toThrow() + + // A browser page reads the choice its Host Gateway row published. + const page = await client(server.origin, undefined, 'page') + await expect.poll(() => page.connection.generation.getSnapshot()?.host.home).toBe('/fixture/home') + expect(opened()).toEqual(['$events', '$events']) + + const overridden = await client(server.origin, undefined, false) + await expect(overridden.ctx.remote.passiveProbe.echo('passive page')).resolves.toEqual({ ok: true, value: 'passive page' }) + expect(overridden.connection.generation.getSnapshot()).toBeUndefined() + expect(opened()).toEqual(['$events', '$events']) + + for (const { ctx } of [overridden, page, configured, ...passive]) await ctx.fiber.dispose() + await expect.poll(() => server.sockets.clients.size).toBe(0) + }) +}) diff --git a/packages/api/gateway/tests/strict-source.host.spec.ts b/packages/api/gateway/tests/strict-source.host.spec.ts new file mode 100644 index 0000000000..dc0d654838 --- /dev/null +++ b/packages/api/gateway/tests/strict-source.host.spec.ts @@ -0,0 +1,77 @@ +import { Context, Service } from '@deepseek-ai/cordis' +import { afterEach, expect, it } from 'vitest' +import { z } from 'zod' +import { bindTypertRemote, Remote } from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry, { type TypertContribution } from '@deepseek-ai/dsh-typert-registry' +import TypertGateway from '../src/index.ts' + +const contexts: Context[] = [] +afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) }) + +const contribution: TypertContribution = { + package: '@fixture/strict', face: 'host', schemas: [], + model: { services: [], events: [], objects: [] }, + invocations: [{ + id: '@fixture/strict#items/read', service: 'items', namespace: 'items', method: 'read', + invocation: { kind: 'direct' }, + parameters: [{ name: 'request', wire: 'request', source: 'json', codec: { + mode: 'strict', typeSymbol: 'Request', create: () => z.strictObject({ title: z.string() }), + } }], + result: { mode: 'strict', typeSymbol: 'string', create: () => z.string() }, + }], +} + +class ItemsService extends Service { + readonly typertRemote + calls = 0 + constructor(ctx: Context) { + super(ctx, 'items') + this.typertRemote = bindTypertRemote(this, 'items') + } + @Remote({ rejectUnknownFields: true }) + read(request: { title: string }): string { + this.calls++ + return request.title + } +} + +async function setup() { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(TypertRegistry) + await ctx.plugin(TypertGateway) + const service = new ItemsService(ctx) + return { ctx, service } +} + +it('validates source dispatch with the ordinary local Registry before invoking the Service', async () => { + const { ctx, service } = await setup() + ctx.typert.register(contribution) + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready' } } })) + .resolves.toBe('ready') + for (const args of [{}, { request: { title: 'ready', extra: true } }, { request: { title: 1 } }]) { + const code: unknown = expect.stringMatching(/^gateway\/(arguments|input)-invalid$/) + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args })) + .rejects.toMatchObject({ code }) + } + expect(service.calls).toBe(1) +}) + +it('allows limited source fallback without recovering erased object constraints', async () => { + const { ctx, service } = await setup() + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready', extra: true } } })) + .resolves.toBe('ready') + expect(service.calls).toBe(1) +}) + +it('refuses source fallback after the strict definition owner unloads', async () => { + const { ctx, service } = await setup() + const owner = ctx.plugin({ inject: ['typert'], apply(ctx) { ctx.typert.register(contribution) } }) + await owner + expect(ctx.typert.local.get('items/read')).toBe(contribution.invocations[0]) + await owner.dispose() + expect(ctx.typert.local.get('items/read')).toBeUndefined() + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready' } } })) + .rejects.toMatchObject({ code: 'gateway/definition-unavailable' }) + expect(service.calls).toBe(0) +}) diff --git a/packages/api/job-controller/tests/client-apply.client.spec.ts b/packages/api/job-controller/tests/client-apply.client.spec.ts index 39db59f0dc..4a46e36619 100644 --- a/packages/api/job-controller/tests/client-apply.client.spec.ts +++ b/packages/api/job-controller/tests/client-apply.client.spec.ts @@ -25,6 +25,7 @@ async function mount(): Promise<{ ctx: Context; observeCalls: unknown[]; rowsCal generation: { getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), subscribe: () => () => {} }, state: { getSnapshot: () => 'connected' as const, subscribe: () => () => {} }, rpc: { call: () => Promise.reject(new Error('unexpected generic RPC call')) }, + openWebSocket: () => { throw new Error('unexpected WebSocket open') }, reconnect: () => {}, registerGenerationSource: () => () => {}, start: () => ({ stop: () => {} }), diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index c8686dd4e2..31efa03564 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/README.i18n.yaml @@ -15,14 +15,14 @@ en: a7a78c6d1aeebeb3 zh: 4bb69f32d8b05ba3 /deepseek-ai-dsh-api-remotes/use-this-package: - en: a80d44e0683768a5 - zh: 379833ac180e5418 + en: 5bd571345016ec7e + zh: df43440e9c8b3aa9 /deepseek-ai-dsh-api-remotes/forwarded-host-events: en: d6d82bf0e55ae5e9 zh: c3a0c80f8b115039 /deepseek-ai-dsh-api-remotes/build-boundary: - en: 7a9934a3ba5b185f - zh: 40457fd5707f57f0 + en: bbce545687d036b9 + zh: db1a6edf3508e9bb /deepseek-ai-dsh-api-remotes/model-experience: en: 6a60ae036c71e76f zh: bf5e86a5ac5b4be4 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index f479777030..99f9cbe4d9 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -29,11 +29,11 @@ Two-sided BFF for Host Remote capabilities selected by this application. The Hos [`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.md) owns Agent and Session identity policy, including the Typert lookup resolvers used by other namespaces. This package selects and mounts that generated Session contribution without a direct runtime dependency on `@deepseek-ai/dsh-session`; activation policy stays with Session Controller. -The Client assembly mounts Office conversion, Commands, credentials, settings, Goal, Schedule, dynamic Cordis, file and Session references, read-only Host plugin inventory, plugin-installation registry response comparison, message feedback, permission presets, Session Controller, subagents, and Workspace Controller contributions. The `permissionPresets` namespace returns the complete process-level catalog used by current-session controls. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. +The Client assembly mounts Office conversion, Commands, credentials, settings, Goal, Schedule, dynamic Cordis, file and Session references, read-only Host plugin inventory, plugin-installation registry response comparison, message feedback, permission presets, Session Controller, subagents, and Workspace Controller contributions. The `permissionPresets` namespace returns the complete process-level catalog used by current-session controls. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. Each ordinary contribution remains a mountable `TYPERT_REMOTE`; consumers needing static method information use its typed `methods` index rather than another metadata export (see the [generator reference](<../../typert/generator/README.md#ordinary-remote-metadata>)). The Client entry calls the typed Gateway service through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. This facade is also the front door for the wire type vocabulary a Client package names. It re-exports, type-only, the Remote failure vocabulary (`RemoteResult`, `RemoteFailure`, `RemoteErrorCode`, `RemoteErrorDetailsMap`), the Host facts (`RemoteHostFacts`), and each selected domain's client-safe payload types, so a Client feature package imports one specifier instead of reaching into `dsh-typert-protocol`, the Gateway, or an owner's Host entry. Two kinds of package deliberately skip this door: the API-layer packages this assembly itself selects — importing it back would close a dependency cycle — and their tests, which take the failure vocabulary from `dsh-typert-protocol` directly. A UI package's tests instead take the `RemoteError` constructor from [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md). -This package owns no physical transport or Host service discovery. It projects the application selection into generated Remote contributions and an independent Host event source per Client; API Gateway owns endpoints, carriers, cancellation, and reconnection. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract. +This package owns no physical transport or Host service discovery. It projects the application selection into generated Remote contributions and an independent Host event source per Client; API Gateway owns endpoints, carriers, cancellation, and reconnection. The Web bundle sets the Host Gateway row's `forwardedEvents: true`, so its served pages join forwarded events. A passive Node composition instead mounts the Gateway Client without that option and with its selected generated contributions, without this Web assembly; it shares the same React-free Remote methods. ----- @@ -59,6 +59,8 @@ That exception is not just a `files` entry. The root `tsconfig.base.json` maps ` The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; split only when the two source sets require different compiler faces. +The [Session consumer artifact test](tests/session-client.e2e.ts) independently compiles a test-local Registry/CLI/MCP adaptation against ordinary declarations, then runs plain Node with real SessionController HTTP/WebSocket calls. It covers current `session/page` and `session/follow`, offline tool selection, input rejection, current-session injection, cancellation, and disposal. Its manual Context composition is not a supported application entry or a full downstream Loader/profile integration; unavailable attachment preparation rejects explicitly. + ## Model Experience diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index b130e28b76..71a5ea30e6 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -29,11 +29,11 @@ kind: "package-reference" [`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.zh.md) 拥有 agent(智能体)与会话身份策略,包括供其他 namespace 使用的 Typert lookup 解析器。本包选择并挂载生成的会话 contribution,无需直接依赖 `@deepseek-ai/dsh-session` 的运行时;激活策略由会话控制器负责。 -Client 组合挂载 Office 转换、Commands、凭据、settings、Goal、Schedule、动态 Cordis、文件与会话引用、只读 Host 插件清单、插件安装源响应比较、消息反馈、权限预设、会话控制器、subagents 和 Workspace 控制器 contribution。`permissionPresets` namespace 返回 current-session 控件使用的完整进程级目录。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪的 namespace 服务、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 +Client 组合挂载 Office 转换、Commands、凭据、settings、Goal、Schedule、动态 Cordis、文件与会话引用、只读 Host 插件清单、插件安装源响应比较、消息反馈、权限预设、会话控制器、subagents 和 Workspace 控制器 contribution。`permissionPresets` namespace 返回 current-session 控件使用的完整进程级目录。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪的 namespace 服务、直接与作用域方法、调用、流与取消。每项普通贡献仍是可挂载的 `TYPERT_REMOTE`;需要静态方法信息的消费方使用其类型化 `methods` 索引,而非另一份元数据导出(参见[生成器参考](<../../typert/generator/README.zh.md#ordinary-remote-metadata>))。Client 入口通过 Cordis 调用带类型的 Gateway 服务,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 本 facade 同时是 Client 包指称 wire 类型词汇的正门。它以 type-only 方式转出 Remote 失败词汇(`RemoteResult`、`RemoteFailure`、`RemoteErrorCode`、`RemoteErrorDetailsMap`)、Host 事实(`RemoteHostFacts`),以及各已选领域对 Client 安全的载荷类型,因此 Client 功能包只 import 一个 specifier,不必伸手进 `dsh-typert-protocol`、Gateway 或某个拥有方的 Host 入口。有两类包刻意不走这道门:本装配自己选中的 API 层包——反向 import 会形成依赖环——以及它们的测试,后者直接从 `dsh-typert-protocol` 取失败词汇。UI 包的测试则从 [`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 取 `RemoteError` 构造器。 -本包不拥有物理传输或 Host 服务发现。它只把应用选择投影为生成的 Remote contribution,以及每个 Client 各自独立的 Host 事件源;API Gateway 负责 endpoint、carrier、取消与重连。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。 +本包不拥有物理传输或 Host 服务发现。它只把应用选择投影为生成的 Remote contribution,以及每个 Client 各自独立的 Host 事件源;API Gateway 负责 endpoint、carrier、取消与重连。Web bundle 设置 Host Gateway 行的 `forwardedEvents: true`,因此其已服务的页面会加入转发事件。被动 Node 组合改为不带该选项挂载 Gateway Client 及所需的生成贡献,不挂载这一 Web 装配;它使用同一组不依赖 React 的 Remote 方法。 ----- @@ -59,6 +59,8 @@ Host entry 为每条 Client 流独立注册一组 allowlist listener 和一个 包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project,并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle;只有两组源码需要不同 compiler face 时才拆分。 +[Session 消费方产物测试](tests/session-client.e2e.ts)将测试专用 Registry/CLI/MCP 适配对普通声明独立编译,再使用普通 Node 发起真实 SessionController HTTP/WebSocket 调用。它覆盖当前 `session/page` 和 `session/follow`、离线工具选择、输入拒绝、当前 Session 注入、取消及释放。其手动 Context 组合不是受支持的应用入口,也不是完整下游 Loader/profile 集成;不可用的附件准备会显式拒绝。 + ## 模型体验 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 1ac78ce12f..bb2f119008 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -81,13 +81,19 @@ "@deepseek-ai/dsh-permission-presets": "workspace:*", "@deepseek-ai/dsh-schedule": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-session-query": "workspace:*", "@deepseek-ai/dsh-session-reference": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-typert-registry": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/dsh-user-questions": "workspace:*", "@deepseek-ai/cordis": "workspace:~", + "typescript": "^6.0.3", + "ws": "^8.21.3", "zod": "4.4.3", "@deepseek-ai/dsh-command-feedback": "workspace:*", "@deepseek-ai/dsh-api-terminal-controller": "workspace:*", diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index 991c1ac46e..e725262bbe 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -175,7 +175,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { '@deepseek-ai/dsh-api-remotes', ]) { const plugin = instantiate(id) - await client.plugin({ inject: plugin.inject, apply: plugin.apply }) + await client.plugin({ inject: plugin.inject, apply: plugin.apply, Config: plugin.Config }) } client.typert.contexts.registerClient('agent', { identity: candidate => candidate.builtAgentId, diff --git a/packages/api/remotes/tests/fixtures/session-client/client.ts b/packages/api/remotes/tests/fixtures/session-client/client.ts new file mode 100644 index 0000000000..5ec9786c2e --- /dev/null +++ b/packages/api/remotes/tests/fixtures/session-client/client.ts @@ -0,0 +1,124 @@ +/** Test-local extraction of Session Client selection, intake projection, and invocation. */ + +import { z } from 'zod' +import type { TypertClientRemote, TypertRemoteRegistry } from '@deepseek-ai/dsh-typert-protocol' +import { isTypertRemoteMethod } from '@deepseek-ai/dsh-typert-protocol/remote-info' +import type { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +/** Only explicitly selected endpoints become local operations. */ +export const selectedEndpoints = ['session/page', 'session/follow'] as const +/** Statically known endpoint names; Registry lookup itself remains runtime-typed. */ +export type Endpoint = typeof selectedEndpoints[number] +/** The ordinary generated artifact retains exact named arguments for known callers. */ +export type PageArguments = Parameters[1] +/** Successful output comes from the generated parser rather than a duplicate declaration. */ +export type PageOutput = ReturnType['parse']> + +/** Local wrapper retained from Session Client; neither selected operation accepts content. */ +export interface LocalRemoteInput { + readonly arguments: Arguments + readonly current?: true + readonly content?: readonly unknown[] +} + +const intake = z.strictObject({ + arguments: z.record(z.string(), z.unknown()), + current: z.literal(true).optional(), +}) + +function object(value: unknown): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError('Selected Session operation requires an object schema') + } + return value as Record +} + +/** + * Select documented operations from the caller's offline Registry without mounting a transport. + * @param registry - Registry whose contribution lifetime belongs to the caller. + * @returns local operation lookup, schema projection, and validated preparation. + */ +export function sessionOperations(registry: TypertRemoteRegistry) { + function method(endpoint: Endpoint) { + if (!selectedEndpoints.some(selected => selected === endpoint)) throw new Error(`Unselected Session endpoint: ${endpoint}`) + const descriptor = registry.get(endpoint) + if (descriptor === undefined || !isTypertRemoteMethod(descriptor)) throw new Error(`Missing generated method for ${endpoint}`) + if (!descriptor.description || !descriptor.parameters.find(parameter => parameter.wire === 'request')?.description) { + throw new Error(`Selected tool ${endpoint} requires method and request documentation`) + } + if (descriptor.input.jsonSchema === undefined) { + throw new Error(`Selected tool ${endpoint} requires an input JSON Schema: ${descriptor.input.jsonSchemaError ?? 'unavailable'}`) + } + return descriptor + } + + function inputSchema(endpoint: Endpoint): Readonly> { + const schema = method(endpoint).input.jsonSchema + const branches = [false, true].map((current) => { + const argumentsSchema = structuredClone(object(schema)) + if (current) { + const request = object(object(argumentsSchema.properties).request) + const properties = object(request.properties) + request.properties = Object.fromEntries(Object.entries(properties).filter(([key]) => key !== 'address')) + request.additionalProperties = false + request.required = z.array(z.string()).parse(request.required).filter(key => key !== 'address') + } + return { + type: 'object', + properties: { + arguments: argumentsSchema, + ...(current ? { current: { type: 'boolean', const: true } } : {}), + }, + required: ['arguments', ...(current ? ['current'] : [])], + additionalProperties: false, + } + }) + return { oneOf: branches } + } + + function prepare(endpoint: Endpoint, input: unknown, callerSessionId?: SessionId): object { + if (typeof input === 'object' && input !== null && 'content' in input) { + throw new TypeError(`${endpoint} does not accept local content`) + } + const parsed = intake.parse(input) + z.fromJSONSchema(inputSchema(endpoint)).parse(parsed) + const args = structuredClone(parsed.arguments) + if (parsed.current === true) { + if (!callerSessionId) throw new Error('current requires caller Session identity') + args.request = { ...object(args.request), address: { kind: 'session', sessionId: callerSessionId } } + } + return method(endpoint).input.create().parse(args) + } + + return { method, inputSchema, prepare, list: () => selectedEndpoints.map(endpoint => ({ endpoint, method: method(endpoint) })) } +} + +/** + * Invoke through an already-mounted ordinary Remote; own neither its Context nor discovery. + * @param operations - offline selection and intake projection. + * @param remote - caller-owned mounted Gateway Client. + * @param callerSessionId - optional identity used only by the current convenience. + * @returns unary calls and complete, cancellable stream handles. + */ +export function sessionClient( + operations: ReturnType, + remote: TypertClientRemote, + callerSessionId?: SessionId, +) { + return { + async call(endpoint: Endpoint, input: unknown, signal?: AbortSignal): Promise { + const method = operations.method(endpoint) + if (method.mode === 'stream') throw new Error(`${endpoint} requires stream()`) + const result = await method.invoke(remote, operations.prepare(endpoint, input, callerSessionId), signal) + if (!result.ok) throw result.error + return method.result.create().parse(result.value) + }, + stream(endpoint: Endpoint, input: unknown, signal?: AbortSignal) { + const method = operations.method(endpoint) + if (method.mode !== 'stream') throw new Error(`${endpoint} requires call()`) + // Gateway validates each item; preserve the full handle, including send/end/dispose. + return method.invoke(remote, operations.prepare(endpoint, input, callerSessionId), signal) + }, + } +} diff --git a/packages/api/remotes/tests/fixtures/session-client/mcp.ts b/packages/api/remotes/tests/fixtures/session-client/mcp.ts new file mode 100644 index 0000000000..18299e62c9 --- /dev/null +++ b/packages/api/remotes/tests/fixtures/session-client/mcp.ts @@ -0,0 +1,82 @@ +/** Test-local MCP presentation and result envelopes; no server, transport, or automatic exposure. */ + +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import type { sessionClient, sessionOperations } from './client.ts' + +const failureSchema = { + type: 'object', properties: { + ok: { const: false }, + error: { + type: 'object', properties: { code: { type: 'string' }, message: { type: 'string' }, details: { type: 'object' } }, + required: ['code', 'message', 'details'], additionalProperties: false, + }, + }, + required: ['ok', 'error'], additionalProperties: false, +} + +function withoutDescriptions(schema: Readonly>): Record { + const result = structuredClone(schema) + function strip(value: unknown): void { + if (typeof value !== 'object' || value === null) return + if (Array.isArray(value)) { value.forEach(strip); return } + const node = value as Record + delete node.description + if (typeof node.properties === 'object' && node.properties !== null) Object.values(node.properties).forEach(strip) + strip(node.items) + strip(node.oneOf) + } + strip(result) + return result +} + +/** + * Project generated schemas into MCP envelopes without requiring an output projection. + * @param input - required local intake schema. + * @param output - optional generated output JSON Schema, independent of the output parser. + * @returns detached input fields and an output envelope only when projectable. + */ +export function mcpSchemas(input: Readonly>, output?: Readonly>) { + return { + inputSchema: { ...withoutDescriptions(input), type: 'object' }, + ...(output === undefined ? {} : { outputSchema: { + type: 'object', oneOf: [ + { type: 'object', properties: { ok: { const: true }, value: withoutDescriptions(output) }, required: ['ok', 'value'], additionalProperties: false }, + failureSchema, + ], + } }), + } +} + +/** + * Present the explicitly selected finite operation; stream endpoints are not MCP tools. + * @param operations - offline Registry-backed operation selection. + * @returns an MCP tools/list entry with detached validation fields. + */ +export function pageTool(operations: ReturnType) { + return { + name: 'remote_session_page', + description: 'Read Session or subagent history without starting it. Records are oldest first. Use the opening follow cursor as throughSeq and the first record\'s sequence as beforeSeq to read older history when hasMore is true. maxMessages limits complete messages; turnWindow requests minimum messages and turns within that limit. To target the calling Session, set current: true and omit its target from arguments.request. This requires caller identity from the selected server.', + ...mcpSchemas(operations.inputSchema('session/page'), operations.method('session/page').result.jsonSchema), + } +} + +/** + * Run the selected unary operation with CLI-compatible success/failure envelopes. + * @param client - mounted Session Client whose executor validates intake and output. + * @param input - untrusted local wrapper supplied by an MCP caller. + * @param signal - caller-owned cancellation. + * @returns identical text and structured results, with isError on failures. + */ +export async function callPageTool(client: ReturnType, input: unknown, signal?: AbortSignal) { + try { + const success = { ok: true, value: await client.call('session/page', input, signal) } + return { content: [{ type: 'text', text: JSON.stringify(success) }], structuredContent: success } + } catch (error) { + const remote = remoteErrorOf(error) + const failure = { ok: false, error: remote !== undefined + ? { code: remote.code, message: remote.message, details: remote.details } + : { code: 'session-client/invalid', message: error instanceof Error ? error.message : String(error), details: {} }, + } + return { content: [{ type: 'text', text: JSON.stringify(failure) }], structuredContent: failure, isError: true } + } +} diff --git a/packages/api/remotes/tests/fixtures/session-client/run.mjs b/packages/api/remotes/tests/fixtures/session-client/run.mjs new file mode 100644 index 0000000000..32629c3923 --- /dev/null +++ b/packages/api/remotes/tests/fixtures/session-client/run.mjs @@ -0,0 +1,259 @@ +/** Plain-Node artifact integration: offline consumer, real Session history, HTTP, and WebSocket. */ + +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import { once } from 'node:events' +import { Context } from '@deepseek-ai/cordis' +import { z } from 'zod' +import WebSocket from 'ws' +import Registry from '@deepseek-ai/dsh-typert-registry' +import * as connectionHost from '@deepseek-ai/dsh-client-connection' +import Gateway from '@deepseek-ai/dsh-api-gateway' +import { AgentRegistry } from '@deepseek-ai/dsh-agent' +import { SessionStore } from '@deepseek-ai/dsh-session' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SessionQueryEngine from '@deepseek-ai/dsh-session-query' +import SessionController from '@deepseek-ai/dsh-api-session-controller' +import { TYPERT } from '@deepseek-ai/dsh-api-session-controller/typert' +import { sessionOperations, sessionClient } from './client.js' +import { pageTool, callPageTool, mcpSchemas } from './mcp.js' + +// Search is outside this fixture; point observations and history windows use the real engine. +class PointQuery extends SessionQueryEngine { + searchSessions() { throw new Error('Search is not selected') } + searchEvents() { throw new Error('Search is not selected') } +} + +const offline = new Context() +const host = new Context() +const client = new Context() +const routes = [] +const upgrades = [] +const records = new Map() +const counters = { http: 0, sockets: 0, uploads: 0 } +const nativeFetch = globalThis.fetch +const nativeWebSocket = globalThis.WebSocket +let server +try { + for (const name of ['window', 'document', 'location']) assert.equal(name in globalThis, false) + globalThis.fetch = () => { throw new Error('Offline metadata attempted network access') } + globalThis.WebSocket = class { constructor() { throw new Error('Offline metadata opened a socket') } } + const [registryClient, { installConnection }, gatewayClient, { TYPERT_REMOTE }] = await Promise.all([ + import('@deepseek-ai/dsh-typert-registry/client'), + import('@deepseek-ai/dsh-client-connection/client'), + import('@deepseek-ai/dsh-api-gateway/client'), + import('@deepseek-ai/dsh-api-session-controller/remote'), + ]) + await offline.plugin(registryClient) + const offlineOwner = offline.plugin({ + inject: ['typert'], + apply(ctx) { ctx.effect(() => ctx.typert.remotes.register(TYPERT_REMOTE)) }, + }) + await offlineOwner + const operations = sessionOperations(offline.typert.remotes) + assert.deepEqual(operations.list().map(item => item.endpoint), ['session/page', 'session/follow']) + assert.throws(() => operations.method('workspace/list'), /Unselected/) + for (const { endpoint, method } of operations.list()) { + assert.equal(method, TYPERT_REMOTE.methods[endpoint]) + assert.equal(method, TYPERT_REMOTE.descriptors.find(item => `${item.namespace}/${item.method}` === endpoint)) + assert.equal('metadata' in method, false) + assert.ok(method.description) + assert.ok(method.parameters.find(parameter => parameter.wire === 'request').description) + assert.equal(typeof method.input.create().parse, 'function') + assert.equal(typeof method.result.create().parse, 'function') + } + const originalSchema = structuredClone(TYPERT_REMOTE.methods['session/page'].input.jsonSchema) + const tool = pageTool(operations) + assert.equal(tool.name, 'remote_session_page') + assert.equal(tool.description, 'Read Session or subagent history without starting it. Records are oldest first. Use the opening follow cursor as throughSeq and the first record\'s sequence as beforeSeq to read older history when hasMore is true. maxMessages limits complete messages; turnWindow requests minimum messages and turns within that limit. To target the calling Session, set current: true and omit its target from arguments.request. This requires caller identity from the selected server.') + assert.equal(JSON.stringify(tool.inputSchema).includes('"description"'), false) + // Exercise optional presentation without manufacturing replacement controller metadata. + const inputOnly = mcpSchemas(operations.inputSchema('session/page')) + assert.equal('outputSchema' in inputOnly, false) + assert.deepEqual(inputOnly.inputSchema, tool.inputSchema) + assert.deepEqual(TYPERT_REMOTE.methods['session/page'].input.jsonSchema, originalSchema) + assert.equal(counters.http + counters.sockets, 0) + assert.throws(() => operations.prepare('session/page', { arguments: { request: { throughSeq: -1 } }, current: true }), /caller Session identity/) + assert.throws(() => operations.prepare('session/page', { arguments: {}, content: [] }), /does not accept local content/) + assert.throws(() => operations.prepare('session/page', { arguments: { request: { throughSeq: 'bad' } }, current: true }, 'session-fixture')) + globalThis.fetch = nativeFetch + globalThis.WebSocket = nativeWebSocket + + host.provide('webServer', { + register(route) { routes.push(route); return () => routes.splice(routes.indexOf(route), 1) }, + registerUpgrade(route) { upgrades.push(route); return () => upgrades.splice(upgrades.indexOf(route), 1) }, + tapIndex() { return () => {} }, port: 0, + }) + host.provide('credentials', { + readRecord(key) { return Promise.resolve(records.get(key)) }, + async modifyRecord(key, mutate) { + const current = records.get(key) + const next = await mutate(current) + if (next !== undefined) records.set(key, next) + return next ?? current + }, + }) + // Unselected upload operations are never called; only the constructor's registration is supplied. + host.provide('fileUploads', { + registerAgentResolver() { counters.uploads++; return () => { counters.uploads-- } }, + }) + await host.plugin({ inject: connectionHost.inject, apply: connectionHost.apply }) + await host.plugin(Registry) + await host.plugin(AgentRegistry) + await host.plugin(SessionStore) + await host.plugin(SessionProjectionRegistry) + await host.plugin(PointQuery) + await host.plugin(Gateway) + const session = host.sessions.create(undefined, { meta: { cwd: process.cwd() } }) + session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'offline history' }], source: { kind: 'user' } }), { surfaceOp: 'append' }) + const hostOwner = host.plugin({ + inject: ['typert', 'agents', 'sessions', 'sessionQuery', 'sessionProjections', 'fileUploads'], + apply(ctx) { + new SessionController(ctx, {}) + ctx.effect(() => ctx.typert.register(TYPERT)) + }, + }) + await hostOwner + assert.equal(counters.uploads, 1) + assert.equal(host.typert.local.get('session/page'), TYPERT.invocations.find(item => item.method === 'page')) + assert.equal(routes.length, 1) + assert.equal(upgrades.length, 1) + server = createServer((request, response) => { + if ((request.url ?? '/').startsWith('/?')) { + if (host.connection.authorizeIndex(request, response)) response.end('Session test') + return + } + void routes[0].handler(request, response).catch(error => { response.destroy(error) }) + }) + server.on('upgrade', (request, socket, head) => { + const route = upgrades.find(item => item.path === new URL(request.url, 'http://localhost').pathname) + if (route === undefined) socket.destroy() + else route.handler(request, socket, head) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const origin = `http://127.0.0.1:${server.address().port}/` + const login = await fetch(host.connection.authenticatedUrl(origin), { redirect: 'manual' }) + assert.equal(login.status, 303) + const cookie = login.headers.get('set-cookie').split(';', 1)[0] + await login.body?.cancel() + + await client.plugin(registryClient) + await client.plugin({ + apply(ctx) { + installConnection(ctx, { transport: { + fetch(input, init) { + counters.http++ + const headers = new Headers(init.headers) + headers.set('cookie', cookie) + return fetch(new URL(input, origin), { ...init, headers }) + }, + streamBaseUrl: origin, + webSocket(url) { counters.sockets++; return new WebSocket(url, { headers: { cookie } }) }, + } }) + }, + }) + await client.plugin(gatewayClient) + const mounted = client.plugin({ + inject: ['remote'], + async apply(ctx) { await ctx.remote.$mount(TYPERT_REMOTE) }, + }) + await mounted + assert.equal(counters.http + counters.sockets, 0, 'passive install/mount must not subscribe') + assert.equal(client.connection.generation.getSnapshot(), undefined) + const calls = sessionClient(operations, client.remote, session.id) + const address = { kind: 'session', sessionId: session.id } + const args = { request: { address, throughSeq: 0 } } + const valid = { arguments: args } + const local = { arguments: { request: { throughSeq: 0 } }, current: true } + assert.deepEqual(operations.prepare('session/page', local, session.id), args) + assert.throws(() => operations.prepare('session/page', { arguments: args, current: true }, session.id)) + const direct = await TYPERT_REMOTE.methods['session/page'].invoke(client.remote, args) + assert.equal(direct.ok, true) + assert.equal(direct.value.records.length, 1) + assert.equal(direct.value.hasMore, false) + assert.deepEqual(await calls.call('session/page', local), direct.value) + assert.equal(counters.http, 2) + assert.equal(counters.sockets, 0) + const malformed = await TYPERT_REMOTE.methods['session/page'].invoke(client.remote, { request: { address, throughSeq: 'bad' } }) + assert.equal(malformed.ok, false) + const before = counters.http + const invalid = await callPageTool(calls, { arguments: { request: { throughSeq: 'bad' } }, current: true }) + assert.equal(invalid.isError, true) + assert.equal(invalid.structuredContent.ok, false) + assert.equal(counters.http, before, 'invalid local inputs never reach HTTP') + assert.deepEqual(JSON.parse(invalid.content[0].text), invalid.structuredContent) + const success = await callPageTool(calls, valid) + assert.deepEqual(success.structuredContent, { ok: true, value: direct.value }) + assert.deepEqual(JSON.parse(success.content[0].text), success.structuredContent) + const failure = await callPageTool(calls, { arguments: { request: { address, throughSeq: 999 } } }) + assert.equal(failure.isError, true) + assert.equal(failure.structuredContent.ok, false) + assert.deepEqual(JSON.parse(failure.content[0].text), failure.structuredContent) + if (tool.outputSchema !== undefined) { + const envelope = z.fromJSONSchema(tool.outputSchema) + envelope.parse(success.structuredContent) + envelope.parse(failure.structuredContent) + assert.throws(() => envelope.parse({ ok: true, value: { records: 'bad', hasMore: false } })) + } + assert.throws(() => TYPERT_REMOTE.methods['session/page'].result.create().parse({ records: 'bad', hasMore: false })) + // A corrupt response exercises the consumer's output parser without inventing descriptors. + const corrupt = sessionClient(operations, { session: { page: async () => ({ ok: true, value: { records: 'bad' } }) } }) + await assert.rejects(corrupt.call('session/page', valid)) + await assert.rejects(calls.call('session/follow', { arguments: { request: { address } } }), /requires stream/) + assert.throws(() => calls.stream('session/page', valid), /requires call/) + + const control = new AbortController() + const handle = calls.stream('session/follow', { arguments: { request: {} }, current: true }, control.signal) + for (const key of ['send', 'end', 'dispose']) assert.equal(typeof handle[key], 'function') + const iterator = handle[Symbol.asyncIterator]() + const opening = await iterator.next() + assert.equal(opening.done, false) + assert.equal(opening.value.type, 'snapshot') + assert.equal(opening.value.cursor, 0) + assert.equal(opening.value.records.length, 1) + handle.end() + const next = iterator.next() + session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'live history' }], source: { kind: 'user' } }), { surfaceOp: 'append' }) + const update = await next + assert.equal(update.value.type, 'event') + assert.equal(update.value.event.seq, 1) + const pending = iterator.next() + control.abort() + const [aborted] = await Promise.allSettled([pending]) + if (aborted.status === 'fulfilled') assert.equal(aborted.value.done, true) + handle.dispose() + await iterator.return?.() + assert.equal(counters.sockets, 1) + assert.equal(host.agents.list().length, 0, 'following a live Session needs no activation') + + // Unmount while a second stream is pending, then await all consumer/socket teardown. + const owned = calls.stream('session/follow', { arguments: { request: { address } } }) + const ownedIterator = owned[Symbol.asyncIterator]() + await ownedIterator.next() + const waiting = Promise.allSettled([ownedIterator.next()]) + await mounted.dispose() + assert.equal(client.typert.remotes.get('session/page'), undefined) + assert.deepEqual(client.typert.remotes.list(), []) + await client.fiber.dispose() + await waiting + await hostOwner.dispose() + assert.equal(host.typert.local.get('session/page'), undefined) + assert.deepEqual(host.typert.local.list(), []) + assert.equal(counters.uploads, 0) + await offlineOwner.dispose() + assert.deepEqual(offline.typert.remotes.list(), []) + assert.throws(() => operations.list(), /Missing generated method/) + console.log(JSON.stringify({ endpoints: ['session/page', 'session/follow'], http: counters.http, sockets: counters.sockets, realController: true })) +} finally { + globalThis.fetch = nativeFetch + globalThis.WebSocket = nativeWebSocket + await client.fiber.dispose() + await host.fiber.dispose() + if (server !== undefined) { + server.closeAllConnections() + await new Promise((resolve, reject) => server.close(error => error && error.code !== 'ERR_SERVER_NOT_RUNNING' ? reject(error) : resolve())) + } + await offline.fiber.dispose() +} diff --git a/packages/api/remotes/tests/fixtures/session-client/tsconfig.json b/packages/api/remotes/tests/fixtures/session-client/tsconfig.json new file mode 100644 index 0000000000..425ecb9956 --- /dev/null +++ b/packages/api/remotes/tests/fixtures/session-client/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023", "ESNext.Disposable", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": false, + "noEmitOnError": true, + "rewriteRelativeImportExtensions": true, + "rootDir": ".", + "types": [] + }, + "files": ["client.ts", "mcp.ts", "typing.ts"] +} diff --git a/packages/api/remotes/tests/fixtures/session-client/typing.ts b/packages/api/remotes/tests/fixtures/session-client/typing.ts new file mode 100644 index 0000000000..991ffee348 --- /dev/null +++ b/packages/api/remotes/tests/fixtures/session-client/typing.ts @@ -0,0 +1,28 @@ +/** Compile-only consumer checks against ordinary generated declarations, without source aliases. */ + +import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' +import type { RemoteResult, RemoteStreamHandle, TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' +import type { PageArguments, PageOutput } from './client.ts' + +declare const remote: TypertClientRemote +declare const input: PageArguments +declare const output: PageOutput +const unary: Promise> = TYPERT_REMOTE.methods['session/page'].invoke(remote, input) +const more: boolean = output.hasMore +const records: readonly object[] = output.records +// @ts-expect-error Page arguments retain the generated request fields. +void TYPERT_REMOTE.methods['session/page'].invoke(remote, { request: { throughSeq: 'bad' } }) +// @ts-expect-error Page output has no cursor; the follow snapshot supplies it. +void output.cursor +// @ts-expect-error Method info is the descriptor, not a metadata wrapper. +void TYPERT_REMOTE.methods['session/page'].metadata + +type Follow = typeof TYPERT_REMOTE.methods['session/follow'] +declare const followInput: Parameters[1] +type FollowOutput = ReturnType['parse']> +const stream: RemoteStreamHandle = TYPERT_REMOTE.methods['session/follow'].invoke(remote, followInput) +stream.end() +stream.dispose() +// @ts-expect-error Follow has no uplink messages. +stream.send({}) +void [unary, more, records] diff --git a/packages/api/remotes/tests/session-client.e2e.ts b/packages/api/remotes/tests/session-client.e2e.ts new file mode 100644 index 0000000000..ee49534c36 --- /dev/null +++ b/packages/api/remotes/tests/session-client.e2e.ts @@ -0,0 +1,56 @@ +/** Built-artifact Session Client regression; generated schemas, declarations, and runtime share one plane. */ + +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { copyFile, mkdtemp, rm } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import ts from 'typescript' +import { expect, it } from 'vitest' + +const directory = dirname(fileURLToPath(import.meta.url)) +const root = resolve(directory, '../../../..') +const fixture = join(directory, 'fixtures/session-client') +const required = [ + 'packages/api/session-controller/lib/typert.host.js', + 'packages/api/session-controller/lib/typert.remote-client.js', + 'packages/api/session-controller/lib/typert.remote-client.d.ts', + 'packages/api/session-controller/lib/index.js', + 'packages/api/gateway/lib/types/client/index.js', + 'packages/client/connection/lib/types/client/index.js', + 'packages/typert/registry/lib/types/client/index.js', + 'packages/typert/protocol/lib/types/remote-info.js', +] + +it.skipIf(required.some(path => !existsSync(join(root, path))))('runs the extracted Session Client against real controller artifacts in plain Node', async () => { + // Stay under the package for ordinary Node package resolution, with one private output per test. + const output = await mkdtemp(join(fixture, '.compiled-')) + try { + const config = ts.readConfigFile(join(fixture, 'tsconfig.json'), path => ts.sys.readFile(path)) + expect(config.error).toBeUndefined() + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, fixture, { outDir: output }) + const program = ts.createProgram(parsed.fileNames, parsed.options) + const diagnostics = [...parsed.errors, ...ts.getPreEmitDiagnostics(program)] + const formatted = ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCurrentDirectory: () => root, + getCanonicalFileName: path => path, + getNewLine: () => '\n', + }) + expect(formatted).toBe('') + expect(program.emit().emitSkipped).toBe(false) + await copyFile(join(fixture, 'run.mjs'), join(output, 'run.mjs')) + const result = await promisify(execFile)(process.execPath, [join(output, 'run.mjs')], { + cwd: root, + env: { ...process.env, NODE_OPTIONS: '' }, + timeout: 25_000, + maxBuffer: 2 * 1024 * 1024, + }) + const summary: unknown = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') + expect(summary).toMatchObject({ endpoints: ['session/page', 'session/follow'], realController: true, sockets: 1 }) + } finally { + // mkdtemp returns this test's absolute directory; never remove a caller-provided path. + if (dirname(output) !== fixture || !output.startsWith(join(fixture, '.compiled-'))) throw new Error('Unexpected fixture output path') + await rm(output, { recursive: true, force: true }) + } +}, 60_000) diff --git a/packages/api/session-controller/src/client/transport.ts b/packages/api/session-controller/src/client/transport.ts index fc2a11a0bb..5cb39c1514 100644 --- a/packages/api/session-controller/src/client/transport.ts +++ b/packages/api/session-controller/src/client/transport.ts @@ -119,9 +119,6 @@ export function createSessionControlStream( const stream = remote.$stream({ name: 'session control stream', open: signal => remote.session.control(signal), - ended: accepted => accepted - ? new RemoteStreamCarrierError('session control stream ended without a terminal result') - : new Error('session control stream ended before its opening snapshot'), ...(options.carrierFailed === undefined ? {} : { carrierFailed: options.carrierFailed }), }) return new RemoteSnapshotStream(stream, { diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index 87d8fd7479..781fec4fa9 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -695,7 +695,7 @@ describe('Session Client stream adapters', () => { before.start() await vi.waitFor(() => { expect(beforeFailed).toHaveBeenCalledOnce() }) expect(beforeFailed.mock.calls[0]?.[0]).toMatchObject({ - message: 'session control stream ended before its opening snapshot', + message: 'session control stream ended before its opening item', }) await before.dispose() diff --git a/packages/api/workspace-controller/src/client/index.ts b/packages/api/workspace-controller/src/client/index.ts index c3182102a7..304ee7899d 100644 --- a/packages/api/workspace-controller/src/client/index.ts +++ b/packages/api/workspace-controller/src/client/index.ts @@ -82,9 +82,6 @@ export function createWorkspaceStateStream( const stream = remote.$stream({ name: 'Workspace state stream', open: signal => remote.workspace.follow(signal), - ended: accepted => accepted - ? new RemoteStreamCarrierError('Workspace state stream ended without a terminal result') - : new Error('Workspace state stream ended before its opening snapshot'), ...(options.carrierFailed === undefined ? {} : { carrierFailed: options.carrierFailed }), }) return new RemoteSnapshotStream(stream, { diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index b74ddfb394..5c38138c98 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -268,7 +268,7 @@ describe('Workspace state stream', () => { { name: 'a normal end before the baseline', items: [] as WorkspaceFollowFrame[], - message: 'ended before its opening snapshot', + message: 'ended before its opening item', }, ])('reports $name as a terminal failure', async ({ items, message }, { mock, start }) => { const { remote } = await gatewayClient(mock, start) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index a1ca0b5483..d636e32692 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -19,6 +19,12 @@ personaPrefix: >- You are a coding agent powered by the {{model}} model. +# Served pages answer Agent-scoped approval and question requests, so their +# Gateway Client receives forwarded Host events. Other compositions stay passive. +- id: typert-gateway + config: + forwardedEvents: true + # Full-text session search is opt-in (the base row's `openAt: never`). This # restatement keeps the Web values on one ephemeral in-memory index; a # deployment enabling content search overrides `openAt` to `first-search` in a diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 9efedba955..f975701c7b 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -15,14 +15,14 @@ en: 18792bf2fb4f8030 zh: 84c63acc7e0099ae /deepseek-ai-dsh-client-connection/use-this-package: - en: 13b54969ff1d00a4 - zh: 4d2ffff0445472ed + en: d36fdf3a6cc76ec3 + zh: 96609d956c191a4b /deepseek-ai-dsh-client-connection/browser-authentication-and-request-trust: en: 44956be3cc11908a zh: d518f765d58f3558 /deepseek-ai-dsh-client-connection/connection-generation: - en: 116f94e44c0d87e7 - zh: 59ef48984154d858 + en: e5d588463967e80b + zh: de2e2c4b7d5815f5 /deepseek-ai-dsh-client-connection/model-experience: en: 88bbc33fe9ae9df8 zh: bfbab6d51b4abd40 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index d6cb58d42c..12ca49a12d 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -25,11 +25,11 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, and con ## Use this package -A static desktop page can provide `__DSH_TRANSPORT__.streamBaseUrl` for the HTTP origin of its owned Host. The Gateway uses that origin for its WebSocket while HTTP transport remains independently selected. The desktop carrier owns authentication; setting the origin alone grants no access. +`installConnection(ctx, { transport })` accepts instance-local `fetch`, `streamBaseUrl`, and `webSocket` hooks. Fetch receives document-relative routes; the composition resolves them against its selected Host and supplies authentication. The WebSocket factory receives an absolute URL and supplies the same authentication. Connection owns each created socket until its close event and joins physical closure on disposal. Without hooks it uses browser Fetch, WebSocket, and the document base. A static desktop page can supply these hooks through `__DSH_TRANSPORT__`; setting an origin alone grants no access. Node resolves `/client` to ordinary ESM, while browsers retain the Client bundle. Unary RPC requests use JSON. A Host handler may return byte attachments that it has already separated from the JSON-compatible result value, with each attachment naming its result-relative path. Connection writes these values as multipart parts. The JSON `metadata` part contains the RPC response envelope with `null` placeholders and an attachment table recording each path, codec, and part identifier. Paths use string keys and numeric array indices; no business field name is reserved. The Client validates the envelope, `rpcId`, attachment table, and parts, then restores each byte value as an `ArrayBuffer`-backed view. Results without attachments, including base64 strings, and failures remain JSON. Logical RPC carriers return decoded native values directly. Connection does not discover binary fields or depend on Typert; the handler that owns a result protocol performs any type-directed or runtime projection before returning. Binary parameters, events, and streamed binary results are unsupported. -The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams, including a stream's uplink, through `connection.rpc.open` without opening a WebSocket. The browser plugin reads the page transport, recovery settings, and location, then delegates to `installConnection(ctx, options)`. A composition that owns its carrier may call the same installer directly; the whole-client test tier does so. Each invocation creates one Context-owned service, so several Client trees can use different carriers in one realm. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). +The browser uses HTTP POST for Remote unary calls. API Gateway owns `/api/remote.mux` framing and logical streams over Connection-owned WebSockets; shell-owned compositions provide equivalent Remote streams, including a stream's uplink, through `connection.rpc.open` without opening a WebSocket. The browser plugin reads the page transport, recovery settings, and location, then delegates to `installConnection(ctx, options)`. A composition that owns its carrier may call the same installer directly; the whole-client test tier does so. Each invocation creates one Context-owned service, so several Client trees can use different carriers in one realm. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). ----- @@ -51,7 +51,7 @@ Authenticated shared HTTP requests pass through the `connection/request` waterfa ## Connection generation -API Gateway Client registers the internal `$events` logical stream as the sole generation source, independently of whether any `$on` listener exists. The Host attaches all incremental listeners in the API Remotes source factory, then sends one `{ type: 'ready', clientId, host: { home } }` item before events. `ConnectionController` publishes that generation and calls `onConnected` only after the ready item arrives, so baseline acquisition cannot race ahead of incremental observation. +The Gateway Client's `forwardedEvents` Config, set for Web pages by the Host Gateway row, registers the internal `$events` logical stream as the sole generation source. Passive compositions omit it; mounting Connection or invoking a generated Remote method does not start the event pump. The Host attaches all incremental listeners in the API Remotes source factory, then sends one `{ type: 'ready', clientId, host: { home } }` item before events. `ConnectionController` publishes that generation and calls `onConnected` only after the ready item arrives, so baseline acquisition cannot race ahead of incremental observation. An ended `$events` stream, a Remote stream error, a non-ready opening item, or a malformed event item invalidates the current generation. A pending handshake logs a slow-Host warning after 3 seconds and logs the readiness timeout and aborts after 15 seconds by default, including time spent waiting for the physical socket. The source must stop delivery, release resources, and settle after cancellation before a replacement starts; late readiness from a cancelled source cannot publish a generation. While the browser reports network availability, the controller publishes `connecting` and retries with 50%–100% jitter under caps of 500ms, 1s, 2s, 4s, 8s, and 10s, continuing at the final cap until recovery. Every retry asks Gateway to replace the physical WebSocket once and reopens `$events`. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 6ec954f84d..b76e187440 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -25,11 +25,11 @@ kind: "package-reference" ## 使用本包 -静态桌面页面可以通过 `__DSH_TRANSPORT__.streamBaseUrl` 提供其所拥有 Host 的 HTTP origin。Gateway 将该 origin 用于 WebSocket,HTTP 传输仍独立选择。桌面载体负责认证;仅设置 origin 不会授予访问权限。 +`installConnection(ctx, { transport })` 接受实例独立的 `fetch`、`streamBaseUrl` 与 `webSocket` hook。Fetch 收到文档相对路由,由组合按选定的 Host 解析并提供认证;WebSocket factory 收到绝对 URL,并提供相同认证。Connection 持有创建的每条 socket,直到其 close 事件,并在销毁时等待物理连接关闭。未提供 hook 时使用浏览器 Fetch、WebSocket 与文档 base。静态桌面页面可通过 `__DSH_TRANSPORT__` 提供这些 hook;仅设置 origin 不会授予访问权限。Node 将 `/client` 解析为普通 ESM,浏览器仍使用 Client bundle。 一元 RPC 请求使用 JSON。Host handler 可以返回已经从 JSON 兼容结果值中分离的字节附件,每个附件标明其相对于结果的路径。Connection 将这些值写为 multipart 部分。JSON `metadata` 部分包含带 `null` 占位值的 RPC 响应信封,以及记录各路径、codec 和部分标识符的附件表。路径使用字符串键与数字数组下标,不保留任何业务字段名。Client 校验信封、`rpcId`、附件表和各部分,再将每个字节值恢复为以 `ArrayBuffer` 为底层缓冲区的视图。没有附件的结果(包括 base64 字符串)与失败仍使用 JSON。逻辑 RPC 载体直接返回解码后的原生值。Connection 不识别二进制字段,也不依赖 Typert;拥有结果协议的 handler 在返回前执行按类型或按运行时值的投影。不支持二进制参数、事件和二进制流式结果。 -浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流(包括流的上行),不打开 WebSocket。浏览器插件读取页面 transport、恢复设置与 location,再委托 `installConnection(ctx, options)`。持有自身载体的组合可以直接调用同一个安装函数;整机客户端测试档就是这一消费者。每次调用都会创建一个归所属 Context 的服务,因此同一 realm 中的多棵 Client 树可以使用不同载体。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 +浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 在 Connection 持有的 WebSocket 上负责 `/api/remote.mux` 帧与逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流(包括流的上行),不打开 WebSocket。浏览器插件读取页面 transport、恢复设置与 location,再委托 `installConnection(ctx, options)`。持有自身载体的组合可以直接调用同一个安装函数;整机客户端测试档就是这一消费者。每次调用都会创建一个归所属 Context 的服务,因此同一 realm 中的多棵 Client 树可以使用不同载体。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 ----- @@ -51,7 +51,7 @@ HTTPS cookie 名称与签名 audience 包含 `https:` scheme,因此即使 host ## Connection generation -API Gateway Client 把内部 `$events` 逻辑流注册为唯一 generation source,与有无 `$on` 订阅无关。Host 在 API Remotes source factory 同步挂好所有增量 listener 后,先发送唯一 `{ type: 'ready', clientId, host: { home } }` 项,再发送事件。`ConnectionController` 仅在收到该 ready 项后发布 generation 并调用 `onConnected`,因此 baseline 不会跑在增量 listener 前面。 +Gateway Client 的 `forwardedEvents` Config(Web 页面由 Host Gateway 行设置)将内部 `$events` 逻辑流注册为唯一 generation source。被动组合不设置它;挂载 Connection 或调用生成的 Remote 方法都不会启动事件 pump。Host 在 API Remotes source factory 同步挂好所有增量 listener 后,先发送唯一 `{ type: 'ready', clientId, host: { home } }` 项,再发送事件。`ConnectionController` 仅在收到该 ready 项后发布 generation 并调用 `onConnected`,因此 baseline 不会跑在增量 listener 前面。 `$events` 结束、Remote 流报错、收到非 ready 首项或畸形事件项,都会使当前 generation 失效。默认情况下,挂起的握手在 3 秒后记录 Host 响应缓慢告警,在 15 秒后记录就绪超时并中止,包含等待物理 socket 的时间。取消后,source 必须停止投递、释放资源并结束,替换 source 才能启动;已取消 source 迟到的 ready 不能发布 generation。浏览器报告网络可用时,Controller 发布 `connecting`,并在 500ms、1s、2s、4s、8s 与 10s 上限内采用 50%–100% 抖动重试,达到终档后继续尝试直到恢复。每次重试都要求 Gateway 替换一次物理 WebSocket,再重开 `$events`。 diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 21e395fb6f..0691d4daf1 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -20,6 +20,7 @@ }, "./client": { "types": "./lib/types/client/index.d.ts", + "node": "./lib/types/client/index.js", "default": "./lib/client.js" }, "./src/*": "./src/*", @@ -43,6 +44,7 @@ "locale/*.json", "lib/index.js", "lib/client.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "peerDependencies": { diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index e9d80a64f4..035024640b 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -12,6 +12,7 @@ import { createWebConnectionRpc, type RpcFetch, type RpcStreamOpen } from './rpc import { isLoopbackHostname } from '../loopback-hostname.ts' import type { ClientConnectionRpc } from '../rpc.ts' import { resolveConnectionConfig } from '../recovery-config.ts' +import { ConnectionSockets, type ConnectionWebSocket, type ConnectionWebSocketFactory } from './socket.ts' declare module '@deepseek-ai/cordis' { interface Events { @@ -50,6 +51,7 @@ export type { ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult, } from '../rpc.ts' export type { RpcFetch } from './rpc.ts' +export type { ConnectionWebSocket, ConnectionWebSocketEventMap, ConnectionWebSocketFactory } from './socket.ts' /** Observable identity and Host facts for the active connection generation. */ export interface ConnectionGenerationState { @@ -104,6 +106,8 @@ export interface ClientTransportHooks { ownsHost?: boolean /** HTTP origin of a shell-owned Host when its WebSocket uses a different page origin. */ streamBaseUrl?: string + /** Physical socket factory, including shell-owned authentication; Connection owns socket disposal. */ + webSocket?: ConnectionWebSocketFactory } /** Page global carrying {@link ClientTransportHooks}; absent in the served web app. */ @@ -144,6 +148,12 @@ export interface ConnectionHandle { readonly state: ConnectionStateSource /** Generic logical RPC channels over the same Connection transport. */ readonly rpc: ClientConnectionRpc + /** + * Open a physical WebSocket on this Connection's selected Host or document mount. + * @param path - absolute logical channel path. + * @returns a socket owned until its close event; the consumer owns framing and retries. + */ + openWebSocket(path: string): ConnectionWebSocket /** Reset retry progression and replace the current attempt immediately. */ reconnect(): void /** @@ -207,6 +217,7 @@ export function installConnection(ctx: Context, options: ConnectionInstallOption const transport = options.transport const recovery = options.recovery ?? {} const rpc = transport?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream) + const sockets = new ConnectionSockets(transport?.webSocket, transport?.streamBaseUrl) let generationSource: ConnectionGenerationSource | undefined let owner: ConnectionOwner | undefined let generationId = 0 @@ -261,6 +272,7 @@ export function installConnection(ctx: Context, options: ConnectionInstallOption }, }, rpc, + openWebSocket: path => sockets.open(path), reconnect() { owner?.controller.reconnect() }, @@ -307,6 +319,10 @@ export function installConnection(ctx: Context, options: ConnectionInstallOption } }, } + ctx.effect(() => async () => { + if (owner !== undefined) releaseOwner(owner) + await sockets.dispose() + }, 'client-connection: physical carriers') ctx.provide('connection', handle) } diff --git a/packages/client/connection/src/client/socket.ts b/packages/client/connection/src/client/socket.ts new file mode 100644 index 0000000000..c76d6e8e23 --- /dev/null +++ b/packages/client/connection/src/client/socket.ts @@ -0,0 +1,102 @@ +/** Physical WebSocket selection and ownership for one Client Connection. */ + +/** Events consumed by Connection and Gateway on either browser or Node sockets. */ +export interface ConnectionWebSocketEventMap { + readonly open: object + readonly error: object + readonly close: object + readonly message: { readonly data: unknown } +} + +/** WebSocket operations shared by the browser API and Node carriers. */ +export interface ConnectionWebSocket { + /** Standard WebSocket connection state, from CONNECTING (0) through CLOSED (3). */ + readonly readyState: number + /** @param data - complete text frame to send. */ + send(data: string): void + /** + * @param code - protocol close code. + * @param reason - peer-facing close explanation. + */ + close(code?: number, reason?: string): void + /** + * @param event - physical socket event. + * @param listener - callback for that event. + * @param options - whether the listener is removed after its first delivery. + */ + addEventListener( + event: Event, + listener: (event: ConnectionWebSocketEventMap[Event]) => void, + options?: { readonly once?: boolean }, + ): void + /** + * @param event - physical socket event. + * @param listener - previously registered callback. + */ + removeEventListener( + event: Event, + listener: (event: ConnectionWebSocketEventMap[Event]) => void, + ): void +} + +/** + * Open an authenticated physical socket; framing remains with its consumer. + * @param url - absolute WebSocket URL below the selected Host or document mount. + * @returns the newly created socket, including its pending connection lifetime. + */ +export type ConnectionWebSocketFactory = (url: string) => ConnectionWebSocket + +const WEBSOCKET_CLOSED = 3 + +/** Own sockets until their close event, including failed connection attempts. */ +export class ConnectionSockets { + private readonly sockets = new Map>() + private disposed = false + + /** + * @param factory - physical carrier; omission uses the browser WebSocket API. + * @param baseUrl - shell-owned Host base; omission uses the current document base. + */ + constructor( + private readonly factory: ConnectionWebSocketFactory = url => new WebSocket(url), + private readonly baseUrl?: string, + ) {} + + /** + * Open a channel beneath the selected Host or document mount. + * @param path - absolute logical channel path. + * @returns a physical socket whose lifetime belongs to this Connection. + */ + open(path: string): ConnectionWebSocket { + if (this.disposed) throw new Error('connection: WebSocket carrier is disposed') + const base = this.baseUrl ?? (typeof document === 'undefined' ? undefined : document.baseURI) + if (base === undefined) throw new Error('connection: WebSocket carrier requires streamBaseUrl outside a document') + const url = new URL(path.slice(1), base) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + const socket = this.factory(url.href) + if (socket.readyState === WEBSOCKET_CLOSED) return socket + // Node sockets can emit an error after a cancelled opener removes its listeners. + const error = (): void => {} + socket.addEventListener('error', error) + const closed = new Promise((resolve) => { + socket.addEventListener('close', () => { + socket.removeEventListener('error', error) + this.sockets.delete(socket) + resolve() + }, { once: true }) + }) + this.sockets.set(socket, closed) + return socket + } + + /** + * Close every owned socket. + * @returns after every physical close event has arrived. + */ + async dispose(): Promise { + this.disposed = true + const sockets = [...this.sockets] + for (const [socket] of sockets) socket.close(1000, 'Connection disposed') + await Promise.all(sockets.map(([, closed]) => closed)) + } +} diff --git a/packages/client/connection/tests/socket.client.spec.ts b/packages/client/connection/tests/socket.client.spec.ts new file mode 100644 index 0000000000..f61c356bc3 --- /dev/null +++ b/packages/client/connection/tests/socket.client.spec.ts @@ -0,0 +1,134 @@ +/** Connection chooses WebSocket URLs and retains physical close ownership. */ + +import { EventEmitter } from 'node:events' +import type { ConnectionWebSocket, ConnectionWebSocketEventMap } from '../src/client/socket.ts' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ConnectionSockets } from '../src/client/socket.ts' + +afterEach(() => { vi.unstubAllGlobals() }) + +class Socket extends EventTarget { + override addEventListener( + type: E, listener: (event: ConnectionWebSocketEventMap[E]) => void, options?: { readonly once?: boolean }, + ): void + override addEventListener( + type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions, + ): void + override addEventListener(type: string, listener: unknown, options?: boolean | AddEventListenerOptions): void { + // This fixture dispatches MessageEvent for messages and Event for other socket events. + super.addEventListener(type, listener as EventListenerOrEventListenerObject | null, options) + } + + override removeEventListener( + type: E, listener: (event: ConnectionWebSocketEventMap[E]) => void, + ): void + override removeEventListener( + type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions, + ): void + override removeEventListener(type: string, listener: unknown, options?: boolean | EventListenerOptions): void { + super.removeEventListener(type, listener as EventListenerOrEventListenerObject | null, options) + } + + readyState = 0 + readonly closedWith: { readonly code?: number; readonly reason?: string }[] = [] + + constructor(readonly url: string) { super() } + + send(_data: string): void {} + + close(code?: number, reason?: string): void { + this.closedWith.push({ + ...code === undefined ? {} : { code }, + ...reason === undefined ? {} : { reason }, + }) + this.readyState = 2 + } + + finishClose(): void { + this.readyState = 3 + this.dispatchEvent(new Event('close')) + } +} + +describe('Connection WebSocket carrier', () => { + it('does not retain a socket that is already closed when its factory returns', async () => { + const socket = new Socket('ws://fixture/api/remote.mux') + socket.finishClose() + const sockets = new ConnectionSockets(() => socket, 'http://fixture/') + expect(sockets.open('/api/remote.mux')).toBe(socket) + await sockets.dispose() + expect(socket.closedWith).toEqual([]) + }) + + it('contains Node socket errors until physical close, then removes its handler', async () => { + const events = new EventEmitter() + const socket: ConnectionWebSocket = { + readyState: 0, + send() {}, + close() {}, + addEventListener(event, listener, options) { + if (options?.once === true) events.once(event, listener) + else events.on(event, listener) + }, + removeEventListener(event, listener) { events.off(event, listener) }, + } + const sockets = new ConnectionSockets(() => socket, 'http://fixture/') + sockets.open('/api/remote.mux') + expect(() => events.emit('error', new Error('opening failed'))).not.toThrow() + const closed = sockets.dispose() + expect(() => events.emit('error', new Error('cancelled while opening'))).not.toThrow() + events.emit('close', {}) + await closed + expect(events.listenerCount('error')).toBe(0) + }) + + it.each([ + ['https://harness.example/mounted/app/', 'wss://harness.example/mounted/app/api/remote.mux'], + ['http://127.0.0.1:43210', 'ws://127.0.0.1:43210/api/remote.mux'], + ])('resolves channel paths below the selected base %s', async (base, expected) => { + vi.stubGlobal('document', { baseURI: 'dsh-app://app/' }) + const created: Socket[] = [] + const sockets = new ConnectionSockets((url) => { + const socket = new Socket(url) + created.push(socket) + return socket + }, base) + const socket = sockets.open('/api/remote.mux') + expect(created[0]?.url).toBe(expected) + expect(socket).toBe(created[0]) + created[0]!.finishClose() + await sockets.dispose() + }) + + it('uses the browser document base and native socket when no carrier is supplied', async () => { + vi.stubGlobal('document', { baseURI: 'https://harness.example/application/' }) + vi.stubGlobal('WebSocket', Socket) + const sockets = new ConnectionSockets() + const socket = sockets.open('/api/remote.mux') + expect(socket).toBeInstanceOf(Socket) + expect((socket as Socket).url).toBe('wss://harness.example/application/api/remote.mux') + ;(socket as Socket).finishClose() + await sockets.dispose() + }) + + it('waits for a connecting socket to close and rejects later opens', async () => { + const socket = new Socket('ws://fixture/api/remote.mux') + const sockets = new ConnectionSockets(() => socket, 'http://fixture/') + sockets.open('/api/remote.mux') + let disposed = false + const closing = sockets.dispose().then(() => { disposed = true }) + await Promise.resolve() + expect(disposed).toBe(false) + expect(socket.closedWith).toEqual([{ code: 1000, reason: 'Connection disposed' }]) + expect(() => sockets.open('/api/remote.mux')).toThrow('carrier is disposed') + socket.finishClose() + await closing + expect(disposed).toBe(true) + }) + + it('requires an explicit stream base when no document exists', () => { + vi.stubGlobal('document', undefined) + const sockets = new ConnectionSockets(url => new Socket(url)) + expect(() => sockets.open('/api/remote.mux')).toThrow('requires streamBaseUrl') + }) +}) diff --git a/packages/client/connection/tsconfig.client.json b/packages/client/connection/tsconfig.client.json index a9c558d29b..9b1d3ba3dd 100644 --- a/packages/client/connection/tsconfig.client.json +++ b/packages/client/connection/tsconfig.client.json @@ -12,6 +12,7 @@ "src/client/index.ts", "src/client/random-uuid.ts", "src/client/rpc.ts", + "src/client/socket.ts", "src/loopback-hostname.ts", "src/recovery-config.ts", "src/rpc.ts" diff --git a/packages/client/product-analytics/src/client/index.ts b/packages/client/product-analytics/src/client/index.ts index dfa990bfe6..ffbaee364e 100644 --- a/packages/client/product-analytics/src/client/index.ts +++ b/packages/client/product-analytics/src/client/index.ts @@ -26,7 +26,6 @@ class DesktopAnalytics extends Service { if (!('dshDesktop' in globalThis)) return const stream = ctx.remote.$stream({ name: 'product analytics policy', open: signal => ctx.remote.productAnalytics.watchPolicy(signal), - ended: () => new Error('product analytics policy stream ended'), carrierFailed: () => { this.collecting = false }, }) ctx.effect(() => () => { this.collecting = false; return stream.dispose() }) diff --git a/packages/client/product-analytics/tests/collection.client.spec.ts b/packages/client/product-analytics/tests/collection.client.spec.ts index bdf5e7e5c5..ab39faf24f 100644 --- a/packages/client/product-analytics/tests/collection.client.spec.ts +++ b/packages/client/product-analytics/tests/collection.client.spec.ts @@ -94,7 +94,8 @@ it('fails closed on stream loss and accepts a fresh policy after reconnection', vi.stubGlobal('dshDesktop', {}) const b = await setup() b.options().open(new AbortController().signal) - expect(b.options().ended(true).message).toContain('policy stream ended') + // A Host reload ends the policy stream after its baseline; the supervisor default reopens it. + expect(b.options().ended).toBeUndefined() b.options().carrierFailed?.(new Error('offline')) expect(b.ctx.productAnalytics.enabled).toBe(false) await b.policy(true) diff --git a/packages/experimental/client-ui-voice-input/tests/readiness.client.spec.ts b/packages/experimental/client-ui-voice-input/tests/readiness.client.spec.ts index 8e7787bcc4..0fe98d2657 100644 --- a/packages/experimental/client-ui-voice-input/tests/readiness.client.spec.ts +++ b/packages/experimental/client-ui-voice-input/tests/readiness.client.spec.ts @@ -40,7 +40,7 @@ it('accepts a Host snapshot and keeps preparation ownership outside observation const signal = new AbortController().signal b.options.open(signal) expect(b.follow).toHaveBeenCalledWith(signal) - expect(b.options.ended(true)).toBeInstanceOf(RemoteStreamCarrierError) + expect(b.options.ended?.(true)).toBeInstanceOf(RemoteStreamCarrierError) b.options.carrierFailed?.(new RemoteStreamCarrierError('connection lost')) expect(b.mirror.state.getSnapshot()).toMatchObject({ connected: false, error: 'connection lost' }) await b.mirror.dispose() diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 469db60766..60fb06141f 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -545,7 +545,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ConnectionHandle', - declaration: 'export interface ConnectionHandle {\n readonly isLoopback: boolean;\n readonly generation: ConnectionGenerationState;\n readonly state: ConnectionStateSource;\n readonly rpc: ClientConnectionRpc;\n reconnect(): void;\n registerGenerationSource(source: ConnectionGenerationSource): () => void;\n start(sinks: ConnectionSinks, config?: ConnectionRecoveryConfig): ConnectionLoop;\n}', + declaration: 'export interface ConnectionHandle {\n readonly isLoopback: boolean;\n readonly generation: ConnectionGenerationState;\n readonly state: ConnectionStateSource;\n readonly rpc: ClientConnectionRpc;\n openWebSocket(path: string): ConnectionWebSocket;\n reconnect(): void;\n registerGenerationSource(source: ConnectionGenerationSource): () => void;\n start(sinks: ConnectionSinks, config?: ConnectionRecoveryConfig): ConnectionLoop;\n}', }, { name: 'ConnectionHostInfo', @@ -579,6 +579,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ConnectionStateSource', declaration: 'export interface ConnectionStateSource {\n getSnapshot(): ConnectionState | undefined;\n subscribe(listener: () => void): () => void;\n}', }, + { + name: 'ConnectionWebSocket', + declaration: 'export interface ConnectionWebSocket {\n readonly readyState: number;\n send(data: string): void;\n close(code?: number, reason?: string): void;\n addEventListener(event: Event, listener: (event: ConnectionWebSocketEventMap[Event]) => void, options?: {\n readonly once?: boolean;\n }): void;\n removeEventListener(event: Event, listener: (event: ConnectionWebSocketEventMap[Event]) => void): void;\n}', + }, + { + name: 'ConnectionWebSocketEventMap', + declaration: 'export interface ConnectionWebSocketEventMap {\n readonly open: object;\n readonly error: object;\n readonly close: object;\n readonly message: {\n readonly data: unknown;\n };\n}', + }, { name: 'DraftInitializationOptions', declaration: 'export interface DraftInitializationOptions {\n readonly prompt?: string;\n readonly clearPreviousDraft?: boolean;\n}', @@ -837,7 +845,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RemoteStreamOptions', - declaration: 'export interface RemoteStreamOptions {\n readonly name: string;\n readonly open: (signal: AbortSignal) => AsyncIterable;\n readonly ended: (accepted: boolean) => Error;\n readonly carrierFailed?: (error: RemoteStreamCarrierError) => void;\n}', + declaration: 'export interface RemoteStreamOptions {\n readonly name: string;\n readonly open: (signal: AbortSignal) => AsyncIterable;\n readonly ended?: (accepted: boolean) => Error;\n readonly carrierFailed?: (error: RemoteStreamCarrierError) => void;\n}', }, { name: 'RenderFactorySlot', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index e662b6953b..9a2bc67ad0 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -5626,11 +5626,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InvocationDescriptor', - declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly mode?: \'stream\';\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypertCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly uplink?: {\n readonly codec: TypertCodec;\n };\n readonly cancellation?: {\n readonly parameter: \'signal\';\n };\n readonly result: TypertCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}', + declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly mode?: \'stream\';\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypertCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly uplink?: {\n readonly codec: TypertCodec;\n };\n readonly cancellation?: {\n readonly parameter: \'signal\';\n };\n readonly result: TypertCodec;\n readonly description?: string;\n readonly sourceLocation?: InvocationSourceLocation;\n}', }, { name: 'InvocationParameterDescriptor', - declaration: 'export interface InvocationParameterDescriptor {\n readonly name: string;\n readonly wire: string;\n readonly source: \'json\' | \'lookup\';\n readonly lookup?: string;\n readonly codec: TypertCodec;\n readonly acceptsUndefined?: true;\n}', + declaration: 'export interface InvocationParameterDescriptor {\n readonly name: string;\n readonly description?: string;\n readonly wire: string;\n readonly source: \'json\' | \'lookup\';\n readonly lookup?: string;\n readonly codec: TypertCodec;\n readonly acceptsUndefined?: true;\n}', }, { name: 'InvocationSourceLocation', @@ -8122,7 +8122,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TypertCodec', - declaration: 'export type TypertCodec = {\n readonly mode: \'strict\';\n readonly typeSymbol: string;\n readonly create: () => TypertSchema;\n readonly decode?: (value: unknown) => unknown;\n readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown;\n} | {\n readonly mode: \'src-json\';\n};', + declaration: 'export type TypertCodec = TypertRemoteValue & {\n readonly mode: \'strict\';\n readonly typeSymbol: string;\n readonly decode?: ((value: unknown) => unknown) | undefined;\n readonly encode?: ((value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown) | undefined;\n} | {\n readonly mode: \'src-json\';\n};', }, { name: 'TypertContribution', @@ -8200,6 +8200,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TypertRemoteService', declaration: 'export abstract class TypertRemoteService extends Service {\n readonly typertRemote: TypertGatewayBinding;\n}', }, + { + name: 'TypertRemoteValue', + declaration: 'export interface TypertRemoteValue {\n readonly create: () => TypertSchema;\n readonly description?: string | undefined;\n readonly jsonSchema?: Readonly> | undefined;\n readonly jsonSchemaError?: string | undefined;\n}', + }, { name: 'TypertSchema', declaration: 'export interface TypertSchema {\n parse(value: unknown): Output;\n}', diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index f4989ed8a8..513bf14548 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-runtime/README.i18n.yaml @@ -27,8 +27,8 @@ en: cbcfc9a331783c98 zh: 1cc2f8e21a3d0dd4 /deepseek-ai-dsh-client-test-runtime/use-this-package/whole-client-tier: - en: 1442fc215c8128e8 - zh: 8439861b08e46ea2 + en: 2c2e221adff5164e + zh: 86349d4718c852e6 /deepseek-ai-dsh-client-test-runtime/use-this-package/roster-and-startup-behavior: en: 964236f295b61c0c zh: 863a9342b1169ad8 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index 2a7690f4ab..1818973fae 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -72,6 +72,8 @@ expect(view.getByRole('alert')).toHaveTextContent('goal/not-found') The slot tier above mounts one feature against doubles. The whole-client tier boots the real assembly: `TestClient.start(plan, mock, options)` imports every roster row's `/client` module in-process (or takes the plan's `provide` replacement), binds `{ rpc: mock.rpc }` to that client's Connection module, synthesizes the boot graph with `graphFromRoster` and hands the loaded modules to the production module system, boots through the production `bootClient`, optionally mounts `uiRenderer`, and waits for `ctx.connection.state === 'connected'`. It lives behind a deep import so slot-tier specs never load it: +The assembly mounts the Gateway Client row with `forwardedEvents: true`, the choice a served Web page receives from its Host's Gateway row, so roster plugins can call `$on()` and `$stream()` before the test waits for Connection readiness. + ```text // @vitest-environment jsdom import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index a603d918c6..16b3021d0f 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -72,6 +72,8 @@ expect(view.getByRole('alert')).toHaveTextContent('goal/not-found') 上面的 slot 档把一个功能挂在替身上。整体档起真实装配:`TestClient.start(plan, mock, options)` 进程内 import 每个 roster 行的 `/client` 模块(或取计划里的 `provide` 替换),把 `{ rpc: mock.rpc }` 绑定到该客户端的 Connection 模块,用 `graphFromRoster` 合成启动图并把已加载模块交给生产模块系统,经生产 `bootClient` 启动,按需挂载 `uiRenderer`,再等 `ctx.connection.state === 'connected'`。它藏在深 import 后面,slot 档测试永不加载它: +装配以 `forwardedEvents: true` 挂载 Gateway Client 行,即已服务的 Web 页面从其 Host 的 Gateway 行获得的选择,因此 roster 插件可以在测试等待 Connection 就绪之前调用 `$on()` 与 `$stream()`。 + ```text // @vitest-environment jsdom import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' diff --git a/packages/test-support/client-runtime/src/assembly/test-client.ts b/packages/test-support/client-runtime/src/assembly/test-client.ts index 2a54d235bc..618b058507 100644 --- a/packages/test-support/client-runtime/src/assembly/test-client.ts +++ b/packages/test-support/client-runtime/src/assembly/test-client.ts @@ -61,6 +61,7 @@ class SharedJsdomShims { const sharedJsdomShims = new SharedJsdomShims() const CONNECTION_PACKAGE = '@deepseek-ai/dsh-client-connection' +const GATEWAY_PACKAGE = '@deepseek-ai/dsh-api-gateway' /** Default readiness budget; the mock answers `$events` immediately, so a miss means a boot-time fixture is absent. */ const DEFAULT_CONNECT_TIMEOUT_MS = 5_000 @@ -177,7 +178,8 @@ export class TestClient { * `installConnection`, while this path supplies the mock carrier, uses * default recovery timings, and captures the current page hostname once for * later reloads. A caller-provided Connection row remains unchanged and owns - * its readiness behavior. The `@deepseek-ai/dsh-api-remotes` row is + * its readiness behavior. The Gateway Client row receives forwarded events, + * the choice a served Web page reads from its Host. The `@deepseek-ai/dsh-api-remotes` row is * dropped from the roster: its generated Remote clients exist only in built * `lib/`, and the `remote.` services the roster injects (plus the * namespaces the mock has rules for at this point) are provided as @@ -226,6 +228,14 @@ export class TestClient { }, }) } + const gateway = modules.get(GATEWAY_PACKAGE) + if (gateway !== undefined && plan.provide?.[GATEWAY_PACKAGE] === undefined) { + // The served Web page receives this choice from the Host Gateway row; the in-process tier has no served page. + modules.set(GATEWAY_PACKAGE, { + ...gateway, + apply: gatewayCtx => gateway.apply(gatewayCtx, { forwardedEvents: true }), + }) + } mountPoint = resolveMountPoint(options.mount) release = sharedJsdomShims.acquire() const system = createInProcessModules(graphFromRoster(roster.rows), modules) diff --git a/packages/typert/generator/README.i18n.yaml b/packages/typert/generator/README.i18n.yaml index e176d0afb8..a293579d0d 100644 --- a/packages/typert/generator/README.i18n.yaml +++ b/packages/typert/generator/README.i18n.yaml @@ -18,8 +18,11 @@ en: 949dec3bb987644a zh: 0ed333a6067465dc /deepseek-ai-dsh-typert-generator/use-this-package/publishing-typert-artifacts-from-a-package: - en: b8720f6ac186a8e8 - zh: 3e04a4c1bebc2cf4 + en: c7078becc8f12609 + zh: 4b8838b559ecb677 +/deepseek-ai-dsh-typert-generator/use-this-package/ordinary-remote-metadata: + en: 54db44190806bcca + zh: a85281e2dfadb7c2 /deepseek-ai-dsh-typert-generator/use-this-package/analyzing-a-workspace-statically: en: 8227097859169cdf zh: 249790ed62e8558a diff --git a/packages/typert/generator/README.md b/packages/typert/generator/README.md index 8d990c5440..92ba7beb3d 100644 --- a/packages/typert/generator/README.md +++ b/packages/typert/generator/README.md @@ -43,6 +43,21 @@ files: After the build, `lib/typert.host.js` and `lib/typert.host.d.ts` exist and the [loader](../loader/README.md) registers the contribution in Loader compositions. The generated declaration file exposes `TYPERT` as `unknown`, so contributing packages never depend on the runtime registry. The generator fails the build when a declaration is missing, points at the wrong file, or publishes Remote artifacts without Remote methods; unsupported Zod projections fail with a `TypertEmitError` naming the construct instead of flattening or weakening the source type. + +### Ordinary Remote metadata + +The emitter writes one static `TypertRemoteMethodSpec` per endpoint and calls the pure `createTypertRemoteInfo` factory. Ordinary consumer `./remote` artifacts retain their default export and `TYPERT_REMOTE`, typed as `TypertRemoteInfo` with precise `TypertRemoteMethod` entries in `TYPERT_REMOTE.methods`. Each entry is the same object as its direct descriptor in `TYPERT_REMOTE.descriptors`; Context-only receivers remain in descriptors and scoped declarations. There is no separate `REMOTE_METHODS` export, nested `metadata`, or second `output` codec. The [protocol](<../protocol/README.md>) owns the factory and method API. + +The factory returns a class instance that retains the spec until its `descriptors` or `methods` getter builds their shared index. Named input schemas and projections are cached on demand, and generated individual Zod factories also materialize and cache on first use. Ordinary Host `./typert` builds a private info instance whose descriptors are exposed by the `TYPERT.invocations` getter. Host and consumer artifacts share definitions, not object identity across Programs or processes. + +Generated declarations use type-only imports for owner and schema types. Importing metadata neither activates the Host Service nor opens a network connection. Framework consumers query the existing [registry](<../registry/README.md>); standalone consumers can import `./remote` directly. Source compositions requiring strict validation prebuild ordinary artifacts and register `./typert` through the [loader](<../loader/README.md>); descriptor-free source discovery cannot recover erased type constraints, documentation, or schemas. + +Missing JSDoc and unsupported tool JSON Schema do not block otherwise valid ordinary generation or its strict codecs. Metadata retains parsers and available documentation independently of projection: an unsupported input or output projection omits `jsonSchema` and records `jsonSchemaError`. A tool consumer explicitly selects methods and requires its documentation and supported input schema; an output parser is required, but output JSON Schema is optional unless that consumer requires it. Metadata alone does not make every Remote method a tool. + +`@Remote({ rejectUnknownFields: true })` independently closes declared request objects in generated codecs and supported input JSON Schemas unless a declaration explicitly contains an index signature. Other methods and result objects retain their existing policy; every metadata input parser and supported input JSON Schema closes the top-level named-argument object. Tool projection supports scalars, objects, arrays, and disjoint unions; canonical `JsonValue` and string-keyed JSON dictionaries need no recursive references. Numeric bounds and other semantic rules absent from TypeScript remain with the owning operation. + +`WorkspaceTypertGenerator` analyzes all contributors of each selected face before filtering emitted packages, so a package subset retains the same declaration merges and output bytes as a complete build. + ### Analyzing a workspace statically Static consumers call `WorkspaceAnalyzer` directly against the workspace's `tsconfig.host.json` and `tsconfig.client.json` aggregates, select a face and package subset, and read the resulting `FaceModel` and type graph without emitting or loading runtime artifacts. `analyzeInBatches()` processes a large package selection through bounded compiler programs with the same model shape, and `discoverPackages()` finds contributing packages without building a type-checker program; its optional `package.json` predicate skips a package before its sources or project config are read. Typert parses only the tsconfigs of packages it analyzes; validating every referenced tsconfig belongs to the preceding `tsc -b`. diff --git a/packages/typert/generator/README.zh.md b/packages/typert/generator/README.zh.md index b67629b8ba..a74fbeb830 100644 --- a/packages/typert/generator/README.zh.md +++ b/packages/typert/generator/README.zh.md @@ -43,6 +43,21 @@ files: 构建完成后,`lib/typert.host.js` 与 `lib/typert.host.d.ts` 即存在,[loader](../loader/README.zh.md) 会在 Loader 组合中注册该贡献。生成的声明文件把 `TYPERT` 暴露为 `unknown`,因此参与贡献的包永远不会依赖运行时注册表。当声明缺失、指向错误文件,或在没有 Remote 方法的情况下发布 Remote 产物时,生成器会使构建失败;不支持的 Zod 投影会以 `TypertEmitError` 指明具体构造并失败,而不会展平或弱化源类型。 + +### 普通 Remote 元数据 + +发射器为每个端点写入一份静态 `TypertRemoteMethodSpec`,并调用纯 `createTypertRemoteInfo` factory。普通消费方 `./remote` 产物保留默认导出和 `TYPERT_REMOTE`,其类型为 `TypertRemoteInfo`,`TYPERT_REMOTE.methods` 中的条目具有精确的 `TypertRemoteMethod` 类型。每个条目就是 `TYPERT_REMOTE.descriptors` 中同一个直接方法描述符对象;仅使用 Context 接收器的方法保留在描述符和作用域声明中。不存在独立的 `REMOTE_METHODS` 导出、嵌套 `metadata` 或第二份 `output` codec。[协议](<../protocol/README.zh.md>)负责 factory 和方法 API。 + +factory 返回类实例,并保留 spec,直到 `descriptors` 或 `methods` getter 构建两者共享的索引。具名输入 schema 和投影按需缓存,生成的独立 Zod factory 同样在首次使用时物化并缓存。普通 Host `./typert` 创建私有 info 实例,由 `TYPERT.invocations` getter 暴露其描述符。Host 与消费方产物共享定义,不承诺跨 Program 或进程的对象身份。 + +生成的声明对拥有者类型和 schema 类型使用仅类型导入。导入元数据既不会激活 Host Service,也不会打开网络连接。框架内消费方查询现有[注册表](<../registry/README.zh.md>);独立消费方可以直接导入 `./remote`。要求严格校验的源码组合预先构建普通产物,并通过 [loader](<../loader/README.zh.md>) 注册 `./typert`;无描述符的源码发现无法恢复已擦除的类型约束、文档或 schema。 + +缺少 JSDoc 或不支持工具 JSON Schema,不会阻止其他方面有效的普通生成及其严格 codec。元数据独立于投影保留解析器与可用文档:不支持的输入或输出投影省略 `jsonSchema`,并记录 `jsonSchemaError`。工具消费方显式选择方法,并要求其所需文档和受支持的输入 schema;输出解析器是必需的,但输出 JSON Schema 是可选的,除非该消费方要求提供。仅有元数据不会让每个 Remote 方法自动成为工具。 + +`@Remote({ rejectUnknownFields: true })` 独立地在生成的 codec 与受支持的输入 JSON Schema 中关闭已声明请求对象,除非声明显式包含索引签名。其他方法与结果对象保留现有策略;每个元数据输入解析器与受支持的输入 JSON Schema 都关闭顶层命名参数对象。工具投影支持标量、对象、数组和互斥联合类型;规范的 `JsonValue` 与字符串键 JSON 字典无需递归引用。TypeScript 未表达的数值范围与其他语义规则仍由操作拥有者负责。 + +`WorkspaceTypertGenerator` 先分析每个选中 face 的所有贡献方,再筛选要生成产物的包,因此包子集保留与完整构建相同的声明合并结果和输出字节。 + ### 静态分析工作区 静态消费方直接针对工作区的 `tsconfig.host.json` 与 `tsconfig.client.json` 聚合配置调用 `WorkspaceAnalyzer`,选择 face 与包子集,并在不生成或加载运行时产物的前提下读取生成的 `FaceModel` 与类型图。`analyzeInBatches()` 通过有界的编译器程序处理大批量包选择,模型形态保持一致;`discoverPackages()` 无需构建类型检查程序即可找出参与贡献的包,其可选的 `package.json` 谓词会在读取某个包的源码或项目配置之前就跳过该包。Typert 只解析它所分析的包的 tsconfig;校验每个被引用的 tsconfig 由之前运行的 `tsc -b` 负责。 diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index 73d22fd9a4..85664b974d 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -39,6 +39,7 @@ "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { + "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-tool-cordis": "workspace:*", "@deepseek-ai/dsh-typert-registry": "workspace:*", "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 0e5d2ea013..885a1fef5a 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -673,6 +673,14 @@ interface FaceAnalyzerOptions { readonly crossFaceLinks: Map } +/** Direct `@Remote` choices read from one decorator. */ +interface DirectRemoteMarker { + readonly kind: 'direct' + readonly exportName?: string + readonly mode?: 'stream' + readonly rejectUnknownFields?: true +} + class FaceAnalyzer { private readonly root: string private readonly face: TypertFace @@ -1128,7 +1136,7 @@ class FaceAnalyzer { binding: GatewayBinding, method: ts.MethodDeclaration, invocation: - | { readonly kind: 'direct'; readonly exportName?: string; readonly mode?: 'stream' } + | DirectRemoteMarker | { readonly kind: 'context'; readonly context: string; readonly exportName?: string }, ): InvocationModel { if (visibilityOf(method) !== 'public' || hasModifier(method, ts.SyntaxKind.StaticKeyword)) { @@ -1143,6 +1151,7 @@ class FaceAnalyzer { if ((method.typeParameters?.length ?? 0) > 0) { this.fail(method, 'generic Remote methods are not supported') } + const documentation = documentationOf(method) const methodName = method.name.text const exportedMethod = invocation.exportName ?? methodName @@ -1212,7 +1221,8 @@ class FaceAnalyzer { } if (wires.has(modeled.wire)) this.fail(parameter, `duplicate Remote wire field ${modeled.wire}`) wires.add(modeled.wire) - parameters.push(modeled) + const description = documentation.tags.find(tag => tag.name === 'param' && tag.argument === modeled.name)?.comment?.replace(/^[-—–]\s*/, '') + parameters.push({ ...modeled, ...(description === undefined ? {} : { description }) }) } let receiver: InvocationModel['invocation'] = { kind: 'direct' } @@ -1271,6 +1281,8 @@ class FaceAnalyzer { ), } return { + ...documentation, + ...(invocation.kind === 'direct' && invocation.rejectUnknownFields ? { rejectUnknownFields: true as const } : {}), id: `${registration.name}#${binding.namespace}/${exportedMethod}`, service: binding.service, namespace: binding.namespace, @@ -1382,11 +1394,11 @@ class FaceAnalyzer { private remoteMarker( member: ts.ClassElement, ): - | { readonly kind: 'direct'; readonly exportName?: string; readonly mode?: 'stream' } + | DirectRemoteMarker | { readonly kind: 'context'; readonly context: string; readonly exportName?: string } | undefined { let found: - | { readonly kind: 'direct'; readonly exportName?: string; readonly mode?: 'stream' } + | DirectRemoteMarker | { readonly kind: 'context'; readonly context: string; readonly exportName?: string } | undefined for (const decorator of ts.canHaveDecorators(member) ? ts.getDecorators(member) ?? [] : []) { @@ -1406,16 +1418,22 @@ class FaceAnalyzer { } marker = { kind: 'direct', exportName } } else { - if (!ts.isObjectLiteralExpression(argument) || argument.properties.length !== 1) { - this.fail(argument, 'Remote() options must contain exactly mode: "stream"') + if (!ts.isObjectLiteralExpression(argument) || argument.properties.length === 0) { + this.fail(argument, 'Remote() options require name, mode: "stream", or rejectUnknownFields: true') + } + marker = { kind: 'direct' } + for (const property of argument.properties) { + if (!ts.isPropertyAssignment(property)) this.fail(property, 'Remote() options must use named values') + const name = memberName(property.name) + if (name === 'rejectUnknownFields' && property.initializer.kind === ts.SyntaxKind.TrueKeyword) { + marker = { ...marker, rejectUnknownFields: true } + } else if (name === 'mode' && stringLiteralValue(property.initializer) === 'stream') marker = { ...marker, mode: 'stream' } + else if (name === 'name') { + const value = stringLiteralValue(property.initializer) + if (value === undefined || !isRemoteSegment(value)) this.fail(property, 'Remote() name must be an endpoint segment') + marker = { ...marker, exportName: value } + } else this.fail(property, 'Remote() options require name, mode: "stream", or rejectUnknownFields: true') } - const [property] = argument.properties - if (property === undefined) this.fail(argument, 'Remote() options must contain exactly mode: "stream"') - const mode = ts.isPropertyAssignment(property) && memberName(property.name) === 'mode' - ? stringLiteralValue(property.initializer) - : undefined - if (mode !== 'stream') this.fail(property, 'Remote() options must contain exactly mode: "stream"') - marker = { kind: 'direct', mode } } } else if (ts.isCallExpression(expression) && this.isTypeMetaSymbol(expression.expression, 'RemoteScope')) { @@ -1682,6 +1700,12 @@ class FaceAnalyzer { completed.set(type, id) return id } + const alias = type.aliasSymbol + const aliasDeclaration = alias === undefined ? undefined : preferredDeclaration(this.resolveSymbol(alias)) + if (alias?.name === 'JsonValue' && aliasDeclaration !== undefined + && this.registrationForFile(aliasDeclaration.getSourceFile().fileName)?.name === '@deepseek-ai/dsh-util-values') { + return add({ kind: 'keyword', name: 'unknown', json: true }) + } const flags = type.flags if (this.isRemoteByteArray(type)) { return add({ kind: 'reference', name: 'Uint8Array', target: { kind: 'standard', name: 'Uint8Array' }, arguments: [] }) @@ -1714,9 +1738,22 @@ class FaceAnalyzer { const value = (type as ts.Type & { readonly intrinsicName?: string }).intrinsicName === 'true' return add({ kind: 'literal', value, text: String(value) }) } - if (type.isUnionOrIntersection()) { + if (type.isIntersection()) { + const material = type.types.filter(member => !this.isRemotePhantomConstraint(member)) + if (material.length === 1) { + const projected = convert(material[0] as ts.Type) + const { id: _projectedId, ...value } = this.nodes.get(projected) as TypeNodeModel + return add(value) + } + // Object intersections contribute one request object; closing each operand + // separately would reject fields declared by its sibling operands. + if (!material.every(member => (member.flags & ts.TypeFlags.Object) !== 0 + && !this.checker.isArrayType(member) && !this.checker.isTupleType(member))) { + return add({ kind: 'intersection', types: material.map(convert) }) + } + } else if (type.isUnion()) { return add({ - kind: (flags & ts.TypeFlags.Union) !== 0 ? 'union' : 'intersection', + kind: 'union', types: type.types.map(convert), }) } @@ -1761,7 +1798,7 @@ class FaceAnalyzer { const propertyType = this.checker.getTypeOfSymbolAtLocation(property, declaration ?? authoredType) const symbolKey = property.getName() members.push({ - ...EMPTY_DOCUMENTATION, + ...(declaration === undefined ? EMPTY_DOCUMENTATION : documentationOf(declaration)), id: `${id}#${symbolKey}`, name: symbolKey, ...(symbolKey.startsWith('__@') ? { computed: 'symbol' as const } : {}), diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index 78907abd5f..ccd080e36b 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -23,6 +23,7 @@ import type { } from './model.ts' import { TypeGraphRenderer } from './renderer.ts' import { childTypeNodeIds } from './model.ts' +import { remoteJsonSchema, RemoteJsonSchemaError } from './remote-json-schema.ts' /** Failure to project a modeled construct into an emitted artifact. */ export class TypertEmitError extends Error { @@ -107,12 +108,11 @@ export class FaceModelEmitter { if (packageModel === undefined) { throw new TypertEmitError(`typert emitter(${this.face.face}): package ${packageName} is not modeled on this face`) } - const schemas = new SchemaEmitter( + const schemaArtifact = invocationSchemas( this.renderer, packageModel.schemas, invocationBoundaryRoots(packageModel.invocations), ) - const schemaArtifact = schemas.emit() const runtimeModel = this.runtimeModel(packageModel) const js = this.renderJs(packageModel, schemaArtifact, runtimeModel) const dts = this.renderDts(packageModel, schemaArtifact) @@ -193,10 +193,14 @@ export class FaceModelEmitter { '/* Generated by @deepseek-ai/dsh-typert-generator from FaceModel — do not edit. */', ] if (schemas.definitions.length > 0) lines.push('import { z } from \'zod\'', '') + if (packageModel.invocations.length > 0) { + lines.push("import { createTypertRemoteInfo } from '@deepseek-ai/dsh-typert-protocol/remote-info'", '') + } lines.push(...schemas.definitions) if (schemas.definitions.length > 0) lines.push('') for (const schema of schemas.exports) lines.push(`export const ${schema.exportName} = ${schema.internalName}`) if (schemas.exports.length > 0) lines.push('') + if (packageModel.invocations.length > 0) lines.push(`const $remote = ${this.remoteInfoLiteral(packageModel, schemas)}`, '') const model = JSON.stringify(runtimeModel, null, 2) lines.push('export const TYPERT = {') lines.push(` package: ${quote(packageModel.name)},`) @@ -206,11 +210,8 @@ export class FaceModelEmitter { lines.push(` { name: ${quote(schema.exportName)}, create: ${schema.exportName} },`) } lines.push(' ],') - lines.push(' invocations: [') - for (const invocation of packageModel.invocations) { - lines.push(`${indent(this.invocationLiteral(invocation, schemas), 4)},`) - } - lines.push(' ],') + if (packageModel.invocations.length > 0) lines.push(' get invocations() { return $remote.descriptors },') + else lines.push(' invocations: [', ' ],') lines.push(` model: ${indent(model, 2).trimStart()},`) lines.push('}') return `${lines.join('\n')}\n` @@ -244,41 +245,60 @@ export class FaceModelEmitter { } private emitRemote(packageModel: PackageModel): RemoteModelEmitResult { - const schemas = new SchemaEmitter( + const schemas = invocationSchemas( this.renderer, [], invocationBoundaryRoots(packageModel.invocations), - ).emit() + ) const lines = [ '/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */', ] if (schemas.definitions.length > 0) lines.push('import { z } from \'zod\'', '') + lines.push("import { createTypertRemoteInfo } from '@deepseek-ai/dsh-typert-protocol/remote-info'", '') lines.push(...schemas.definitions) if (schemas.definitions.length > 0) lines.push('') - lines.push('export const TYPERT_REMOTE = {') - lines.push(` package: ${quote(packageModel.name)},`) - lines.push(' descriptors: [') - for (const invocation of packageModel.invocations) { - lines.push(`${indent(this.invocationLiteral(invocation, schemas), 4)},`) - } - lines.push(' ],') - lines.push('}') - lines.push('') + lines.push(`export const TYPERT_REMOTE = ${this.remoteInfoLiteral(packageModel, schemas)}`, '') lines.push('export default TYPERT_REMOTE') - const declaration = this.renderRemoteDts(packageModel) return { js: `${lines.join('\n')}\n`, - ...declaration, + ...this.renderRemoteDts(packageModel), } } + private remoteMethodTypes(packageModel: PackageModel): string { + const names = allocateRemoteImportNames(remoteImports(packageModel.invocations)) + const entries = packageModel.invocations.filter(invocation => invocation.invocation.kind === 'direct').map((invocation) => { + const input = this.remoteInputType(invocation, names) + const result = this.renderer.renderType(invocation.result.type, names) + const uplink = invocation.uplink === undefined ? '' : `, ${this.renderer.renderType(invocation.uplink.boundary.type, names)}` + const mode = invocation.mode === 'stream' ? `, ${quote('stream')}${uplink}` + : containsBytes(this.renderer, invocation.result.codecType) ? `, 'unary', never, RemoteDecoded<${result}>` : '' + return ` readonly ${quote(`${invocation.namespace}/${invocation.method}`)}: TypertRemoteMethod<${input}, ${result}${mode}>` + }) + return `{\n${entries.join('\n')}\n}` + } + + private remoteInputType(invocation: InvocationModel, names: ReadonlyMap): string { + return `{ ${invocation.parameters.map(parameter => `readonly ${quote(parameter.wire)}${parameter.boundary.acceptsUndefined ? '?' : ''}: ${this.renderer.renderType(parameter.boundary.type, names)}`).join('; ')} }` + } + + private remoteInfoLiteral(packageModel: PackageModel, schemas: SchemaArtifact): string { + return [ + 'createTypertRemoteInfo({', + ` package: ${quote(packageModel.name)},`, + ' methods: {', + ...packageModel.invocations.map(invocation => + ` ${quote(`${invocation.namespace}/${invocation.method}`)}: ${indent(this.invocationLiteral(invocation, schemas), 4).trimStart()},`), + ' },', + '})', + ].join('\n') + } + private invocationLiteral(invocation: InvocationModel, schemas: SchemaArtifact): string { const lines = [ '{', - ` id: ${quote(invocation.id)},`, ` service: ${quote(invocation.service)},`, - ` namespace: ${quote(invocation.namespace)},`, - ` method: ${quote(invocation.method)},`, + ...(invocation.description === undefined ? [] : [` description: ${quote(invocation.description)},`]), ] if (invocation.implementation !== undefined) { lines.push(` implementation: ${quote(invocation.implementation)},`) @@ -307,13 +327,18 @@ export class FaceModelEmitter { invocation.parameters.forEach((parameter, index) => { lines.push(' {') lines.push(` name: ${quote(parameter.name)},`) - lines.push(` wire: ${quote(parameter.wire)},`) + if (parameter.wire !== parameter.name) lines.push(` wire: ${quote(parameter.wire)},`) + if (parameter.description !== undefined) lines.push(` description: ${quote(parameter.description)},`) lines.push(` source: ${quote(parameter.source)},`) if (parameter.lookup !== undefined) lines.push(` lookup: ${quote(parameter.lookup)},`) if (parameter.boundary.acceptsUndefined) lines.push(' acceptsUndefined: true,') lines.push(` codec: ${indent(strictCodec( parameter.boundary, schemas.boundary(parameterBoundaryKey(invocation, index)), + undefined, + toolProjection(() => remoteJsonSchema( + this.renderer, parameter.boundary.codecType, invocation.rejectUnknownFields === true, parameter.boundary.acceptsUndefined, + )), ), 6).trimStart()},`) lines.push(' },') }) @@ -329,10 +354,13 @@ export class FaceModelEmitter { if (invocation.cancellation !== undefined) { lines.push(" cancellation: { parameter: 'signal' },") } + const returns = invocation.tags.find(tag => tag.name === 'returns' || tag.name === 'return')?.comment?.replace(/^[-—–]\s*/, '') lines.push(` result: ${indent(strictCodec( invocation.result, schemas.boundary(resultBoundaryKey(invocation)), schemas.encoder(resultBoundaryKey(invocation)), + toolProjection(() => remoteJsonSchema(this.renderer, invocation.result.codecType)), + returns, ), 2).trimStart()},`) lines.push(` sourceLocation: ${JSON.stringify(invocation.location)},`) lines.push('}') @@ -356,9 +384,11 @@ export class FaceModelEmitter { const lines = [ '/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */', 'import type {', + ...packageModel.invocations.some(invocation => containsBytes(this.renderer, invocation.result.codecType)) ? [' RemoteDecoded,'] : [], ' RemoteResult,', ...packageModel.invocations.some(invocation => invocation.mode === 'stream') ? [' RemoteStreamHandle,'] : [], - ' TypertRemoteContribution,', + ' TypertRemoteInfo,', + ...packageModel.invocations.some(invocation => invocation.invocation.kind === 'direct') ? [' TypertRemoteMethod,'] : [], '} from \'@deepseek-ai/dsh-typert-protocol\'', ] const sourceMap = new GenMapping({ file: 'typert.remote-client.d.ts' }) @@ -368,12 +398,6 @@ export class FaceModelEmitter { lines.push(`import type { ${names.join(', ')} } from ${quote(specifier)}`) } lines.push('') - if (packageModel.invocations.some(invocation => containsBytes(this.renderer, invocation.result.codecType))) { - lines.push('type RemoteDecoded = Value extends Uint8Array ? Uint8Array') - lines.push(' : Value extends string | number | boolean | null | undefined ? Value') - lines.push(' : { [Key in keyof Value]: RemoteDecoded }') - lines.push('') - } lines.push('declare module \'@deepseek-ai/dsh-typert-protocol\' {') const direct = packageModel.invocations.filter(invocation => invocation.invocation.kind === 'direct') const scoped = packageModel.invocations.filter(invocation => @@ -406,7 +430,7 @@ export class FaceModelEmitter { } lines.push('}') lines.push('') - lines.push('export declare const TYPERT_REMOTE: TypertRemoteContribution') + lines.push(`export declare const TYPERT_REMOTE: TypertRemoteInfo<${this.remoteMethodTypes(packageModel)}>`) lines.push('export default TYPERT_REMOTE') lines.push('//# sourceMappingURL=typert.remote-client.d.ts.map') return { @@ -536,6 +560,30 @@ interface SchemaArtifact { interface BoundarySchemaRoot { readonly key: string readonly type: TypeNodeId + /** Reject unknown fields in declared objects reached from this root. */ + readonly closedObjects?: true +} + +function toolProjection(project: () => Record): string { + try { + return `jsonSchema: ${JSON.stringify(project())}` + } catch (error) { + if (!(error instanceof RemoteJsonSchemaError)) throw error + return `jsonSchemaError: ${quote(error.message)}` + } +} + +function invocationSchemas( + renderer: TypeGraphRenderer, schemas: readonly SchemaModel[], roots: readonly BoundarySchemaRoot[], +): SchemaArtifact { + const ordinary = new SchemaEmitter(renderer, schemas, roots.filter(root => !root.closedObjects)).emit() + const closedRoots = roots.filter(root => root.closedObjects) + const closed = new SchemaEmitter(renderer, [], closedRoots, { closedObjects: true }).emit() + const keys = new Set(closedRoots.map(root => root.key)) + return { definitions: [...ordinary.definitions, ...closed.definitions], exports: ordinary.exports, + boundary: key => keys.has(key) ? closed.boundary(key) : ordinary.boundary(key), + encoder: key => keys.has(key) ? closed.encoder(key) : ordinary.encoder(key), + } } class ResultEncoderEmitter { @@ -659,6 +707,7 @@ class SchemaEmitter { private readonly renderer: TypeGraphRenderer, private readonly schemas: readonly SchemaModel[], private readonly boundaries: readonly BoundarySchemaRoot[], + private readonly options: { readonly closedObjects?: boolean } = {}, ) { const declarations = new Map() for (const schema of schemas) { @@ -674,7 +723,7 @@ class SchemaEmitter { this.declarations = renderer.graph.declarations.filter(declaration => declarations.has(declaration.id)) const identifiers = new Set() for (const declaration of this.declarations) { - const base = `${safeIdentifier(declaration.name)}$schema` + const base = `${options.closedObjects ? 'closed$' : ''}${safeIdentifier(declaration.name)}$schema` let name = base let suffix = 2 while (identifiers.has(name)) name = `${base}${String(suffix++)}` @@ -699,7 +748,7 @@ class SchemaEmitter { const encode = encoder.reference(boundary.type) if (encode !== undefined) encoders.set(boundary.key, encode) const name = this.boundaryName(boundary.key) - definitions.push(`let ${name}$value\nconst ${name} = () => (${name}$value ??= ${this.typeSchema(boundary.type)})`) + definitions.push(this.cachedFactory(name, this.typeSchema(boundary.type))) } definitions.push(...encoder.definitions()) const exports = this.schemas.map((model): SchemaExport => ({ @@ -718,7 +767,7 @@ class SchemaEmitter { private declarationDefinition(declaration: TypeDeclarationModel): string { const name = this.schemaName(declaration.id) if (declaration.typeParameters.length === 0) { - return `let ${name}$value\nconst ${name} = () => (${name}$value ??= ${this.declarationSchema(declaration, new Map())})` + return this.cachedFactory(name, this.declarationSchema(declaration, new Map())) } const parameters = declaration.typeParameters.map((parameter, index) => [`type${String(index)}$schema`, parameter.id] as const) @@ -726,6 +775,10 @@ class SchemaEmitter { return `const ${name} = (${parameters.map(([schema]) => schema).join(', ')}) => ${this.declarationSchema(declaration, substitutions)}` } + private cachedFactory(name: string, expression: string): string { + return `let ${name}$value\nconst ${name} = () => (${name}$value ??= ${expression})` + } + private declarationSchema( declaration: TypeDeclarationModel, substitutions: ReadonlyMap, @@ -747,6 +800,7 @@ class SchemaEmitter { private typeSchema(id: TypeNodeId, substitutions: ReadonlyMap = new Map()): string { const node = this.renderer.node(id) + if (node.json) return 'z.json()' switch (node.kind) { case 'keyword': return this.keywordSchema(node.name) case 'literal': return `z.literal(${node.text})` @@ -916,7 +970,7 @@ class SchemaEmitter { if (indices.length > 1) this.fail(subject, 'object type has more than one JSON index signature') // A unique-symbol-only object is a compile-time marker and imposes no JSON shape. if (properties.length === 0 && indices.length === 0 && symbolMembers > 0) return 'z.unknown()' - const object = `z.object({${properties.length === 0 ? '' : `\n${properties.map(property => ` ${property},`).join('\n')}\n`}})` + const object = `z.${this.options.closedObjects && indices.length === 0 ? 'strictObject' : 'object'}({${properties.length === 0 ? '' : `\n${properties.map(property => ` ${property},`).join('\n')}\n`}})` const index = indices[0] if (index === undefined) return object if (properties.length === 0) return index @@ -998,7 +1052,9 @@ function invocationBoundaryRoots(invocations: readonly InvocationModel[]): Bound result.push({ key: contextBoundaryKey(invocation), type: invocation.invocation.boundary.codecType }) } invocation.parameters.forEach((parameter, index) => { - result.push({ key: parameterBoundaryKey(invocation, index), type: parameter.boundary.codecType }) + result.push({ key: parameterBoundaryKey(invocation, index), type: parameter.boundary.codecType, + ...(invocation.rejectUnknownFields ? { closedObjects: true as const } : {}), + }) }) if (invocation.uplink !== undefined) { result.push({ key: uplinkBoundaryKey(invocation), type: invocation.uplink.boundary.codecType }) @@ -1048,12 +1104,14 @@ function containsBytes( return childTypeNodeIds(node).some(child => containsBytes(renderer, child, nested, visited)) } -function strictCodec(boundary: RemoteBoundaryModel, schema: string, encoder?: string): string { +function strictCodec(boundary: RemoteBoundaryModel, schema: string, encoder?: string, projection?: string, description?: string): string { return [ '{', ' mode: \'strict\',', ` typeSymbol: ${quote(boundary.typeSymbol)},`, ` create: ${schema},`, + ...(description === undefined ? [] : [` description: ${quote(description)},`]), + ...(projection === undefined ? [] : [` ${projection},`]), ...(encoder === undefined ? [] : [ ` decode: value => ${schema}().parse(value),`, ` encode: (value, writeBytes) => ${encoder}(value, writeBytes, [], new Map()),`, @@ -1089,7 +1147,8 @@ function allocateRemoteImportNames(imports: readonly RemoteTypeImportModel[]): R 'RemoteDecoded', 'RemoteResult', 'RemoteStreamHandle', - 'TypertRemoteContribution', + 'TypertRemoteInfo', + 'TypertRemoteMethod', 'TYPERT_REMOTE', ]) const names = new Map() diff --git a/packages/typert/generator/src/model.ts b/packages/typert/generator/src/model.ts index ba88936e26..035248a61a 100644 --- a/packages/typert/generator/src/model.ts +++ b/packages/typert/generator/src/model.ts @@ -122,16 +122,19 @@ export interface InvocationParameterModel { /** Authored as an optional parameter, so consumers may omit the wire field. */ readonly optional?: true readonly boundary: RemoteBoundaryModel + readonly description?: string } /** One strictly analyzed Host method exported through Typert Gateway. */ -export interface InvocationModel { +export interface InvocationModel extends DocumentationModel { readonly id: string readonly service: string readonly namespace: string readonly method: string readonly implementation?: string readonly mode?: 'stream' + /** Reject unknown fields in declared request objects, independently of consumer selection. */ + readonly rejectUnknownFields?: true readonly invocation: | { readonly kind: 'direct' } | { @@ -352,7 +355,7 @@ export interface TemplateSpanModel { } /** Compiler-independent TypeScript type expression. */ -export type TypeNodeModel = +export type TypeNodeModel = ( | { readonly id: TypeNodeId; readonly kind: 'keyword'; readonly name: KeywordTypeName } | { readonly id: TypeNodeId; readonly kind: 'literal'; readonly value: string | number | bigint | boolean | null; readonly text: string } | { readonly id: TypeNodeId; readonly kind: 'parenthesized'; readonly type: TypeNodeId } @@ -390,6 +393,10 @@ export type TypeNodeModel = } | { readonly id: TypeNodeId; readonly kind: 'predicate'; readonly asserts: boolean; readonly parameter: string; readonly type?: TypeNodeId } | { readonly id: TypeNodeId; readonly kind: 'this' } +) & { + /** Canonical lossless JsonValue, represented without recursive tool-schema references. */ + readonly json?: true +} /** * Return the direct type-expression edges owned by one node. diff --git a/packages/typert/generator/src/remote-json-schema.ts b/packages/typert/generator/src/remote-json-schema.ts new file mode 100644 index 0000000000..1c60e9ed59 --- /dev/null +++ b/packages/typert/generator/src/remote-json-schema.ts @@ -0,0 +1,149 @@ +/** Lossless JSON Schema projection for Remote methods selected by command and tool consumers. */ + +import type { MemberModel, TypeNodeId } from './model.ts' +import { TypeGraphRenderer } from './renderer.ts' + +/** A valid Remote codec whose type cannot be represented by the tool-schema subset. */ +export class RemoteJsonSchemaError extends Error { + override name = 'RemoteJsonSchemaError' +} + +interface Schema extends Record { + type?: string + const?: string | number | boolean | null + enum?: (string | number | boolean | null)[] + oneOf?: Schema[] + properties?: Record + required?: string[] + additionalProperties?: boolean + items?: Schema + description?: string +} + +/** + * Project one resolved Remote type without weakening unsupported constraints. + * @param renderer - complete analyzed type graph. + * @param type - resolved wire type. + * @param rejectUnknownFields - close declared request objects with `additionalProperties: false`. + * @param optional - omit a top-level undefined alternative supplied by an optional argument. + * @returns an acyclic schema in the command/tool subset. + */ +export function remoteJsonSchema( + renderer: TypeGraphRenderer, type: TypeNodeId, rejectUnknownFields = false, optional = false, +): Record { + const active = new Set() + const fail = (subject: string): never => { throw new RemoteJsonSchemaError(`typert Remote JSON Schema: ${subject}`) } + const project = (id: TypeNodeId, optional = false): Schema => { + const node = renderer.node(id) + if (node.json) return {} + if (active.has(id)) return fail('recursive selected type requires a supported JSON value declaration') + active.add(id) + try { + switch (node.kind) { + case 'keyword': { + if (['string', 'number', 'boolean'].includes(node.name)) return { type: node.name } + return fail(`unsupported ${node.name} value`) + } + case 'literal': { + if (typeof node.value === 'bigint') return fail('bigint is not JSON') + return { type: node.value === null ? 'null' : typeof node.value, const: node.value } + } + case 'parenthesized': return project(node.type, optional) + case 'array': return { type: 'array', items: project(node.element) } + case 'object': return object(node.members) + case 'reference': { + if (node.target.kind !== 'declaration' || node.arguments.length !== 0) { + return fail(`unsupported selected reference ${node.name}`) + } + const declaration = renderer.declaration(node.target.symbol) + const schema = declaration.kind === 'alias' && declaration.type !== undefined + ? project(declaration.type, optional) + : object(declaration.members) + if (declaration.extends.length > 0) return fail('unresolved inherited selected type') + return declaration.description === undefined ? schema : { ...schema, description: declaration.description } + } + case 'union': { + const branches = node.types.filter((item) => { + const branch = renderer.node(item) + return !(optional && branch.kind === 'keyword' && branch.name === 'undefined') + }).map(item => project(item)) + return union(branches) + } + default: return fail(`unsupported selected ${node.kind} type`) + } + } finally { + active.delete(id) + } + } + const object = (members: readonly MemberModel[]): Schema => { + const properties: Record = {} + const required: string[] = [] + let indexed = false + for (const member of members) { + if (member.static || member.visibility !== 'public' || member.computed === 'symbol') continue + if (member.kind === 'index') { + const key = member.signature.parameters[0] + if (key === undefined || project(key.type).type !== 'string' + || Object.keys(project(member.signature.returns)).length !== 0) { + return fail('selected dictionaries require string keys and unconstrained JSON values') + } + indexed = true + continue + } + if (member.kind !== 'property' || member.computed === 'dynamic') return fail(`unsupported member ${member.name}`) + const name = member.jsonName ?? member.name + const schema = project(member.type, member.optional) + properties[name] = member.description === undefined ? schema : { ...schema, description: member.description } + if (!member.optional) required.push(name) + } + return { type: 'object', properties, required, additionalProperties: indexed || !rejectUnknownFields } + } + const union = (input: Schema[]): Schema => { + if (input.some(branch => Object.keys(branch).length === 0)) return {} + const branches: Schema[] = [] + for (const branch of input) { + const scalar = branch.type !== undefined && !['object', 'array'].includes(branch.type) + const previous = scalar ? branches.find(candidate => candidate.type === branch.type) : undefined + if (previous === undefined) { branches.push(branch); continue } + const values = literals(previous) + const next = literals(branch) + if (values === undefined || next === undefined) { + delete previous.const + delete previous.enum + } else { + delete previous.const + previous.enum = [...new Set([...values, ...next])] + } + } + if (branches.length === 1) return branches[0] as Schema + if (branches.length === 0) return fail('empty selected union') + for (const [index, left] of branches.entries()) { + for (const right of branches.slice(index + 1)) { + if (!disjoint(left, right)) return fail('selected union branches overlap or have no required discriminator') + } + } + return { oneOf: branches } + } + return project(type, optional) +} + +function literals(schema: Schema): (string | number | boolean | null)[] | undefined { + if (schema.const !== undefined) return [schema.const] + return schema.enum +} + +function disjoint(left: Schema, right: Schema): boolean { + if (left.oneOf !== undefined) return left.oneOf.every(branch => disjoint(branch, right)) + if (right.oneOf !== undefined) return right.oneOf.every(branch => disjoint(left, branch)) + if (left.type !== right.type) return left.type !== undefined && right.type !== undefined + if (left.type !== 'object' || right.type !== 'object') return false + return (left.required ?? []).some((name) => { + if (!right.required?.includes(name)) return false + const a = left.properties?.[name] + const b = right.properties?.[name] + if (a === undefined || b === undefined) return false + const first = literals(a) + const second = literals(b) + return first !== undefined && second !== undefined && first.every(value => !second.includes(value)) + }) +} diff --git a/packages/typert/generator/src/workspace.ts b/packages/typert/generator/src/workspace.ts index b27b39eac2..a12b7475c1 100644 --- a/packages/typert/generator/src/workspace.ts +++ b/packages/typert/generator/src/workspace.ts @@ -70,15 +70,16 @@ export class WorkspaceTypertGenerator { /** * Generate all discovered contributors, or an explicit package subset. - * @param packages - optional exact package names for a focused pass. + * @param packages - optional exact package names to emit; every pass includes the same face-wide declaration merges. * @param faces - optional independent program faces to analyze. * @returns one artifact per package face. */ generate(packages?: readonly string[], faces?: readonly TypertFace[]): WorkspaceEmitResult[] { - const selected = packages ?? this.discover(faces).map(candidate => candidate.package) + const contributors = this.discover(faces).map(candidate => candidate.package) + const selected = new Set(packages ?? contributors) const workspace = new WorkspaceAnalyzer({ root: this.root, - packages: selected, + packages: contributors, caches: this.caches, ...(faces === undefined ? {} : { faces }), ...(this.options.checkDiagnostics === undefined ? {} : { checkDiagnostics: this.options.checkDiagnostics }), @@ -88,6 +89,7 @@ export class WorkspaceTypertGenerator { for (const face of workspace.faces) { const emitter = new FaceModelEmitter(face) for (const packageModel of face.packages) { + if (!selected.has(packageModel.name)) continue const artifact = { ...emitter.emit(packageModel.name), packageRoot: packageModel.root, diff --git a/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts b/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts index 5bd9db5755..dad47d0666 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts @@ -23,6 +23,10 @@ declare module '@deepseek-ai/dsh-typert-protocol' { | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: RemoteFailure } + export type RemoteDecoded = Value extends Uint8Array ? Uint8Array + : Value extends string | number | boolean | bigint | symbol ? Value + : Value extends object ? { [Key in keyof Value]: RemoteDecoded } : Value + export type TypertRemoteNamespace = { [Endpoint in keyof TypertRemoteMap as Endpoint extends `${Namespace}/${infer Method}` ? Method @@ -30,10 +34,73 @@ declare module '@deepseek-ai/dsh-typert-protocol' { } export interface TypertRemoteNamespaceMap {} + export interface TypertClientRemote extends TypertRemoteNamespaceMap {} + + export interface TypertRemoteValue { + readonly create: () => { parse(value: unknown): Value } + readonly description?: string + readonly jsonSchema?: Readonly> + readonly jsonSchemaError?: string + } + + export type TypertCodec = + | TypertRemoteValue & { + readonly mode: 'strict' + readonly typeSymbol: string + readonly decode?: (value: unknown) => unknown + readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown + } + | { readonly mode: 'src-json' } + + export interface InvocationParameterDescriptor { + readonly name: string + readonly wire: string + readonly source: 'json' | 'lookup' + readonly lookup?: string + readonly acceptsUndefined?: true + readonly description?: string + readonly codec: TypertCodec + } + + export interface InvocationDescriptor { + readonly id: string + readonly service: string + readonly namespace: string + readonly method: string + readonly implementation?: string + readonly mode?: 'stream' + readonly invocation: + | { readonly kind: 'direct' } + | { readonly kind: 'context'; readonly context: string; readonly wire: string; readonly codec: TypertCodec } + readonly scope?: { readonly context: string; readonly wire: string } + readonly parameters: readonly InvocationParameterDescriptor[] + readonly uplink?: { readonly codec: TypertCodec } + readonly cancellation?: { readonly parameter: 'signal' } + readonly result: TypertCodec + readonly description?: string + readonly sourceLocation?: { readonly file: string; readonly line: number; readonly column: number } + } + + export type TypertRemoteMethod = + Omit + & (Mode extends 'stream' ? { readonly mode: 'stream' } : { readonly mode?: never }) & { + readonly invocation: { readonly kind: 'direct' } + readonly input: TypertRemoteValue + readonly result: Extract, { readonly mode: 'strict' }> + invoke(remote: TypertClientRemote, input: Input, signal?: AbortSignal): Mode extends 'stream' + ? RemoteStreamHandle : Promise> + } export interface TypertRemoteContribution { readonly package: string - readonly descriptors: readonly unknown[] + readonly descriptors: readonly InvocationDescriptor[] + } + + export interface TypertRemoteInfo | TypertRemoteMethod + >> = Readonly | TypertRemoteMethod>>> + extends TypertRemoteContribution { + readonly methods: Methods } export type RemoteStream = AsyncIterable & { readonly uplinkItem?: In } @@ -68,7 +135,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { context: ClassMethodDecoratorContext Result>, ): void - export function Remote(option: string | { readonly mode: 'stream' }): + export function Remote(option: string | { readonly mode?: 'stream'; readonly name?: string; readonly rejectUnknownFields?: true }): ( method: (this: This, ...args: Args) => Result, context: ClassMethodDecoratorContext Result>, diff --git a/packages/typert/generator/tests/remote-metadata.spec.ts b/packages/typert/generator/tests/remote-metadata.spec.ts new file mode 100644 index 0000000000..7068baa48d --- /dev/null +++ b/packages/typert/generator/tests/remote-metadata.spec.ts @@ -0,0 +1,550 @@ +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import ts from 'typescript' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { assertSupportedJsonSchema, validateJsonSchemaValue } from '@deepseek-ai/dsh-tools' +import { WorkspaceTypertGenerator } from '../src/workspace.ts' +import { executableRemoteModule } from './remote-module.ts' + +const roots: string[] = [] +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) + +interface RuntimeValue { + create(): { parse(value: unknown): unknown } + readonly description?: string + readonly jsonSchema?: Record + readonly jsonSchemaError?: string +} + +interface RuntimeDescriptor { + readonly id: string + readonly mode?: 'stream' + readonly description?: string + readonly parameters: readonly { + readonly name: string + readonly wire: string + readonly description?: string + readonly codec: RuntimeValue + }[] + readonly result: RuntimeValue +} + +interface RuntimeMethod extends RuntimeDescriptor { + readonly input: RuntimeValue + invoke(remote: object, input: object, signal?: AbortSignal): unknown +} + +interface Generated { + readonly TYPERT_REMOTE: { + readonly descriptors: readonly RuntimeDescriptor[] + readonly methods: Record + } +} + +function editFile(root: string, path: string, edit: (source: string) => string): void { + const file = join(root, path) + const source = readFileSync(file, 'utf8') + const result = edit(source) + if (result === source) throw new Error(`fixture edit made no change to ${path}`) + writeFileSync(file, result) +} + +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-remote-metadata-')) + roots.push(root) + cpSync(resolve(import.meta.dirname, 'fixtures/remote-model'), root, { recursive: true }) + editFile(root, 'packages/remote/src/index.ts', source => source.replace(' @Remote\n', ` /** + * Create a named goal. + * @param agent - owning Agent. + * @param request - goal fields. + * @param signal - request lifetime. + * @returns the created reference. + */ + @Remote({ rejectUnknownFields: true }) +`)) + editFile(root, 'packages/remote/src/types.ts', source => source.replace( + ' readonly title: string', ' /** Human-readable goal name. */\n readonly title: string', + )) + return root +} + +const executable = executableRemoteModule + +function assertConsumerTypechecks(root: string, dts: string, source: string): void { + const declaration = join(root, 'packages/remote/lib/typert.remote-client.d.ts') + mkdirSync(join(root, 'packages/remote/lib'), { recursive: true }) + writeFileSync(declaration, dts) + const consumer = join(root, 'consumer.ts') + writeFileSync(consumer, source) + const config = ts.readConfigFile(join(root, 'tsconfig.base.json'), path => ts.sys.readFile(path)) + expect(config.error).toBeUndefined() + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root) + expect(parsed.errors).toEqual([]) + const options = { ...parsed.options, composite: false, skipLibCheck: false, paths: { + ...parsed.options.paths, '@fixture/remote/remote': [declaration], + } } + const diagnostics = ts.getPreEmitDiagnostics(ts.createProgram([consumer], options)) + expect(diagnostics.map(diagnostic => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))).toEqual([]) +} + +describe('Remote method metadata', { timeout: 60_000 }, () => { + it('keeps selected and full-face artifacts identical when another package augments the result', async () => { + const root = fixture() + const extension = join(root, 'packages/extension') + mkdirSync(join(extension, 'src'), { recursive: true }) + writeFileSync(join(extension, 'package.json'), JSON.stringify({ + name: '@fixture/extension', private: true, type: 'module', + exports: { + '.': './src/index.ts', + './typert': { types: './lib/typert.host.d.ts', default: './lib/typert.host.js' }, + }, + files: ['lib/typert.host.js', 'lib/typert.host.d.ts'], + })) + writeFileSync(join(extension, 'tsconfig.json'), JSON.stringify({ + extends: '../../tsconfig.base.json', include: ['src'], references: [{ path: '../remote' }], + compilerOptions: { rootDir: 'src', outDir: 'lib/types', noEmit: false, declaration: true, emitDeclarationOnly: true }, + })) + writeFileSync(join(extension, 'src/index.ts'), `import '@fixture/remote/types' +/** @typert schema */ +export interface ExtensionMarker { readonly enabled: boolean } +declare module '@fixture/remote/types' { + interface CreateGoalResult { readonly extension?: string } +} +`) + editFile(root, 'tsconfig.host.json', (source) => { + const config = JSON.parse(source) as { references: { path: string }[] } + config.references.push({ path: './packages/extension' }) + return JSON.stringify(config) + }) + const selected = new WorkspaceTypertGenerator(root).generate(['@fixture/remote'], ['host']) + const complete = new WorkspaceTypertGenerator(root).generate(undefined, ['host']) + expect(selected.map(artifact => artifact.package)).toEqual(['@fixture/remote']) + expect(selected[0]).toEqual(complete.find(artifact => artifact.package === '@fixture/remote')) + const generated = await executable(selected[0]!.remote!.js) + const output = generated.TYPERT_REMOTE.methods['goals/create']!.result.create() + expect(output.parse({ ref: 'created', extension: 'installed' })).toEqual({ ref: 'created', extension: 'installed' }) + expect(() => output.parse({ ref: 'created', extension: 1 })).toThrow() + }) + + it('shares direct descriptors and codec documentation in ordinary Host and Remote artifacts', async () => { + const [artifact] = new WorkspaceTypertGenerator(fixture()).generate() + if (artifact?.remote === undefined) throw new Error('missing Remote artifact') + expect(artifact.remote).not.toHaveProperty('source') + const remote = await executable(artifact.remote.js) + const host = await executableRemoteModule<{ TYPERT: { invocations: RuntimeDescriptor[] } }>(artifact.js) + expect(remote).not.toHaveProperty('REMOTE_METHODS') + expect(Object.keys(remote.TYPERT_REMOTE.methods)).toEqual(['goals/create', 'goals/watch']) + expect(remote.TYPERT_REMOTE.descriptors).toHaveLength(3) + const method = remote.TYPERT_REMOTE.methods['goals/create']! + expect(method).toBe(remote.TYPERT_REMOTE.descriptors[0]) + for (const descriptors of [host.TYPERT.invocations, remote.TYPERT_REMOTE.descriptors]) { + const current = descriptors.find(candidate => candidate.id.endsWith('/create')) as RuntimeMethod + expect(current).toMatchObject({ + id: '@fixture/remote#goals/create', namespace: 'goals', method: 'create', + invocation: { kind: 'direct' }, + }) + expect(current).not.toHaveProperty('metadata') + expect(current).not.toHaveProperty('descriptor') + expect(current).not.toHaveProperty('output') + expect(current).not.toHaveProperty('parameterDescriptions') + expect(current.mode).toBeUndefined() + expect(current.description).toBe('Create a named goal.') + expect(current.parameters).toMatchObject([ + { name: 'agent', wire: 'agentId', description: 'owning Agent.' }, + { name: 'request', wire: 'request', description: 'goal fields.' }, + ]) + expect(current.input.jsonSchema).toEqual(method.input.jsonSchema) + expect(current.input.jsonSchema).toMatchObject({ properties: { + request: { description: 'goal fields.', additionalProperties: false, properties: { title: { description: 'Human-readable goal name.' } } }, + } }) + expect(current.result.description).toBe('the created reference.') + expect(current.result.jsonSchema).toEqual(method.result.jsonSchema) + expect(current.result.jsonSchema?.description).toBe('the created reference.') + expect(current.input.jsonSchemaError).toBeUndefined() + expect(current.result.jsonSchemaError).toBeUndefined() + assertSupportedJsonSchema(current.input.jsonSchema) + assertSupportedJsonSchema(current.result.jsonSchema) + expect(current.result.create().parse({ ref: 'created' })).toEqual({ ref: 'created' }) + expect(() => current.result.create().parse({ ref: 1 })).toThrow() + expect(current.input.create().parse({ agentId: 'agent', request: { title: 'ship' } })).toEqual({ agentId: 'agent', request: { title: 'ship' } }) + for (const input of [ + { request: { title: 'ship' } }, + { agentId: 'agent', request: { title: 'ship', extra: true } }, + { agentId: 'agent' }, + { agentId: 'agent', request: { title: 'ship' }, extra: true }, + ]) expect(() => current.input.create().parse(input)).toThrow() + expect(() => current.parameters[1]!.codec.create().parse({ title: 'ship', extra: true })).toThrow() + const create = vi.fn(async () => ({ ok: true, value: { ref: 'created' } })) + const signal = new AbortController().signal + await expect(current.invoke({ goals: { create } }, { agentId: 'agent', request: { title: 'ship' } }, signal)) + .resolves.toEqual({ ok: true, value: { ref: 'created' } }) + expect(create).toHaveBeenCalledWith('agent', { title: 'ship' }, signal) + } + const scoped = remote.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/rename'))! + for (const descriptor of [scoped, host.TYPERT.invocations.find(candidate => candidate.id === scoped.id)]) { + expect(descriptor).toMatchObject({ invocation: { kind: 'context', context: 'agent' } }) + for (const key of ['metadata', 'descriptor', 'input', 'invoke']) expect(descriptor).not.toHaveProperty(key) + } + expect(remote.TYPERT_REMOTE.methods['goals/rename']).toBeUndefined() + expect(remote.TYPERT_REMOTE.methods['agent:goals/rename']).toBeUndefined() + expect(remote.TYPERT_REMOTE.methods['goals/missing']).toBeUndefined() + expect(remote.TYPERT_REMOTE.methods['goals/watch']).toBe(remote.TYPERT_REMOTE.descriptors[2]) + expect(artifact.remote.dts).toContain('export declare const TYPERT_REMOTE: TypertRemoteInfo<{') + expect(artifact.remote.dts).not.toContain('REMOTE_METHODS') + expect(artifact.remote.dts).toContain("'agent:goals/rename':") + expect(artifact.remote.dts).not.toContain("'goals/rename':") + }) + + it('emits one static method spec without runtime Host imports or eager schemas', async () => { + const [artifact] = new WorkspaceTypertGenerator(fixture()).generate() + if (artifact?.remote === undefined) throw new Error('missing Remote artifact') + expect(artifact.remote.js).toContain('export const TYPERT_REMOTE = createTypertRemoteInfo({') + expect(artifact.remote.js.match(/'goals\/create':/g)).toHaveLength(1) + expect(artifact.remote.js).not.toMatch(/\b(?:id|namespace|method|input|invoke|metadata|output):/) + expect(artifact.remote.js).not.toContain("wire: 'request'") + for (const source of [artifact.js, artifact.remote.js]) { + const imports = ts.createSourceFile('artifact.js', source, ts.ScriptTarget.ESNext, true, ts.ScriptKind.JS) + .statements.filter(ts.isImportDeclaration).map((statement) => { + if (!ts.isStringLiteral(statement.moduleSpecifier)) throw new Error('nonliteral import') + return statement.moduleSpecifier.text + }) + expect(imports.sort()).toEqual(['@deepseek-ai/dsh-typert-protocol/remote-info', 'zod']) + const states = [...source.matchAll(/^let ([\w$]+\$value)$/gm)].map(match => match[1]!) + expect(states.length).toBeGreaterThan(0) + const generated = await executableRemoteModule<{ readSchemaStates(): readonly unknown[] }>( + `${source}\nexport const readSchemaStates = () => [${states.join(', ')}]\n`, + ) + expect(generated.readSchemaStates()).toEqual(states.map(() => undefined)) + } + }) + + it('keeps request objects open unless rejectUnknownFields is enabled', async () => { + const root = fixture() + editFile(root, 'packages/remote/src/index.ts', source => source.replace('@Remote({ rejectUnknownFields: true })', '@Remote')) + const [artifact] = new WorkspaceTypertGenerator(root).generate() + const generated = await executable(artifact!.remote!.js) + const method = generated.TYPERT_REMOTE.methods['goals/create']! + expect(method.input.jsonSchema).toMatchObject({ additionalProperties: false, properties: { request: { additionalProperties: true } } }) + const extra = { agentId: 'agent', request: { title: 'ship', extra: true } } + expect(validateJsonSchemaValue(method.input.jsonSchema!, extra)).toEqual([]) + expect(method.input.create().parse(extra)).toEqual({ agentId: 'agent', request: { title: 'ship' } }) + expect(generated.TYPERT_REMOTE.descriptors[0]!.parameters[1]!.codec.create().parse(extra.request)).toEqual({ title: 'ship' }) + expect(() => method.input.create().parse({ ...extra, unexpected: true })).toThrow() + }) + + it('generates undocumented methods with parsers and optional source documentation', async () => { + const [artifact] = new WorkspaceTypertGenerator(resolve(import.meta.dirname, 'fixtures/remote-model')).generate() + const generated = await executable(artifact!.remote!.js) + const method = generated.TYPERT_REMOTE.methods['goals/create']! + expect(method.description).toBeUndefined() + expect(method.parameters.map(parameter => parameter.description)).toEqual([undefined, undefined]) + expect(method.result.description).toBeUndefined() + expect(method.input.jsonSchemaError).toBeUndefined() + expect(method.result.jsonSchemaError).toBeUndefined() + expect(method.input.create().parse({ agentId: 'agent', request: { title: 'ship' } })).toEqual({ agentId: 'agent', request: { title: 'ship' } }) + expect(method.result.create().parse({ ref: 'created' })).toEqual({ ref: 'created' }) + }) + + it('typechecks ordinary declarations for named invocation, Context exclusion, uplink, and decoded bytes', () => { + const root = fixture() + editFile(root, 'packages/remote/src/index.ts', source => `import type { RemoteStream } from '@deepseek-ai/dsh-typert-protocol'\n${source.replace(' constructor() {', ` @Remote + bytes(): Uint8Array { return new Uint8Array([255]) } + + @Remote({ mode: 'stream' }) + async *attach(): RemoteStream { yield { ref: 'first' } } + + constructor() {`)}`) + const [artifact] = new WorkspaceTypertGenerator(root).generate() + assertConsumerTypechecks(root, artifact!.remote!.dts, `import { TYPERT_REMOTE } from '@fixture/remote/remote' +import type { InvocationDescriptor, TypertClientRemote, TypertRemoteInfo, TypertRemoteMethod, RemoteResult, RemoteStreamHandle, TypertRemoteScopeMap } from '@deepseek-ai/dsh-typert-protocol' +import type { CreateGoalRequest, CreateGoalResult } from '@fixture/remote/types' +declare const remote: TypertClientRemote +const info: TypertRemoteInfo = TYPERT_REMOTE +const method = TYPERT_REMOTE.methods['goals/create'] +const typedMethod: TypertRemoteMethod<{ agentId: string; request: CreateGoalRequest }, CreateGoalResult> = method +const descriptor: InvocationDescriptor = method +const descriptors: readonly InvocationDescriptor[] = TYPERT_REMOTE.descriptors +const unaryMode: undefined = method.mode +const streamMode: 'stream' = TYPERT_REMOTE.methods['goals/attach'].mode +const direct: 'direct' = method.invocation.kind +const id: string = method.id +const codecMode: 'strict' = method.result.mode +const typeSymbol: string = method.result.typeSymbol +const keys: Record = { + 'goals/create': true, 'goals/watch': true, 'goals/bytes': true, 'goals/attach': true, +} +const parsed: { readonly agentId: string; readonly request: CreateGoalRequest } = method.input.create().parse({}) +const output: CreateGoalResult = method.result.create().parse({}) +const value: Promise> = method.invoke(remote, parsed, new AbortController().signal) +// @ts-expect-error Invocation results retain the owner's result fields. +const wrongResult: Promise> = method.invoke(remote, parsed) +// @ts-expect-error Output parsers retain the owner's result fields. +const wrongOutput: string = method.result.create().parse({}) +const description: string | undefined = method.description +const schema: Readonly> | undefined = method.input.jsonSchema +const projectionError: string | undefined = method.input.jsonSchemaError +const parameterDescription: string | undefined = method.parameters[0]?.description +const resultDescription: string | undefined = method.result.description +// @ts-expect-error The request retains the owner's title type. +method.invoke(remote, { agentId: 'agent', request: { title: 1 } }) +// @ts-expect-error Required named arguments cannot be omitted. +method.invoke(remote, { agentId: 'agent' }) +// @ts-expect-error A Context receiver needs scoped invocation. +TYPERT_REMOTE.methods['goals/rename'] +// @ts-expect-error Scoped keys are absent from the direct-method index. +TYPERT_REMOTE.methods['agent:goals/rename'] +// @ts-expect-error Unknown keys are absent from the exact method index. +TYPERT_REMOTE.methods['goals/missing'] +declare const rename: TypertRemoteScopeMap['agent:goals/rename'] +rename({ ref: 'goal', title: 'ship' }) +// @ts-expect-error The method itself is the descriptor. +method.descriptor +// @ts-expect-error Descriptors have no separate method metadata. +method.metadata +// @ts-expect-error Results use the descriptor codec, not an output wrapper. +method.output +// @ts-expect-error Parameter documentation lives on each parameter. +method.parameterDescriptions +const stream = TYPERT_REMOTE.methods['goals/attach'].invoke(remote, {}) +const typedStream: RemoteStreamHandle = stream +stream.send({ title: 'ship' }) +// @ts-expect-error Uplink items retain their declared type. +stream.send({ title: 1 }) +stream.end() +stream.dispose() +const watch = TYPERT_REMOTE.methods['goals/watch'].invoke(remote, { agentId: 'agent' }) +const typedWatch: RemoteStreamHandle = watch +// @ts-expect-error A downlink-only method accepts no uplink items. +watch.send({ title: 'ship' }) +watch.end() +watch.dispose() +async function bytes() { + const parsed = TYPERT_REMOTE.methods['goals/bytes'].result.create().parse(new Uint8Array(new SharedArrayBuffer(1))) + const native: Uint8Array = parsed + // @ts-expect-error Parsing does not reconstruct carrier-owned ArrayBuffer backing. + const narrowed: Uint8Array = parsed + void [native, narrowed] + const result = await TYPERT_REMOTE.methods['goals/bytes'].invoke(remote, {}) + if (!result.ok) return + const data: Uint8Array = result.value + new Blob([data]) + // @ts-expect-error Decoded bytes have ordinary ArrayBuffer backing. + const shared: Uint8Array = result.value + // @ts-expect-error Binary results are not base64 strings. + const base64: string = result.value + void shared + void base64 +} +void [info, typedMethod, descriptor, descriptors, unaryMode, streamMode, direct, id, codecMode, typeSymbol, keys] +void [output, value, wrongResult, wrongOutput, description, schema, projectionError, parameterDescription, resultDescription, typedStream, typedWatch, bytes] +`) + }) + + it('keeps overlapping-union input parsers and source docs when JSON Schema projection is unsupported', async () => { + const root = fixture() + editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string', 'readonly title: { first: string } | { second: string }')) + const [artifact] = new WorkspaceTypertGenerator(root).generate() + const generated = await executable(artifact!.remote!.js) + const method = generated.TYPERT_REMOTE.methods['goals/create']! + expect(method.description).toBe('Create a named goal.') + expect(method.parameters.find(parameter => parameter.wire === 'request')?.description).toBe('goal fields.') + expect(method.input.jsonSchema).toBeUndefined() + expect(method.input.jsonSchemaError).toContain('selected union branches overlap') + for (const title of [{ first: 'a' }, { second: 'b' }]) { + const input = { agentId: 'agent', request: { title } } + expect(method.input.create().parse(input)).toEqual(input) + expect(generated.TYPERT_REMOTE.descriptors[0]!.parameters[1]!.codec.create().parse(input.request)).toEqual(input.request) + } + expect(() => method.input.create().parse({ agentId: 'agent', request: { title: {} } })).toThrow() + }) + + it('supports optional named arguments and parameterless methods', async () => { + const root = fixture() + editFile(root, 'packages/remote/src/index.ts', source => source.replace(' constructor() {', ` @Remote + ready(): boolean { return true } + + @Remote + labelled(id: string, label?: string): string { return label ?? id } + + constructor() {`)) + const [artifact] = new WorkspaceTypertGenerator(root).generate() + const generated = await executable(artifact!.remote!.js) + const ready = generated.TYPERT_REMOTE.methods['goals/ready']! + expect(ready.input.jsonSchema).toEqual({ type: 'object', properties: {}, required: [], additionalProperties: false }) + expect(ready.input.create().parse({})).toEqual({}) + expect(() => ready.input.create().parse({ extra: true })).toThrow() + const callReady = vi.fn(async () => ({ ok: true, value: true })) + await expect(ready.invoke({ goals: { ready: callReady } }, {})).resolves.toEqual({ ok: true, value: true }) + expect(callReady).toHaveBeenCalledWith() + const labelled = generated.TYPERT_REMOTE.methods['goals/labelled']! + expect(labelled.input.jsonSchema).toMatchObject({ required: ['id'], properties: { label: { type: 'string' } } }) + for (const input of [{ id: 'goal' }, { id: 'goal', label: 'ship' }]) { + expect(validateJsonSchemaValue(labelled.input.jsonSchema!, input)).toEqual([]) + expect(labelled.input.create().parse(input)).toEqual(input) + } + expect(() => labelled.input.create().parse({ id: 'goal', label: 1 })).toThrow() + const callLabelled = vi.fn(async () => ({ ok: true, value: 'goal' })) + await labelled.invoke({ goals: { labelled: callLabelled } }, { id: 'goal' }) + expect(callLabelled).toHaveBeenCalledWith('goal', undefined) + assertConsumerTypechecks(root, artifact!.remote!.dts, `import { TYPERT_REMOTE } from '@fixture/remote/remote' +import type { TypertClientRemote, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +declare const remote: TypertClientRemote +const ready: Promise> = TYPERT_REMOTE.methods['goals/ready'].invoke(remote, {}) +TYPERT_REMOTE.methods['goals/labelled'].invoke(remote, { id: 'goal' }) +TYPERT_REMOTE.methods['goals/labelled'].invoke(remote, { id: 'goal', label: 'ship' }) +// @ts-expect-error Optional labels still require strings when present. +TYPERT_REMOTE.methods['goals/labelled'].invoke(remote, { id: 'goal', label: 1 }) +void ready +`) + }) + + it('preserves discriminator unions and nested unknown-field rejection in tool schemas', async () => { + const root = fixture() + editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string', + "readonly title: { readonly kind: 'text'; readonly text: string } | { readonly kind: 'count'; readonly count: number }")) + const [artifact] = new WorkspaceTypertGenerator(root).generate() + const generated = await executable(artifact!.remote!.js) + const method = generated.TYPERT_REMOTE.methods['goals/create']! + const schema = method.input.jsonSchema + assertSupportedJsonSchema(schema) + for (const title of [{ kind: 'text', text: 'ship' }, { kind: 'count', count: 2 }]) { + const input = { agentId: 'agent', request: { title } } + expect(validateJsonSchemaValue(schema, input)).toEqual([]) + expect(method.input.create().parse(input)).toEqual(input) + } + const extra = { agentId: 'agent', request: { title: { kind: 'text', text: 'ship', count: 2 } } } + expect(validateJsonSchemaValue(schema, extra)).not.toEqual([]) + expect(() => method.input.create().parse(extra)).toThrow() + }) + + it('returns the ordinary stream handle without wrapping its uplink or disposal', async () => { + const [artifact] = new WorkspaceTypertGenerator(fixture()).generate() + const generated = await executable(artifact!.remote!.js) + const handle = Object.assign((async function* () { yield { ref: 'first' } })(), { + send: vi.fn(), end: vi.fn(), dispose: vi.fn(), + }) + const watch = vi.fn(() => handle) + const method = generated.TYPERT_REMOTE.methods['goals/watch']! + expect(method.mode).toBe('stream') + const signal = new AbortController().signal + expect(method.invoke({ goals: { watch } }, { agentId: 'agent' }, signal)).toBe(handle) + expect(watch).toHaveBeenCalledWith('agent', signal) + const items = [] + for await (const item of handle) items.push(item) + expect(items).toEqual([{ ref: 'first' }]) + }) + + it('projects canonical JsonValue and JSON dictionaries without recursive schema references', async () => { + const root = fixture() + const directory = join(root, 'packages/json-values-fixture') + mkdirSync(join(directory, 'src'), { recursive: true }) + writeFileSync(join(directory, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-util-values', exports: { '.': './src/index.ts' } })) + writeFileSync(join(directory, 'tsconfig.json'), readFileSync(join(root, 'packages/domain/tsconfig.json'), 'utf8')) + writeFileSync(join(directory, 'src/index.ts'), 'export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n') + editFile(root, 'tsconfig.host.json', (source) => { + const config = JSON.parse(source) as { references: { path: string }[] } + config.references.push({ path: './packages/json-values-fixture' }) + return JSON.stringify(config) + }) + editFile(root, 'tsconfig.base.json', (source) => { + const config = JSON.parse(source) as { compilerOptions: { paths: Record } } + config.compilerOptions.paths['@deepseek-ai/dsh-util-values'] = ['./packages/json-values-fixture/src/index.ts'] + return JSON.stringify(config) + }) + editFile(root, 'packages/remote/src/types.ts', source => "import type { JsonValue } from '@deepseek-ai/dsh-util-values'\n" + source + .replace('readonly title: string', 'readonly title: string\n readonly payload: JsonValue\n readonly labels?: Record')) + const [artifact] = new WorkspaceTypertGenerator(root).generate() + const generated = await executable(artifact!.remote!.js) + const method = generated.TYPERT_REMOTE.methods['goals/create']! + const schema = method.input.jsonSchema + assertSupportedJsonSchema(schema) + expect(JSON.stringify(schema)).not.toContain('$ref') + const input = { agentId: 'agent', request: { title: 'ship', payload: [null, { nested: [true, 3] }], labels: { tag: ['a', 'b'] } } } + expect(validateJsonSchemaValue(schema, input)).toEqual([]) + expect(method.input.create().parse(input)).toEqual(input) + expect(() => method.input.create().parse({ agentId: 'agent', request: { title: 'ship', payload: undefined } })).toThrow() + }) + + it('projects real nominal primitive brands without treating the marker as an object', async () => { + const root = fixture() + const real = resolve(import.meta.dirname, '../../../util/brand') + const destination = join(root, 'packages/util/brand') + mkdirSync(join(destination, 'src'), { recursive: true }) + for (const file of ['package.json', 'tsconfig.json', 'src/index.ts']) { + writeFileSync(join(destination, file), readFileSync(join(real, file))) + } + editFile(root, 'tsconfig.host.json', (source) => { + const config = JSON.parse(source) as { references: { path: string }[] } + config.references.push({ path: './packages/util/brand' }) + return JSON.stringify(config) + }) + editFile(root, 'tsconfig.base.json', (source) => { + const config = JSON.parse(source) as { compilerOptions: { paths: Record } } + config.compilerOptions.paths['@deepseek-ai/dsh-brand'] = ['./packages/util/brand/src/index.ts'] + return JSON.stringify(config) + }) + editFile(root, 'packages/domain/src/types.ts', () => `import type { Branded } from '@deepseek-ai/dsh-brand' +/** Stable nominal Agent identity. */ +export type AgentId = Branded<'AgentId'> +`) + const [artifact] = new WorkspaceTypertGenerator(root).generate() + const generated = await executable(artifact!.remote!.js) + const method = generated.TYPERT_REMOTE.methods['goals/create']! + expect(method.input.jsonSchema).toMatchObject({ properties: { agentId: { type: 'string' } } }) + expect(method.input.create().parse({ agentId: 'agent', request: { title: 'ship' } })) + .toEqual({ agentId: 'agent', request: { title: 'ship' } }) + expect(artifact!.remote!.dts).toContain("readonly 'agentId': AgentId") + }) + + it('closes merged object intersections once, including nested request objects', async () => { + const root = fixture() + editFile(root, 'packages/remote/src/types.ts', source => source.replace(/export interface CreateGoalRequest \{[\s\S]*?\n\}/, + `export type CreateGoalRequest = { + readonly title: string + readonly nested: { readonly alpha: string } & { readonly beta: number } +} & { readonly priority: number }`)) + const [artifact] = new WorkspaceTypertGenerator(root).generate() + const generated = await executable(artifact!.remote!.js) + const method = generated.TYPERT_REMOTE.methods['goals/create']! + const input = { agentId: 'agent', request: { title: 'ship', nested: { alpha: 'a', beta: 2 }, priority: 3 } } + const schema = method.input.jsonSchema + assertSupportedJsonSchema(schema) + expect(validateJsonSchemaValue(schema, input)).toEqual([]) + expect(method.input.create().parse(input)).toEqual(input) + const extra = { ...input, request: { ...input.request, nested: { ...input.request.nested, extra: true } } } + expect(validateJsonSchemaValue(schema, extra)).not.toEqual([]) + expect(() => method.input.create().parse(extra)).toThrow() + }) + + it('retains output codecs and source docs when optional JSON Schema projection is unsupported', async () => { + const root = fixture() + editFile(root, 'packages/remote/src/index.ts', source => source.replace('Promise', 'Promise') + .replace('return { ref: `${agent.id}:${request.title}` }', 'return [agent.id, request.title.length]')) + const [artifact] = new WorkspaceTypertGenerator(root).generate() + const generated = await executable(artifact!.remote!.js) + const output = generated.TYPERT_REMOTE.methods['goals/create']!.result + expect(output.description).toBe('the created reference.') + expect(output.jsonSchema).toBeUndefined() + expect(output.jsonSchemaError).toContain('unsupported selected tuple type') + expect(output.create().parse(['goal', 2])).toEqual(['goal', 2]) + expect(() => output.create().parse(['goal', 'not-number'])).toThrow() + }) + + it('keeps recursive codec references valid when removing a nominal object marker', async () => { + const root = fixture() + editFile(root, 'packages/remote/src/types.ts', source => source.replace(/export interface CreateGoalResult \{[\s\S]*?\n\}/, `declare const nodeBrand: unique symbol +export type CreateGoalResult = { readonly ref: string; readonly next?: CreateGoalResult } & { readonly [nodeBrand]: 'node' }`)) + editFile(root, 'packages/remote/src/index.ts', source => source + .replace('return { ref: `${agent.id}:${request.title}` }', 'return { ref: `${agent.id}:${request.title}` } as CreateGoalResult') + .replace('yield { ref: agent.id }', 'yield { ref: agent.id } as CreateGoalResult')) + const [artifact] = new WorkspaceTypertGenerator(root).generate() + const generated = await executable(artifact!.remote!.js) + const output = generated.TYPERT_REMOTE.methods['goals/create']!.result + expect(output.jsonSchema).toBeUndefined() + expect(output.jsonSchemaError).toContain('recursive selected type') + expect(output.create().parse({ ref: 'first', next: { ref: 'second' } })) + .toEqual({ ref: 'first', next: { ref: 'second' } }) + expect(() => output.create().parse({ ref: 'first', next: { ref: 2 } })).toThrow() + }) +}) diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index e8e69be85f..0dafb01694 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { WorkspaceAnalyzer } from '../src/analyzer.ts' import type { InvocationModel } from '../src/model.ts' import { WorkspaceTypertGenerator } from '../src/workspace.ts' +import { executableRemoteModule } from './remote-module.ts' const fixtureRoot = resolve(import.meta.dirname, 'fixtures/remote-model') const temporaryRoots: string[] = [] @@ -32,6 +33,8 @@ interface RuntimeDescriptor { } readonly result: { readonly create: () => RuntimeSchema + readonly jsonSchema?: Record + readonly jsonSchemaError?: string readonly decode?: (value: unknown) => unknown readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown } @@ -41,6 +44,7 @@ interface RuntimeRemoteModule { readonly TYPERT_REMOTE: { readonly package: string readonly descriptors: readonly RuntimeDescriptor[] + readonly methods: Readonly> } } @@ -55,7 +59,7 @@ afterEach(() => { }) describe('Remote model generation', { timeout: 60_000 }, () => { - it('projects a generic binary result without copying or freezing its bytes', async () => { + it('projects ordinary unary binary results and metadata without copying or freezing bytes', async () => { const root = copyFixture() editFile(root, 'packages/remote/src/types.ts', source => `${source}\nexport type BinaryFile = {\n readonly data: Data\n} & { readonly size: number }\n`) editFile(root, 'packages/remote/src/index.ts', source => `import type { BinaryFile } from './types.ts'\n${source.replace( @@ -65,12 +69,13 @@ describe('Remote model generation', { timeout: 60_000 }, () => { const [artifact] = new WorkspaceTypertGenerator(root).generate() expect(artifact?.js).not.toContain('binaryResult') expect(artifact?.remote?.dts).toContain('Promise>>') - const generated = await import(`data:text/javascript,${encodeURIComponent( - (artifact?.remote?.js ?? '').replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`), - )}`) as RuntimeRemoteModule + const generated = await executableRemoteModule(artifact?.remote?.js ?? '') const descriptor = generated.TYPERT_REMOTE.descriptors.find(invocation => invocation.id.endsWith('/bytes')) expect(descriptor?.result.decode).toBeTypeOf('function') if (descriptor === undefined) throw new Error('binary descriptor missing') + expect(generated.TYPERT_REMOTE.methods['goals/bytes']).toBe(descriptor) + expect(descriptor.result.jsonSchema).toBeUndefined() + expect(descriptor.result.jsonSchemaError).toContain('Remote JSON Schema:') const schema = descriptor.result.create() for (const data of [new Uint8Array(), new Uint8Array([0, 128, 255]), new Uint8Array(new SharedArrayBuffer(3))]) { const parsed = schema.safeParse({ data, size: data.length }) @@ -163,9 +168,7 @@ export interface BinaryTree { }\n\nexport type {`, )}`) const [artifact] = new WorkspaceTypertGenerator(root).generate() - const generated = await import(`data:text/javascript,${encodeURIComponent( - (artifact?.remote?.js ?? '').replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`), - )}`) as RuntimeRemoteModule + const generated = await executableRemoteModule(artifact?.remote?.js ?? '') const codec = (method: string) => { const descriptor = generated.TYPERT_REMOTE.descriptors.find(item => item.id.endsWith(`/${method}`)) if (descriptor === undefined) throw new Error(`descriptor missing: ${method}`) @@ -364,7 +367,7 @@ void binaryTree result: { typeSymbol: '@fixture/remote/types#CreateGoalResult' }, }) - expect(artifact?.js).toContain('invocations: [') + expect(artifact?.js).toContain('get invocations() { return $remote.descriptors }') expect(artifact?.remote?.dts).toContain( "'goals/create': (agentId: AgentId, request: CreateGoalRequest, signal?: AbortSignal) => Promise>", ) @@ -382,8 +385,7 @@ void binaryTree const remoteJs = artifact?.remote?.js if (remoteJs === undefined) throw new Error('Remote fixture emitted no Host-for-Client JavaScript') - const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`) - const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule + const generated = await executableRemoteModule(remoteJs) expect(generated.TYPERT_REMOTE.package).toBe('@fixture/remote') const create = generated.TYPERT_REMOTE.descriptors[0] expect(create?.cancellation).toEqual({ parameter: 'signal' }) @@ -438,8 +440,7 @@ export type {`, const remoteJs = artifact?.remote?.js if (remoteJs === undefined) throw new Error('undefined Remote fixture emitted no Host-for-Client JavaScript') - const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`) - const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule + const generated = await executableRemoteModule(remoteJs) const maybe = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/maybe')) const clear = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/clear')) expect(maybe?.parameters[0]?.acceptsUndefined).toBe(true) @@ -504,7 +505,7 @@ export type {`, const [artifact] = new WorkspaceTypertGenerator(root).generate() expect(artifact?.remote?.dts).toContain( - " RemoteStreamHandle,\n TypertRemoteContribution,\n} from '@deepseek-ai/dsh-typert-protocol'", + " RemoteStreamHandle,\n TypertRemoteInfo,\n TypertRemoteMethod,\n} from '@deepseek-ai/dsh-typert-protocol'", ) expect(artifact?.remote?.dts).toContain("declare module '@deepseek-ai/dsh-typert-protocol' {") expect(artifact?.remote?.dts).toContain( @@ -518,8 +519,7 @@ export type {`, const remoteJs = artifact?.remote?.js if (remoteJs === undefined) throw new Error('RemoteStream fixture emitted no Host-for-Client JavaScript') - const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`) - const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule + const generated = await executableRemoteModule(remoteJs) const descriptor = generated.TYPERT_REMOTE.descriptors.find(candidate => candidate.id.endsWith('/attach')) expect(descriptor?.mode).toBe('stream') expect(descriptor?.uplink?.codec.create().safeParse({ title: 'ship' }).success).toBe(true) @@ -540,7 +540,7 @@ export type {`, name: 'an unknown Remote mode', mode: 'duplex', method: 'async *attach(agent: Agent, signal: AbortSignal): AsyncIterable', - message: 'Remote\\(\\) options must contain exactly mode: "stream"', + message: 'Remote\\(\\) options require name, mode: "stream", or rejectUnknownFields: true', }, ])('rejects $name', ({ mode, method, message }) => { const root = copyFixture() @@ -553,6 +553,13 @@ export type {`, expect(() => analyzeRemote(root, false)).toThrow(new RegExp(message)) }) + it.each(['committed', 'emitSourceDef'])('rejects the removed %s option', (option) => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source.replace(' @Remote\n', ` @Remote({ ${option}: true })\n`)) + expect(() => analyzeRemote(root, false)) + .toThrow('Remote() options require name, mode: "stream", or rejectUnknownFields: true') + }) + it('evaluates declaration-merged mapped and conditional boundaries for codecs without widening consumer types', async () => { const root = copyFixture() editFile(root, 'packages/remote/src/types.ts', source => `${source} @@ -613,8 +620,7 @@ export type GenericResult = { ) const remoteJs = artifact?.remote?.js if (remoteJs === undefined) throw new Error('generic Remote fixture emitted no Host-for-Client JavaScript') - const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`) - const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule + const generated = await executableRemoteModule(remoteJs) const dispatch = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/dispatch')) const schema = dispatch?.parameters[0]?.codec.create() expect(schema?.safeParse({ kind: 'ship', payload: { count: 2, meta: { nested: [true, null] } } }).success).toBe(true) @@ -757,6 +763,14 @@ export interface ClientMarker { ), message: 'Gateway service key must be a string literal', }, + { + name: 'source definitions in a Gateway binding', + edit: (source: string) => source.replace( + " super(undefined, 'goals')", + " super(undefined, 'goals', { definitions: {} })", + ), + message: 'only supports a namespace option', + }, { name: 'TypertRemoteService without a constructor', edit: (source: string) => source.replace( diff --git a/packages/typert/generator/tests/remote-module.ts b/packages/typert/generator/tests/remote-module.ts new file mode 100644 index 0000000000..88946cd6f3 --- /dev/null +++ b/packages/typert/generator/tests/remote-module.ts @@ -0,0 +1,22 @@ +/** Source-only evaluator for generated Remote and Host invocation artifacts. */ + +import { readFileSync } from 'node:fs' +import ts from 'typescript' + +/** + * Load generated JavaScript with the current protocol factory, not built workspace libraries. + * @param source - generated JavaScript containing only Zod and pure Remote-info imports. + * @returns the evaluated generated module. + */ +export async function executableRemoteModule(source: string): Promise { + const zodImport = `from ${JSON.stringify(import.meta.resolve('zod'))}` + const factory = ts.transpileModule( + readFileSync(new URL('../../protocol/src/remote-info.ts', import.meta.url), 'utf8'), + { compilerOptions: { target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.ESNext } }, + ).outputText.replace(/from (['"])zod\1/g, zodImport) + const factoryUrl = `data:text/javascript,${encodeURIComponent(factory)}` + const executable = source + .replace(/from (['"])zod\1/g, zodImport) + .replace(/from (['"])@deepseek-ai\/dsh-typert-protocol\/remote-info\1/g, `from ${JSON.stringify(factoryUrl)}`) + return await import(`data:text/javascript,${encodeURIComponent(executable)}`) as Module +} diff --git a/packages/typert/loader/README.i18n.yaml b/packages/typert/loader/README.i18n.yaml index 17cfc16273..92931f8cf0 100644 --- a/packages/typert/loader/README.i18n.yaml +++ b/packages/typert/loader/README.i18n.yaml @@ -21,8 +21,8 @@ en: 881670b2d7dfb04d zh: fbf612e945aaf066 /deepseek-ai-dsh-typert-loader/use-this-package/what-gets-registered: - en: 01318fcba39e9b0f - zh: c5f6528a91b335ac + en: 73660724a51afcea + zh: 448a32bb29614c7a /deepseek-ai-dsh-typert-loader/use-this-package/observable-behavior-and-failures: en: 01494e9fbd469ec8 zh: 3025d870c0f36e2b @@ -33,8 +33,8 @@ en: 03fc077e58271e18 zh: 20f8fc02008cee5e /deepseek-ai-dsh-typert-loader/understand-the-implementation/manifest-validation: - en: eb246aef15ea0532 - zh: 7a4a7bcc788b3ba3 + en: b16629b4ed1af907 + zh: 5db61c1a9b8606a8 /deepseek-ai-dsh-typert-loader/understand-the-implementation/caching-and-ownership: en: 38e781e052fdf44e zh: d8f6b4d119bf9856 @@ -51,8 +51,8 @@ en: f8a0ad3d0fc0eb32 zh: 7649a694b279f5f3 /deepseek-ai-dsh-typert-loader/known-limitations-and-deferred-work: - en: a7e281826b58b218 - zh: b4347c965151a1a9 + en: 9bc795b850f7ab8a + zh: f3aea571a70bf327 /deepseek-ai-dsh-typert-loader/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/typert/loader/README.md b/packages/typert/loader/README.md index f58c0fae63..e2750ab900 100644 --- a/packages/typert/loader/README.md +++ b/packages/typert/loader/README.md @@ -44,7 +44,9 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### What gets registered -Each qualifying Loader entry contributes its generated host-face reflection and schema factories to the runtime registry. Registration follows the entry lifecycle: it is withdrawn when the entry or the plugin unmounts, and an import that settles after either owner is gone is discarded. +Each qualifying Loader entry contributes its generated host-face reflection, schema factories, and `TYPERT.invocations` to the runtime registry. The ordinary artifact's getter exposes descriptors from a private Remote info instance; generated direct descriptors also provide named input and invocation helpers. Registration follows the entry lifecycle: it is withdrawn when the entry or the plugin unmounts, and an import that settles after either owner is gone is discarded. + +Source compositions requiring strict Remote validation prebuild and load the ordinary `./typert` artifact. The loader registers its invocations in `ctx.typert.local`; it does not recover erased constraints, documentation, or schemas from live Services. Limited descriptor-free source fallback remains a Gateway concern, and `local.hasSeen()` prevents downgrading to it after a registered definition is withdrawn. ### Observable behavior and failures @@ -66,7 +68,7 @@ The plugin is an incremental scanner mirroring the client-modules node half: eve ### Manifest validation -`validateTypertManifest()` is the module/file boundary: the manifest crosses from a build artifact into the typed registry, so every field is checked. The manifest must name the package that exports it, carry face `host`, hold schema factories, and keep well-formed service, event, object, member, type, and documentation records; invocation descriptors must use strict codecs with factories. Every failure names the package and the defect. +`validateTypertManifest()` checks artifacts before they enter the typed registry. A manifest must name its exporting package, carry face `host`, hold schema factories, and contain well-formed reflection records; invocation descriptors require strict codecs with factories. Generated direct-method `input` and `invoke` capabilities are validated on the descriptor itself, while `result` remains its sole output codec. Documentation and JSON Schema are optional. Validation inspects eager projection data without invoking lazy projection getters or constructing schemas; consumers request the derived projections they need. Every failure names the package and the defect. ### Caching and ownership @@ -113,6 +115,7 @@ These limits define what the loader does not discover or register; they are curr - **Host face only** — discovery imports only the host `./typert` artifact; client runtimes need a separate composition owner before equivalent discovery is added. - **Explicit entries for nested plugins** — Loader entries are discovered automatically, but plugins nested behind another entry, or not loaded by the Loader at all, need an explicit `packages` entry or direct `ctx.typert.register()` ownership. - **Cached verdicts never expire** — a package that gains a `./typert` export mid-process needs a restart before the loader registers it. +- **Host readiness remains composition-owned** — consumers requiring strict metadata must wait for registration before serving calls. The lazy info factory does not make asynchronous Loader registration ready; `local.hasSeen()` only protects definitions already observed. ### Dev Note diff --git a/packages/typert/loader/README.zh.md b/packages/typert/loader/README.zh.md index 0f5a32594c..eb4f1125f8 100644 --- a/packages/typert/loader/README.zh.md +++ b/packages/typert/loader/README.zh.md @@ -44,7 +44,9 @@ kind: "package-reference" ### 注册什么 -每个符合条件的 Loader 配置项都会把其生成的宿主侧反射与 schema factory 贡献给运行时注册表。注册跟随配置项生命周期:配置项或本插件卸载时撤销;在配置项或本插件任一方卸载后才结束的导入操作会被丢弃。 +每个符合条件的 Loader 配置项都会把其生成的宿主侧反射、schema factory 和 `TYPERT.invocations` 贡献给运行时注册表。普通产物的 getter 暴露来自私有 Remote info 实例的描述符;生成的直接方法描述符还提供具名输入与调用辅助能力。注册跟随配置项生命周期:配置项或本插件卸载时撤销;在配置项或本插件任一方卸载后才结束的导入操作会被丢弃。 + +要求严格 Remote 校验的源码组合预先构建并加载普通 `./typert` 产物。loader 将其调用定义注册到 `ctx.typert.local`;它不会从运行中的 Service 恢复已擦除的约束、文档或 schema。受限的无描述符源码回退仍由 Gateway 负责,`local.hasSeen()` 会阻止已注册定义撤销后降级到该回退。 ### 可观察行为与失败 @@ -66,7 +68,7 @@ kind: "package-reference" ### Manifest 校验 -`validateTypertManifest()` 是模块/文件边界:manifest 从构建产物进入类型化注册表,因此每个字段都会被检查。manifest 必须指名导出它的包、携带 `host` face、持有 schema factory,并保持服务、事件、对象、成员、类型与文档记录格式正确;调用描述符必须使用带 factory 的严格编解码器。每次失败都会指名包与缺陷。 +`validateTypertManifest()` 在产物进入类型化注册表前执行检查。manifest 必须指名导出它的包、携带 `host` face、持有 schema factory,并包含格式正确的反射记录;调用描述符要求带 factory 的严格 codec。生成直接方法的 `input` 和 `invoke` 能力直接在描述符上验证,而 `result` 仍是唯一输出 codec。文档和 JSON Schema 可选。校验读取已提供的投影数据,不会调用惰性投影 getter 或构建 schema;消费方按需请求派生投影。每次失败都会指名包与缺陷。 ### 缓存与归属 @@ -113,6 +115,7 @@ kind: "package-reference" - **仅宿主侧**——发现机制只会导入宿主侧 `./typert` 产物;在为客户端运行时添加等价的发现机制之前,需要先有独立的组合所有者。 - **嵌套插件需要显式条目**——Loader 配置项会被自动发现,但嵌套在另一配置项之下、或完全不经 Loader 加载的插件,需要显式加入 `packages`,或由其所有者直接调用 `ctx.typert.register()`。 - **缓存结论永不过期**——进程中途新增 `./typert` 导出的包需要重启后 loader 才会注册它。 +- **Host 就绪仍由组合负责**——要求严格元数据的消费方必须等待注册完成后再接受调用。惰性 info factory 不会让异步 Loader 注册自动就绪;`local.hasSeen()` 只保护已经观察到的定义。 ### 开发备注 diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json index 060afff278..19810f3ca1 100644 --- a/packages/typert/loader/package.json +++ b/packages/typert/loader/package.json @@ -40,7 +40,9 @@ "@deepseek-ai/dsh-app-boot": "workspace:*", "@deepseek-ai/cordis-plugin-loader": "workspace:~", "@deepseek-ai/dsh-typert-registry": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/cordis": "workspace:~", + "typescript": "^6.0.3", "zod": "4.4.3" } } diff --git a/packages/typert/loader/src/index.ts b/packages/typert/loader/src/index.ts index 50ec19165d..06391c9d2f 100644 --- a/packages/typert/loader/src/index.ts +++ b/packages/typert/loader/src/index.ts @@ -80,8 +80,9 @@ function typertExportOf(pkgName: string, exportsField: unknown): string | undefi /** * Narrow a dynamically imported typert module's `TYPERT` export to a * contribution owned by `pkgName`. This is the module/file boundary: the - * manifest crosses from a build artifact into the typed registry, so every - * field is checked and every failure names the package and the defect. + * manifest crosses from a build artifact into the typed registry. Dispatch + * facts and eager projection fields are checked; computed tool projections + * remain lazy until a consumer requests them. Failures name the package and defect. * @param pkgName - the package whose typert face was imported. * @param exported - the module's `TYPERT` export. * @returns the validated contribution. @@ -165,6 +166,12 @@ function requireString(pkgName: string, value: Record, key: str } } +function requireOptionalString(pkgName: string, value: Record, key: string, subject: string): void { + if (value[key] !== undefined && typeof value[key] !== 'string') { + throw new Error(`typert-loader: ${pkgName} ${subject}.${key} must be a string`) + } +} + function requireDocumentation(pkgName: string, value: Record, subject: string): void { requireArray(pkgName, value.tags, `${subject}.tags`) for (const key of ['description', 'summary', 'jsDoc'] as const) { @@ -199,6 +206,10 @@ function requireInvocation(pkgName: string, value: unknown): void { requireString(pkgName, invocation, key, 'invocation') } const id = invocation.id as string + requireOptionalString(pkgName, invocation, 'description', `invocation "${id}"`) + if (invocation.mode !== undefined && invocation.mode !== 'stream') { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" mode must be "stream" when present`) + } const receiver = requireObject(pkgName, invocation.invocation, `invocation "${id}" receiver`) if (receiver.kind === 'context') { requireString(pkgName, receiver, 'context', `invocation "${id}" Context receiver`) @@ -214,6 +225,7 @@ function requireInvocation(pkgName: string, value: unknown): void { const parameter = requireObject(pkgName, valueParameter, `invocation "${id}" parameter`) requireString(pkgName, parameter, 'name', `invocation "${id}" parameter`) requireString(pkgName, parameter, 'wire', `invocation "${id}" parameter`) + requireOptionalString(pkgName, parameter, 'description', `invocation "${id}" parameter`) const wire = parameter.wire as string if (wires.has(wire)) { throw new Error(`typert-loader: ${pkgName} invocation "${id}" repeats wire field "${wire}"`) @@ -256,6 +268,16 @@ function requireInvocation(pkgName: string, value: unknown): void { throw new Error(`typert-loader: ${pkgName} invocation "${id}" repeats Context wire field "${receiver.wire as string}"`) } requireStrictCodec(pkgName, invocation.result, `invocation "${id}" result codec`) + if (invocation.input !== undefined || invocation.invoke !== undefined) { + if (receiver.kind !== 'direct') { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" input/invoke require a direct receiver`) + } + const input = requireObject(pkgName, invocation.input, `invocation "${id}" input`) + requireSchemaProjection(pkgName, input, `invocation "${id}" input`) + if (typeof invocation.invoke !== 'function') { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" has no invoke() function`) + } + } if (invocation.sourceLocation !== undefined) { const location = requireObject(pkgName, invocation.sourceLocation, `invocation "${id}" sourceLocation`) requireString(pkgName, location, 'file', `invocation "${id}" sourceLocation`) @@ -267,6 +289,22 @@ function requireInvocation(pkgName: string, value: unknown): void { } } +function requireSchemaProjection(pkgName: string, value: Record, subject: string): void { + if (typeof value.create !== 'function') { + throw new Error(`typert-loader: ${pkgName} ${subject} has no create() factory`) + } + // Inspect supplied data without invoking the factory's cached projection getters. + const fields = Object.getOwnPropertyDescriptors(value) + for (const key of ['description', 'jsonSchemaError']) { + const field: unknown = fields[key]?.value + if (field !== undefined && typeof field !== 'string') { + throw new Error(`typert-loader: ${pkgName} ${subject}.${key} must be a string`) + } + } + const schema: unknown = fields.jsonSchema?.value + if (schema !== undefined) requireObject(pkgName, schema, `${subject}.jsonSchema`) +} + function requireStrictCodec(pkgName: string, value: unknown, subject: string): void { const codec = requireObject(pkgName, value, subject) if (codec.mode !== 'strict') { @@ -278,9 +316,7 @@ function requireStrictCodec(pkgName: string, value: unknown, subject: string): v throw new Error(`typert-loader: ${pkgName} ${subject} ${method} must be a function`) } } - if (typeof codec.create !== 'function') { - throw new Error(`typert-loader: ${pkgName} ${subject} has no create() factory`) - } + requireSchemaProjection(pkgName, codec, subject) } /** diff --git a/packages/typert/loader/tests/lazy-info.spec.ts b/packages/typert/loader/tests/lazy-info.spec.ts new file mode 100644 index 0000000000..f843895840 --- /dev/null +++ b/packages/typert/loader/tests/lazy-info.spec.ts @@ -0,0 +1,32 @@ +/** Loading a generated contribution must not request its consumer-owned lazy projections. */ + +import { expect, it, vi } from 'vitest' +import { z } from 'zod' +import { createTypertRemoteInfo } from '@deepseek-ai/dsh-typert-protocol/remote-info' +import { validateTypertManifest } from '../src/index.ts' + +it('validates invocation facts without materializing parsers or derived tool projections', () => { + const create = vi.fn(() => z.string()) + const codec = { mode: 'strict' as const, typeSymbol: 'pkg#Text', create, jsonSchema: { type: 'string' } } + const info = createTypertRemoteInfo({ package: 'pkg', methods: { + 'goals/create': { + service: 'goals', invocation: { kind: 'direct' }, + parameters: [{ name: 'request', source: 'json', codec }], + result: { ...codec, description: 'Created goal.' }, + }, + } }) + const method = info.methods['goals/create']! + const inputSchema = vi.spyOn(method.input, 'jsonSchema', 'get') + const inputError = vi.spyOn(method.input, 'jsonSchemaError', 'get') + const outputSchema = vi.spyOn(method.result, 'jsonSchema', 'get') + const manifest = { package: 'pkg', face: 'host', schemas: [], model: { services: [], events: [], objects: [] }, invocations: info.descriptors } + expect(validateTypertManifest('pkg', manifest)).toBe(manifest) + expect(create).not.toHaveBeenCalled() + expect(inputSchema).not.toHaveBeenCalled() + expect(inputError).not.toHaveBeenCalled() + expect(outputSchema).not.toHaveBeenCalled() + expect(method.input.jsonSchema).toMatchObject({ properties: { request: { type: 'string' } } }) + expect(method.result.jsonSchema).toEqual({ type: 'string', description: 'Created goal.' }) + expect(method.input.create().parse({ request: 'ship' })).toEqual({ request: 'ship' }) + expect(create).toHaveBeenCalledTimes(1) +}) diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index 52c54c0d65..0c282f7972 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -10,6 +10,9 @@ import { PluginPackages } from '@deepseek-ai/dsh-app-boot' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import * as typertLoader from '@deepseek-ai/dsh-typert-loader' import { validateTypertManifest } from '@deepseek-ai/dsh-typert-loader' +import { isTypertRemoteMethod } from '@deepseek-ai/dsh-typert-protocol/remote-info' +import type { TypertRemoteInfo } from '@deepseek-ai/dsh-typert-protocol' +import ts from 'typescript' import { z } from 'zod' let root: string | undefined @@ -70,27 +73,48 @@ function typertSource(pkgName: string, entryName: string): string { ].join('\n') } -function invocationTypertSource(pkgName: string): string { +/** Native fixture imports use current factory source, never a previously built protocol artifact. */ +async function writeRemoteInfoRuntime(base: string): Promise { + const source = await readFile(new URL('../../protocol/src/remote-info.ts', import.meta.url), 'utf8') + const { outputText } = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ESNext }, + }) + // The factory's only runtime dependency resolves through this fixture's node_modules/zod link. + const path = join(base, 'remote-info.mjs') + await writeFile(path, outputText, { flag: 'wx', mode: 0o600 }) + return pathToFileURL(path).href +} + +function invocationTypertSource(pkgName: string, factoryUrl: string): string { return [ 'import { z } from \'zod\'', + `import { createTypertRemoteInfo } from ${JSON.stringify(factoryUrl)}`, 'const Text = () => z.string()', - 'export const TYPERT = {', + 'export const REMOTE_INFO = createTypertRemoteInfo({', ` package: '${pkgName}',`, + ' methods: {', + " 'goals/create': {", + " service: 'goals', invocation: { kind: 'direct' },", + " description: 'Create a goal.',", + ' parameters: [{', + " name: 'request', source: 'json', description: 'Goal title.',", + ` codec: { mode: 'strict', typeSymbol: '${pkgName}/types#Request', create: Text, jsonSchema: { type: 'string' } },`, + ' }],', + " cancellation: { parameter: 'signal' },", + ' result: {', + ` mode: 'strict', typeSymbol: '${pkgName}/types#Result', create: Text,`, + " description: 'Created goal.', jsonSchema: { type: 'string' },", + ' },', + " sourceLocation: { file: 'src/index.ts', line: 8, column: 3 },", + ' },', + ' },', + '})', + 'export const TYPERT = {', + ' package: REMOTE_INFO.package,', ' face: \'host\',', ' schemas: [],', ' model: { services: [], events: [], objects: [] },', - ' invocations: [{', - ` id: '${pkgName}#goals/create',`, - ' service: \'goals\', namespace: \'goals\', method: \'create\',', - ' invocation: { kind: \'direct\' },', - ' parameters: [{', - ' name: \'request\', wire: \'request\', source: \'json\',', - ` codec: { mode: 'strict', typeSymbol: '${pkgName}/types#Request', create: Text },`, - ' }],', - " cancellation: { parameter: 'signal' },", - ` result: { mode: 'strict', typeSymbol: '${pkgName}/types#Result', create: Text },`, - ' sourceLocation: { file: \'src/index.ts\', line: 8, column: 3 },', - ' }],', + ' invocations: REMOTE_INFO.descriptors,', '}', '', ].join('\n') @@ -147,11 +171,12 @@ describe('typert loader', () => { expect(ctx.typert.getPackage('@fixture/nested')).toBeUndefined() }) - it('registers a strict invocation into the local registry and withdraws it with the loader', LOADER_TEST_TIMEOUT, async () => { + it('retains generated flat MethodInfo through native artifact import and loader disposal', LOADER_TEST_TIMEOUT, async () => { root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-')) await linkZod(root) + const factoryUrl = await writeRemoteInfoRuntime(root) await writePackage(root, '@fixture/invocation', { - typertSource: invocationTypertSource('@fixture/invocation'), + typertSource: invocationTypertSource('@fixture/invocation', factoryUrl), }) const ctx = await boot() @@ -166,8 +191,19 @@ describe('typert loader', () => { cancellation: { parameter: 'signal' }, sourceLocation: { file: 'src/index.ts', line: 8, column: 3 }, }) - expect(descriptor?.parameters[0]?.codec.mode).toBe('strict') - if (descriptor?.parameters[0]?.codec.mode === 'strict') { + expect(ctx.typert.local.list()[0]).toBe(descriptor) + if (descriptor === undefined || !isTypertRemoteMethod(descriptor)) throw new Error('Generated direct method is missing') + const artifact = await import(pathToFileURL(join(root, 'node_modules/@fixture/invocation/typert.host.js')).href) as { REMOTE_INFO: TypertRemoteInfo } + expect(artifact.REMOTE_INFO.methods['goals/create']).toBe(descriptor) + expect(descriptor).not.toHaveProperty('metadata') + expect(descriptor.description).toBe('Create a goal.') + expect(descriptor.parameters[0]?.description).toBe('Goal title.') + expect(descriptor.input.create().parse({ request: 'ready' })).toEqual({ request: 'ready' }) + expect(() => descriptor.input.create().parse({ request: 1 })).toThrow() + expect(descriptor.result.create().parse('created')).toBe('created') + expect(descriptor.result.jsonSchema).toEqual({ type: 'string', description: 'Created goal.' }) + expect(descriptor.parameters[0]?.codec.mode).toBe('strict') + if (descriptor.parameters[0]?.codec.mode === 'strict') { expect(descriptor.parameters[0].codec.create().parse('request')).toBe('request') } @@ -529,6 +565,43 @@ describe('validateTypertManifest', () => { })).toThrow('object has a missing or empty exportName') }) + it('validates flat method capabilities and eager projections without requiring tool schemas', () => { + const method = { + ...strictInvocation(), invoke: () => undefined, + input: { create: () => z.object({ request: z.string() }) }, + result: { ...strictCodec('pkg#Result'), jsonSchemaError: 'Not projectable.' }, + } + const manifest = { ...completeManifest(zodish), invocations: [method] } + expect(validateTypertManifest('pkg', manifest)).toBe(manifest) + const withDocs = { + ...method, description: '', + parameters: method.parameters.map(parameter => ({ ...parameter, description: 'Goal title.' })), + input: { ...method.input, description: '', jsonSchema: { type: 'object' } }, + result: { ...method.result, description: '' }, + } + expect(validateTypertManifest('pkg', { ...manifest, invocations: [withDocs] }).invocations[0]).toBe(withDocs) + const stream = { ...method, mode: 'stream' } + expect(validateTypertManifest('pkg', { ...manifest, invocations: [stream] }).invocations[0]).toBe(stream) + for (const invalid of [ + { ...method, mode: 'unary' }, { ...method, mode: 'future' }, + { ...method, invoke: false }, { ...method, description: 1 }, + { ...method, parameters: method.parameters.map(parameter => ({ ...parameter, description: false })) }, + { ...method, input: null }, { ...method, input: { create: false } }, + { ...method, input: { ...method.input, jsonSchema: [] } }, + { ...method, input: { ...method.input, jsonSchemaError: false } }, + { ...method, result: { ...method.result, jsonSchemaError: false } }, + { ...method, result: { ...method.result, description: 1 } }, + { ...method, invocation: { kind: 'context', context: 'agent', wire: 'agentId', codec: strictCodec('pkg#Agent') } }, + ]) { + expect(() => validateTypertManifest('pkg', { ...manifest, invocations: [invalid] })).toThrow('typert-loader') + } + const { input: _input, ...withoutInput } = method + const { invoke: _invoke, ...withoutInvoke } = method + for (const invalid of [withoutInput, withoutInvoke]) { + expect(() => validateTypertManifest('pkg', { ...manifest, invocations: [invalid] })).toThrow('typert-loader') + } + }) + it('requires and validates strict invocation descriptors', () => { const base = completeManifest(zodish) const { invocations: _invocations, ...missingInvocations } = base diff --git a/packages/typert/protocol/README.i18n.yaml b/packages/typert/protocol/README.i18n.yaml index c867ef0af6..0395bb32d9 100644 --- a/packages/typert/protocol/README.i18n.yaml +++ b/packages/typert/protocol/README.i18n.yaml @@ -18,8 +18,8 @@ en: c479b5b665822198 zh: 1ffb789c18043baf /deepseek-ai-dsh-typert-protocol/use-this-package/exposing-a-host-method: - en: fae902875c18ef33 - zh: ed4332cabe0e3a45 + en: 6f0306f504fe11d4 + zh: c5e5f1588757127f /deepseek-ai-dsh-typert-protocol/use-this-package/associating-host-objects-and-contexts-with-wire-identities: en: 3db384ae6f215632 zh: 56207184b09d4018 @@ -39,14 +39,14 @@ en: 93b83a9441a5a3c2 zh: c4eb143ddd167a27 /deepseek-ai-dsh-typert-protocol/understand-the-implementation/protocol-maps-and-descriptors: - en: a553d2c60d02df85 - zh: c2bd3d630c28290f + en: c202904201402211 + zh: 3db1d83683b4fd2a /deepseek-ai-dsh-typert-protocol/understand-the-implementation/wire-identity-grammar: en: aac79a7b4682371e zh: bc9cc513c1ced3e6 /deepseek-ai-dsh-typert-protocol/understand-the-implementation/source-map: - en: 62da405f16379f39 - zh: fcbd0f10dd198018 + en: 8b878e0e003b86ec + zh: 5d92998ad2964d7c /deepseek-ai-dsh-typert-protocol/further-exploration: en: 84191d1b07fb8bb6 zh: 9a5212732ed38ab6 @@ -57,8 +57,8 @@ en: b1c49d5e440d9d41 zh: 87ead78472415189 /deepseek-ai-dsh-typert-protocol/known-limitations-and-deferred-work: - en: 0be59e6d97923487 - zh: 70523fb0dc567ed6 + en: 6079caacbcd01f08 + zh: 70295e443f35afe8 /deepseek-ai-dsh-typert-protocol/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/typert/protocol/README.md b/packages/typert/protocol/README.md index 96132e27aa..a874b31327 100644 --- a/packages/typert/protocol/README.md +++ b/packages/typert/protocol/README.md @@ -48,6 +48,14 @@ A unary method can return `Uint8Array` directly or within nested objects, arrays A stream method (`@Remote({ mode: 'stream' })`) returns `Iterable`, `AsyncIterable`, or `RemoteStream`. `In` declares the items the Client may send back on the same logical stream; the method reads them through `this.ctx.invocation.uplink()`, and the descriptor carries their codec. `RemoteInvocation` also names the receiving `service`, the calling `peer` (a `PeerScope` the connection layer admitted), and the carrier `signal`; `ctx.invocation` is `undefined` on a Context no Remote call derived: the first `bindTypertRemote()` binding in a tree, which every `TypertRemoteService` constructor makes, registers that accessor on the root. A generated Client stream method returns `RemoteStreamHandle`: the handle with `send`, `end`, and `dispose` beside the downlink iteration. Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through. +`@Remote({ rejectUnknownFields: true })` is an independent generation-only request-validation option, combinable with `name` and `mode: 'stream'`. It is neither a runtime marker nor a source-dispatch refusal flag; a source Host must load ordinary generated artifacts to enforce it. The [generator](<../generator/README.md#ordinary-remote-metadata>) owns publication, object policy, and optional tool-schema projection. + +Ordinary `./remote` artifacts export `TYPERT_REMOTE` as a `TypertRemoteInfo`, extending the mountable `TypertRemoteContribution`. Its typed `methods[endpoint]` entries are the same direct descriptor objects found in `descriptors`, with derived `input` and `invoke(remote, input, signal?)`. Parameter documentation lives in `parameters[].description`; `result` is the single result codec, parser, and optional projection. Unary descriptors omit `mode`; streams use `'stream'`. Context-only receivers remain in `descriptors` and scoped declarations, not `methods`. + +`createTypertRemoteInfo(spec)` from the published pure `@deepseek-ai/dsh-typert-protocol/remote-info` entry returns a class instance, not a constructor. It retains the static spec until `descriptors` or `methods` is read, then caches their shared index. Named input schemas are built and cached on first `input.create()`; their optional JSON Schema projection is derived and cached when read. The raw spec requires Zod factories, while consumer-facing `TypertSchema` promises only `parse(value: unknown): T`. JSON Schema comes from generator projections, not runtime `z.toJSONSchema()`. Metadata and schema access never invokes the network; the root also re-exports the factory but imports Cordis. + +The invoker takes a caller-owned, mounted, unscoped Client Remote and resolves its namespace method at call time. It preserves the namespace receiver, optional positional holes, and signal, forwarding the same `Promise>` or complete `RemoteStreamHandle` without an async wrapper. `DecodedOutput` defaults to the authored output; generation supplies `RemoteDecoded` only for byte-bearing unary methods, avoiding recursive remapping of ordinary JSON results; `result.create().parse()` retains the authored output type and does not normalize shared byte buffers. Documentation and parsing remain available independently of unsupported JSON Schema projection; `jsonSchemaError` explains an absent projection. Registry readers use `isTypertRemoteMethod(descriptor)` from the same pure entry to narrow a base descriptor to these direct capabilities. + ### Associating Host objects and Contexts with wire identities Complex Host objects cannot cross the wire directly. A business package declares the association through the merge-extensible `TypertLookupMap` and `TypertContextMap`. A Host Context adapter owns the stable wire declaration and resolves wire identities to live Contexts. A Client Context adapter maps in both directions because scoped calls originate from a Client Context and forwarded Host events resolve their explicit wire identity there. Host composition may override its synchronous or asynchronous resolver. A resolver that refuses on policy grounds throws `RemoteError` with its own code, which reaches the caller unchanged. @@ -93,7 +101,7 @@ The package keeps strict reflection in the compiler: decorator initializers reta ### Protocol maps and descriptors -The merge-extensible protocol maps keep static associations in the type system, while runtime providers register resolution with `ctx.typert`; the map names and shapes live in [`src/types.ts`](src/types.ts). `InvocationDescriptor` is the shared runtime form consumed by the registry, the Gateway, and the Client Remote, covering direct and Context receivers, JSON and lookup parameters, scope projections, the uplink codec, cancellation, and result codecs. +The merge-extensible protocol maps keep static associations in the type system, while runtime providers register resolution with `ctx.typert`; the map names and shapes live in [`src/types.ts`](src/types.ts). `InvocationDescriptor` is the shared runtime form consumed by the registry, the Gateway, and the Client Remote, covering direct and Context receivers, JSON and lookup parameters, scope projections, the uplink codec, cancellation, result codecs, and documentation. Generated `TypertRemoteMethod` objects extend this descriptor directly. `RemoteInvocation.uplinkWindow` optionally describes the carrier's complete-frame inbox capacity and the UTF-8 envelope cost added to compact JSON items. Its absence means the carrier has no encoded-frame inbox limit; consumers still own their local buffering and acknowledgement policy. @@ -108,6 +116,7 @@ Every namespace, method, lookup, and Context segment must satisfy `isTypertRemot | [`src/index.ts`](src/index.ts) | Decorators, Gateway bindings, `remoteMethods`, segment validation | | [`src/json-value.ts`](src/json-value.ts) | `isRemoteJsonValue` and `isRemoteUplinkItem`, the lossless JSON checks every carrier shares | | [`src/remote-error.ts`](src/remote-error.ts) | `RemoteError` and the structural `remoteErrorOf` recognizer | +| [`src/remote-info.ts`](src/remote-info.ts) | Pure lazy info factory and direct-method type guard | | [`src/types.ts`](src/types.ts) | Protocol maps, `RemoteErrorDetailsMap`, `RemoteResult`, `RemoteStream`, `RemoteStreamHandle`, `PeerScope`, `RemoteInvocation`, `InvocationDescriptor`, codecs, provider contracts, registry interfaces, `TypertClientRemote` | @@ -143,7 +152,7 @@ No direct effect; the declared contracts reach a request only when an assembly p These limits define what the declarations can represent; they are current package constraints, not a task backlog. -- **Decorator markers are minimal** — markers contain only the method name and the direct or Context invocation mode; parameter, result, lookup, and schema reflection require the Typert build pipeline. +- **Decorator markers are minimal** — markers contain the method name, optional export alias and stream mode, and direct or Context receiver selection; parameter, result, lookup, schema, and documentation metadata require the Typert build pipeline. - **Remote signatures are restricted** — decorators accept only public, non-static instance methods with string names, and source-mode execution cannot represent overloaded, destructured, defaulted, or rest-parameter signatures. diff --git a/packages/typert/protocol/README.zh.md b/packages/typert/protocol/README.zh.md index 5730ec5a8a..c5f159451f 100644 --- a/packages/typert/protocol/README.zh.md +++ b/packages/typert/protocol/README.zh.md @@ -48,6 +48,14 @@ export class GoalService extends TypertRemoteService { 流方法(`@Remote({ mode: 'stream' })`)返回 `Iterable`、`AsyncIterable` 或 `RemoteStream`。`In` 声明 Client 可以在同一条逻辑流上回送的项;方法通过 `this.ctx.invocation.uplink()` 读取它们,描述符携带其 codec。`RemoteInvocation` 还给出接收服务 `service`、发起调用的 `peer`(连接层接纳的一个 `PeerScope`)与载体 `signal`;非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`:树中第一个 `bindTypertRemote()` 绑定(每个 `TypertRemoteService` 构造函数都会建立一个)在根上注册该 accessor。生成的 Client 流方法返回 `RemoteStreamHandle`:在下行迭代之外提供 `send`、`end` 与 `dispose` 的句柄。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,原样透传。 +`@Remote({ rejectUnknownFields: true })` 是独立的、仅作用于生成阶段的请求校验选项,可与 `name` 和 `mode: 'stream'` 组合。它既不是运行时标记,也不是源码分发拒绝标志;源码 Host 必须加载普通生成产物才能执行该校验。[生成器](<../generator/README.zh.md#ordinary-remote-metadata>)负责发布、对象策略和可选的工具 schema 投影。 + +普通 `./remote` 产物将 `TYPERT_REMOTE` 导出为 `TypertRemoteInfo`,它扩展可挂载的 `TypertRemoteContribution`。类型化的 `methods[endpoint]` 条目就是 `descriptors` 中同一个直接方法描述符对象,并带有派生的 `input` 和 `invoke(remote, input, signal?)`。参数文档位于 `parameters[].description`;`result` 是唯一的结果 codec、解析器和可选投影。一元描述符省略 `mode`;流使用 `'stream'`。仅使用 Context 接收器的方法保留在 `descriptors` 和作用域声明中,不进入 `methods`。 + +已发布的纯入口 `@deepseek-ai/dsh-typert-protocol/remote-info` 提供 `createTypertRemoteInfo(spec)`,返回类实例而非构造函数。它保留静态 spec,直到读取 `descriptors` 或 `methods` 时才构建并缓存两者共享的索引。具名输入 schema 在首次 `input.create()` 时构建并缓存;其可选 JSON Schema 投影在读取时派生并缓存。原始 spec 要求 Zod factory,而面向消费方的 `TypertSchema` 仅保证 `parse(value: unknown): T`。JSON Schema 来自生成器投影,而非运行时 `z.toJSONSchema()`。读取元数据和 schema 绝不发起网络调用;根入口也重新导出该 factory,但会导入 Cordis。 + +调用器接收调用方拥有、已挂载且无作用域的 Client Remote,并在调用时解析其命名空间方法。它保留命名空间接收者、可选位置参数空位和 signal,不使用 async 包装,直接转发同一个 `Promise>` 或完整 `RemoteStreamHandle`。`DecodedOutput` 默认采用原始输出类型;生成器仅为含字节的一元方法指定 `RemoteDecoded`,避免递归重映射普通 JSON 结果;`result.create().parse()` 保留原始输出类型,不会规范化共享字节缓冲区。文档与解析能力独立于不受支持的 JSON Schema 投影保留;`jsonSchemaError` 说明投影缺失的原因。Registry 读取方使用同一纯入口提供的 `isTypertRemoteMethod(descriptor)`,将基础描述符收窄为这些直接调用能力。 + ### 把 Host 对象与 Context 关联到 wire identity 复杂的 Host 对象不能直接跨 wire 传输。业务包通过可合并扩展的 `TypertLookupMap` 与 `TypertContextMap` 声明关联。Host Context 适配器拥有稳定 wire 声明,并把 wire identity 解析为活跃 Context。Client Context 适配器需要双向映射,因为作用域调用从 Client Context 发起,而转发的 Host 事件要在 Client 侧解析其显式 wire identity。Host 组合可以覆盖其同步或异步解析器。因策略原因拒绝解析的解析器会抛出带有自身错误码的 `RemoteError`,该码原样到达调用方。 @@ -93,7 +101,7 @@ Host 装配以转发给消费方的 Cordis 事件扩展 `TypertRemoteEventSelect ### 协议映射与描述符 -可合并扩展的协议映射在类型系统中保留静态关联,运行时提供方则向 `ctx.typert` 注册解析;映射的名称与形状见 [`src/types.ts`](src/types.ts)。`InvocationDescriptor` 是注册表、Gateway 与 Client Remote 共同消费的共享运行时形式,涵盖直接与 Context 接收者、JSON 与查找参数、作用域投影、上行编解码器、取消与结果编解码器。 +可合并扩展的协议映射在类型系统中保留静态关联,运行时提供方则向 `ctx.typert` 注册解析;映射的名称与形状见 [`src/types.ts`](src/types.ts)。`InvocationDescriptor` 是注册表、Gateway 与 Client Remote 共同消费的共享运行时形式,涵盖直接与 Context 接收者、JSON 与查找参数、作用域投影、上行编解码器、取消、结果编解码器与文档。生成的 `TypertRemoteMethod` 对象直接扩展该描述符。 `RemoteInvocation.uplinkWindow` 可选地描述载体完整帧 inbox 的容量,以及紧凑 JSON 项之外的 UTF-8 封装开销。字段缺省表示载体没有编码帧 inbox 限额;消费者仍负责本地缓存与确认策略。 @@ -108,6 +116,7 @@ Host 装配以转发给消费方的 Cordis 事件扩展 `TypertRemoteEventSelect | [`src/index.ts`](src/index.ts) | 装饰器、Gateway 绑定、`remoteMethods`、段校验 | | [`src/json-value.ts`](src/json-value.ts) | 各载体共享的无损 JSON 校验 `isRemoteJsonValue` 与 `isRemoteUplinkItem` | | [`src/remote-error.ts`](src/remote-error.ts) | `RemoteError` 与结构式识别函数 `remoteErrorOf` | +| [`src/remote-info.ts`](src/remote-info.ts) | 纯惰性 info factory 与直接方法类型守卫 | | [`src/types.ts`](src/types.ts) | 协议映射、`RemoteErrorDetailsMap`、`RemoteResult`、`RemoteStream`、`RemoteStreamHandle`、`PeerScope`、`RemoteInvocation`、`InvocationDescriptor`、编解码器、提供方约定、注册表接口、`TypertClientRemote` | @@ -143,7 +152,7 @@ Host 装配以转发给消费方的 Cordis 事件扩展 `TypertRemoteEventSelect 这些限制说明声明能表示什么;它们是当前包约束,不是任务积压。 -- **装饰器标记是最小化的**——标记只包含方法名与直接调用或 Context 调用模式;参数、结果、查找与 schema 反射需要 Typert 构建流水线。 +- **装饰器标记是最小化的**——标记包含方法名、可选的导出别名与流模式,以及直接或 Context 接收器选择;参数、结果、查找、schema 与文档元数据需要 Typert 构建流水线。 - **Remote 签名受限**——装饰器只接受具有字符串名称的公开、非静态实例方法,源码模式执行无法表示重载、解构、默认参数或剩余参数签名。 diff --git a/packages/typert/protocol/package.json b/packages/typert/protocol/package.json index 4227729aad..080649497d 100644 --- a/packages/typert/protocol/package.json +++ b/packages/typert/protocol/package.json @@ -22,6 +22,10 @@ "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" }, + "./remote-info": { + "types": "./lib/types/remote-info.d.ts", + "default": "./lib/types/remote-info.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -32,7 +36,8 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-brand": "workspace:*" + "@deepseek-ai/dsh-brand": "workspace:*", + "zod": "^4.4.3" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~" diff --git a/packages/typert/protocol/src/index.ts b/packages/typert/protocol/src/index.ts index 67adce78a8..9a9e5abe65 100644 --- a/packages/typert/protocol/src/index.ts +++ b/packages/typert/protocol/src/index.ts @@ -12,6 +12,7 @@ export { RemoteError, remoteErrorOf } from './remote-error.ts' export { TYPERT_OWNED_VALUE, isTypertOwnedValue, typertOwnedValue } from './owned-value.ts' export type { TypertOwnedValue } from './owned-value.ts' export { isRemoteJsonValue, isRemoteUplinkItem } from './json-value.ts' +export { createTypertRemoteInfo, isTypertRemoteMethod } from './remote-info.ts' const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ @@ -30,6 +31,7 @@ export type { InvocationSourceLocation, PeerId, PeerScope, + RemoteDecoded, RemoteErrorCode, RemoteErrorDetailsMap, RemoteFailure, @@ -64,6 +66,13 @@ export type { TypertRemoteScopeMap, TypertRemoteScopeNamespace, TypertRemoteContribution, + TypertRemoteInfo, + TypertRemoteMethod, + TypertRemoteValue, + TypertRemoteCodecSpec, + TypertRemoteParameterSpec, + TypertRemoteMethodSpec, + TypertRemoteSpec, TypertRemoteEvent, TypertRemoteEventSelection, TypertRemoteMap, @@ -105,10 +114,14 @@ export interface RemoteMethodMarker { readonly invocation: RemoteInvocationMarker } -/** Options for a non-unary Remote method. */ +/** Explicit endpoint, stream, and generated request-validation choices for one Remote method. */ export interface RemoteMethodOptions { + /** Exported endpoint method; omission retains the implementation member name. */ + readonly name?: string /** `stream`: deliver each Iterable item over the shared logical-stream carrier. */ - readonly mode: 'stream' + readonly mode?: 'stream' + /** Reject unknown fields in generated request-object codecs; source Hosts load the ordinary Typert artifact to enforce this. */ + readonly rejectUnknownFields?: true } type RemoteMethodDecorator = ( @@ -129,6 +142,12 @@ interface StoredRemoteMethodMarker { readonly invocation: RemoteInvocationMarker } +/** Marker fields selected by decorator arguments. */ +interface RemoteMarkerOptions { + readonly mode?: 'stream' + readonly exportName?: string +} + interface StoredRemoteMethod extends StoredRemoteMethodMarker { readonly method: string } @@ -147,7 +166,7 @@ const REMOTE_METHOD_DESCRIPTOR = '@deepseek-ai/dsh-typert-protocol/remote-method * a Host composition to read it. * @param service - owning Service instance, normally `this`. * @param serviceKey - exact Cordis service key. - * @param options - optional distinct wire namespace. + * @param options - optional wire namespace. * @returns a frozen, inspectable binding with no compiler-injected metadata. */ export function bindTypertRemote( @@ -172,7 +191,7 @@ export abstract class TypertRemoteService extends Service { * Register the Service and bind the same key to Typert Gateway. * @param ctx - owning Cordis Context. * @param serviceKey - exact Cordis service key and default wire namespace. - * @param options - optional distinct wire namespace. + * @param options - optional wire namespace. */ protected constructor(ctx: Context, serviceKey: string, options: TypertGatewayBindingOptions = {}) { super(ctx, serviceKey) @@ -201,8 +220,8 @@ export function Remote( context: ClassMethodDecoratorContext Result>, ): void /** - * Mark one public instance method under an exported name or as a logical stream. - * @param option - endpoint method name or stream delivery mode. + * Mark one method with an exported name, stream delivery, or closed generated request objects. + * @param option - endpoint name or explicit Remote options. * @returns a standard method decorator. */ export function Remote(option: string | RemoteMethodOptions): RemoteMethodDecorator @@ -212,33 +231,40 @@ export function Remote( ): void | RemoteMethodDecorator { if (typeof methodExportOrOptions === 'string') { validateName('Remote export name', methodExportOrOptions) - return remoteDecorator({ kind: 'direct' }, undefined, methodExportOrOptions) + return remoteDecorator({ kind: 'direct' }, { exportName: methodExportOrOptions }) } if (typeof methodExportOrOptions === 'object') { - if (remoteOptionMode(methodExportOrOptions) !== 'stream' - || Reflect.ownKeys(methodExportOrOptions).length !== 1) { - throw new TypeError('typert-protocol: Remote options must contain exactly mode: "stream"') - } - return remoteDecorator({ kind: 'direct' }, 'stream') + validateRemoteOptions(methodExportOrOptions) + return remoteDecorator({ kind: 'direct' }, { + ...(methodExportOrOptions.mode === undefined ? {} : { mode: methodExportOrOptions.mode }), + ...(methodExportOrOptions.name === undefined ? {} : { exportName: methodExportOrOptions.name }), + }) } if (context === undefined) throw new TypeError('typert-protocol: Remote decorator context is missing') addMarkerInitializer(context, { kind: 'direct' }) } -function remoteOptionMode(options: object): unknown { - return Reflect.get(options, 'mode') as unknown +const REMOTE_OPTION_KEYS = new Set(['name', 'mode', 'rejectUnknownFields']) + +function validateRemoteOptions(options: RemoteMethodOptions): void { + const keys = Reflect.ownKeys(options) + if (keys.length === 0 || keys.some(key => typeof key !== 'string' || !REMOTE_OPTION_KEYS.has(key)) + || (Object.hasOwn(options, 'mode') && options.mode !== 'stream') + || (Object.hasOwn(options, 'rejectUnknownFields') && options.rejectUnknownFields !== true)) { + throw new TypeError('typert-protocol: Remote options require name, mode: "stream", or rejectUnknownFields: true') + } + if (options.name !== undefined) validateName('Remote export name', options.name) } function remoteDecorator( invocation: RemoteInvocationMarker, - mode?: 'stream', - exportName?: string, + options: RemoteMarkerOptions = {}, ): RemoteMethodDecorator { return function ( _method: (this: This, ...args: Args) => Result, context: ClassMethodDecoratorContext Result>, ): void { - addMarkerInitializer(context, invocation, mode, exportName) + addMarkerInitializer(context, invocation, options) } } @@ -254,7 +280,7 @@ export function RemoteScope( ): RemoteMethodDecorator { validateName('Scope key', key) if (exportName !== undefined) validateName('Remote export name', exportName) - return remoteDecorator({ kind: 'context', context: key }, undefined, exportName) + return remoteDecorator({ kind: 'context', context: key }, exportName === undefined ? {} : { exportName }) } /** @@ -290,8 +316,7 @@ function readRemoteMethodDescriptor(prototype: object): RemoteMethodDescriptorV1 function addMarkerInitializer( context: RemoteInitializerContext, invocation: RemoteInvocationMarker, - mode?: 'stream', - exportName?: string, + options: RemoteMarkerOptions = {}, ): void { if (context.private || context.static || typeof context.name !== 'string') { throw new TypeError('typert-protocol: Remote decorators require a public instance method with a string name') @@ -302,7 +327,7 @@ function addMarkerInitializer( if (prototype === null) { throw new TypeError(`typert-protocol: cannot mark Remote method "${method}" on an object without a prototype`) } - mark(prototype, method, invocation, mode, exportName) + mark(prototype, method, invocation, options) }) } @@ -310,8 +335,7 @@ function mark( prototype: object, method: string, invocation: RemoteInvocationMarker, - mode?: 'stream', - exportName?: string, + { mode, exportName }: RemoteMarkerOptions, ): void { const descriptor = readRemoteMethodDescriptor(prototype) const marker: StoredRemoteMethod = Object.freeze({ diff --git a/packages/typert/protocol/src/remote-info.ts b/packages/typert/protocol/src/remote-info.ts new file mode 100644 index 0000000000..5304d0b884 --- /dev/null +++ b/packages/typert/protocol/src/remote-info.ts @@ -0,0 +1,145 @@ +/** Lazy assembly of generated Remote definitions; imports no Service, Registry, or transport. */ + +import { z } from 'zod' +import type { + InvocationDescriptor, + TypertClientRemote, + TypertRemoteCodecSpec, + TypertRemoteInfo, + TypertRemoteMethod, + TypertRemoteParameterSpec, + TypertRemoteSpec, + TypertRemoteValue, +} from './types.ts' + +type Method = TypertRemoteMethod | TypertRemoteMethod +type Parameter = TypertRemoteParameterSpec & { readonly wire: string } +type Index = Pick + +/** + * Retain one generated definition per endpoint without constructing parsers or projections. + * The class instance builds its shared descriptor/method index on first access; + * input schemas and JSON Schema projections are cached only when requested. + * @param spec - compiler-produced method facts and Zod factories. + * @returns a lazy, mountable info instance with no registration or transport effects. + */ +export function createTypertRemoteInfo(spec: TypertRemoteSpec): TypertRemoteInfo { + return new RemoteInfo(spec) +} + +class RemoteInfo implements TypertRemoteInfo { + private index?: Index + + constructor(private readonly spec: TypertRemoteSpec) {} + + get package(): string { return this.spec.package } + get descriptors(): readonly InvocationDescriptor[] { return this.resolve().descriptors } + get methods(): TypertRemoteInfo['methods'] { return this.resolve().methods } + + private resolve(): Index { + if (this.index !== undefined) return this.index + const descriptors: InvocationDescriptor[] = [] + const methods = Object.create(null) as Record + for (const [endpoint, definition] of Object.entries(this.spec.methods)) { + const separator = endpoint.indexOf('/') + const namespace = endpoint.slice(0, separator) + const method = endpoint.slice(separator + 1) + const parameters = definition.parameters.map(parameter => ({ ...parameter, wire: parameter.wire ?? parameter.name })) + const descriptor = { + ...definition, + id: `${this.spec.package}#${endpoint}`, + namespace, + method, + parameters, + result: definition.result.description === undefined ? definition.result : new ResultValue(definition.result), + } + if (definition.invocation.kind === 'direct') { + const direct = Object.assign(descriptor, { + input: new NamedInput(parameters), + invoke(remote: TypertClientRemote, input: object, signal?: AbortSignal) { + const named = input as Readonly> + const values = parameters.map(parameter => named[parameter.wire]) + if (definition.cancellation !== undefined) values.push(signal) + // Namespace access retains the Gateway's mount checks and scoped receiver policy. + const receiver = Reflect.get(remote, namespace) as object + const call = Reflect.get(receiver, method) as (...args: unknown[]) => ReturnType + return call.apply(receiver, values) + }, + }) + // The generated namespace return type is selected by this same descriptor's mode. + methods[endpoint] = direct as Method + } + descriptors.push(descriptor) + } + return this.index = { descriptors, methods } + } +} + +/** + * Narrow a Registry descriptor to its generated direct-method capabilities. + * @param descriptor - a live descriptor, including possible source fallback or Context-only receivers. + * @returns whether generated named input and direct invocation are available. + */ +export function isTypertRemoteMethod(descriptor: InvocationDescriptor): descriptor is Method { + return descriptor.invocation.kind === 'direct' && descriptor.result.mode === 'strict' + && 'input' in descriptor && 'invoke' in descriptor && typeof descriptor.invoke === 'function' +} + +function optional(parameter: Parameter): boolean { + return parameter.source === 'json' && parameter.acceptsUndefined === true +} + +class NamedInput implements TypertRemoteValue { + private schema?: z.ZodType + private projection?: Pick + + constructor(private readonly parameters: readonly Parameter[]) {} + + readonly create = (): z.ZodType => this.schema ??= z.strictObject(Object.fromEntries(this.parameters.map((parameter) => { + const value = parameter.codec.create() + return [parameter.wire, optional(parameter) ? value.optional() : value.nonoptional()] + }))) + + get jsonSchema(): TypertRemoteValue['jsonSchema'] { return this.project().jsonSchema } + get jsonSchemaError(): string | undefined { return this.project().jsonSchemaError } + + private project(): NonNullable { + if (this.projection !== undefined) return this.projection + const unavailable = this.parameters.find(parameter => parameter.codec.jsonSchema === undefined) + if (unavailable !== undefined) { + return this.projection = { jsonSchemaError: unavailable.codec.jsonSchemaError ?? `No JSON Schema for parameter ${unavailable.wire}` } + } + return this.projection = { + jsonSchema: { + type: 'object', + properties: Object.fromEntries(this.parameters.map(parameter => [parameter.wire, { + ...parameter.codec.jsonSchema, + ...(parameter.description === undefined ? {} : { description: parameter.description }), + }])), + required: this.parameters.filter(parameter => !optional(parameter)).map(parameter => parameter.wire), + additionalProperties: false, + }, + } + } +} + +class ResultValue implements TypertRemoteCodecSpec { + private projection?: { readonly value: TypertRemoteValue['jsonSchema'] } + + constructor(private readonly spec: TypertRemoteCodecSpec) {} + + get mode(): 'strict' { return this.spec.mode } + get typeSymbol(): string { return this.spec.typeSymbol } + get create(): TypertRemoteCodecSpec['create'] { return this.spec.create } + get decode(): TypertRemoteCodecSpec['decode'] { return this.spec.decode } + get encode(): TypertRemoteCodecSpec['encode'] { return this.spec.encode } + get description(): string | undefined { return this.spec.description } + get jsonSchemaError(): string | undefined { return this.spec.jsonSchemaError } + get jsonSchema(): TypertRemoteValue['jsonSchema'] { + if (this.projection === undefined) { + const schema = this.spec.jsonSchema + this.projection = { value: schema === undefined ? undefined : { ...schema, description: this.spec.description } } + } + return this.projection.value + } +} diff --git a/packages/typert/protocol/src/types.ts b/packages/typert/protocol/src/types.ts index fe4cf775d0..f7e6680df1 100644 --- a/packages/typert/protocol/src/types.ts +++ b/packages/typert/protocol/src/types.ts @@ -6,6 +6,7 @@ import type { Context, Events } from '@deepseek-ai/cordis' import type { Branded } from '@deepseek-ai/dsh-brand' +import type { ZodType } from 'zod' import type { TypertOwnedValue } from './owned-value.ts' declare const LOOKUP_HOST: unique symbol @@ -266,26 +267,29 @@ export interface TypertSchema { parse(value: unknown): Output } +/** Native byte views reconstructed by a Remote carrier have ordinary ArrayBuffer backing. */ +export type RemoteDecoded = Value extends Uint8Array ? Uint8Array + : Value extends string | number | boolean | bigint | symbol ? Value + : Value extends object ? { [Key in keyof Value]: RemoteDecoded } : Value + /** Codec attached to one invocation parameter or result. */ -export type TypertCodec = - | { +export type TypertCodec = + | TypertRemoteValue & { readonly mode: 'strict' readonly typeSymbol: string - /** Materialize and return the process-realm schema on first boundary use. */ - readonly create: () => TypertSchema /** * Decode a unary result whose fields require type-specific handling. * @param value - result reconstructed by the RPC carrier. * @returns the validated result, retaining native byte views. */ - readonly decode?: (value: unknown) => unknown + readonly decode?: ((value: unknown) => unknown) | undefined /** * Project typed binary fields into RPC result attachments. * @param value - native unary result. * @param writeBytes - records a byte view at its result-relative path and returns its JSON placeholder. * @returns JSON metadata with untouched JSON subtrees retained. */ - readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown + readonly encode?: ((value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown) | undefined } | { readonly mode: 'src-json' @@ -295,6 +299,8 @@ export type TypertCodec = export interface InvocationParameterDescriptor { /** Source-level parameter name. */ readonly name: string + /** Source parameter documentation, independent of JSON Schema support. */ + readonly description?: string /** Required key in the wire `args` object. */ readonly wire: string /** Whether the value is JSON or requires a registered Host lookup. */ @@ -363,6 +369,8 @@ export interface InvocationDescriptor { } /** Codec for the unary result or each yielded stream item. */ readonly result: TypertCodec + /** Source-owned method documentation; ordinary methods need not have JSDoc. */ + readonly description?: string /** Source declaration used only for diagnostics. */ readonly sourceLocation?: InvocationSourceLocation } @@ -438,6 +446,79 @@ export interface TypertRemoteContribution { readonly descriptors: readonly InvocationDescriptor[] } +/** Generated parser and optional lossless tool-schema projection of one Remote value. */ +export interface TypertRemoteValue { + /** Materialize the generated parser without loading the owning Service. */ + readonly create: () => TypertSchema + /** Source-owned value documentation, independent of tool projection support. */ + readonly description?: string | undefined + /** Present when the value is representable in the tool-schema subset without losing constraints; may be derived lazily. */ + readonly jsonSchema?: Readonly> | undefined + /** Projection failure for consumers that require JSON Schema; parsing remains available. */ + readonly jsonSchemaError?: string | undefined +} + +/** A direct descriptor; DecodedOutput differs from its parser output only for carrier-reconstructed unary bytes. */ +export type TypertRemoteMethod = + Omit + & (Mode extends 'stream' ? { readonly mode: 'stream' } : { readonly mode?: never }) & { + /** Named invocation selects a direct Service method, not a Context receiver. */ + readonly invocation: { readonly kind: 'direct' } + /** Named wire arguments derived from the ordered parameters and their codecs. */ + readonly input: TypertRemoteValue + /** The descriptor's result or stream-item parser; parsing alone does not normalize native byte backing. */ + readonly result: Extract, { readonly mode: 'strict' }> + /** + * Invoke the mounted namespace method with named wire arguments. + * @param remote - caller-owned, unscoped Client Remote service; its mount owns availability. + * @param input - decoded named wire arguments. + * @param signal - caller-owned invocation lifetime. + * @returns the carrier-decoded unary result or complete stream handle, including uplink and disposal. + */ + invoke(remote: TypertClientRemote, input: Input, signal?: AbortSignal): Mode extends 'stream' + ? RemoteStreamHandle + : Promise> + } + +/** Ordinary contribution whose method index references its direct invocation descriptors. */ +export interface TypertRemoteInfo | TypertRemoteMethod +>> = Readonly | TypertRemoteMethod>>> + extends TypertRemoteContribution { + /** Exact generated keys and types; Context-only receivers are available only through descriptors. */ + readonly methods: Methods +} + +/** Generated Zod factory and optional tool projection for one invocation value. */ +export interface TypertRemoteCodecSpec extends Omit, 'create'> { + /** Materialize the generated Zod schema only when a parser is requested. */ + readonly create: () => ZodType +} + +/** One ordered parameter; omitted wire names resolve to the source parameter name. */ +export interface TypertRemoteParameterSpec extends Omit { + /** Explicit wire alias, notably for a Host lookup identity. */ + readonly wire?: string + /** The only generated parser and projection definition for this parameter. */ + readonly codec: TypertRemoteCodecSpec +} + +/** Static method facts; endpoint identity and named-call helpers are derived by the runtime factory. */ +export interface TypertRemoteMethodSpec extends Omit { + /** Ordered Host parameters, including their wire representations. */ + readonly parameters: readonly TypertRemoteParameterSpec[] + /** Successful unary result or stream item, with optional native-byte handling. */ + readonly result: TypertRemoteCodecSpec +} + +/** Compiler-produced method definitions, independent of services, registration, and transport. */ +export interface TypertRemoteSpec { + /** Package owning these methods. */ + readonly package: string + /** Public endpoints; each method's invocation facts occur only once. */ + readonly methods: Readonly> +} + /** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */ export interface TypertClientRemote extends TypertRemoteNamespaceMap { /** diff --git a/packages/typert/protocol/tests/protocol.spec.ts b/packages/typert/protocol/tests/protocol.spec.ts index e8e42b0ac0..4be13eaaef 100644 --- a/packages/typert/protocol/tests/protocol.spec.ts +++ b/packages/typert/protocol/tests/protocol.spec.ts @@ -305,11 +305,16 @@ describe('typert-protocol Remote declarations', () => { expect(() => Remote('bad name')).toThrow('export name') expect(() => Remote('.')).toThrow('export name') expect(() => Remote('..')).toThrow('export name') - expect(() => Remote({ mode: 'unary' } as unknown as { mode: 'stream' })).toThrow('exactly mode') - expect(() => Remote({ mode: 'stream', extra: true } as unknown as { mode: 'stream' })).toThrow('exactly mode') + expect(() => Remote({ mode: 'unary' } as unknown as { mode: 'stream' })).toThrow('Remote options require') + expect(() => Remote({ mode: 'stream', extra: true } as unknown as { mode: 'stream' })).toThrow('Remote options require') const bogusMode: string = 'duplex' expect(() => Remote({ mode: bogusMode } as { mode: 'stream' })) - .toThrow('Remote options must contain exactly mode: "stream"') + .toThrow('Remote options require name, mode: "stream", or rejectUnknownFields: true') + expect(() => Remote({ rejectUnknownFields: false } as never)).toThrow('Remote options require') + expect(() => Remote({ strict: true } as never)).toThrow('Remote options require') + expect(() => Remote({ committed: true } as never)).toThrow('Remote options require') + expect(() => Remote({ rejectUnknownFields: true })).not.toThrow() + expect(() => Remote({})).toThrow('Remote options require') expect(() => RemoteScope('' as 'metaFixture')).toThrow('Scope key') expect(() => RemoteScope('metaFixture', 'bad/name')).toThrow('export name') @@ -323,6 +328,23 @@ describe('typert-protocol Remote declarations', () => { } }) + it('keeps codec generation options out of renamed stream markers', async () => { + const ctx = new Context() + class ItemsService extends TypertRemoteService { + constructor(owner: Context) { super(owner, 'itemsFixture') } + + @Remote({ name: 'items', mode: 'stream', rejectUnknownFields: true }) + async *read(): AsyncIterable { yield 'first' } + } + try { + expect(remoteMethods(new ItemsService(ctx))).toEqual([{ + method: 'read', exportName: 'items', mode: 'stream', invocation: { kind: 'direct' }, + }]) + } finally { + await ctx.fiber.dispose() + } + }) + it('rejects prototype-less initialization and conflicting markers', () => { const method: (this: object) => void = function (this: object): void {} const direct: Array<(this: object) => void> = [] diff --git a/packages/typert/protocol/tests/remote-info.spec.ts b/packages/typert/protocol/tests/remote-info.spec.ts new file mode 100644 index 0000000000..089ed54e07 --- /dev/null +++ b/packages/typert/protocol/tests/remote-info.spec.ts @@ -0,0 +1,190 @@ +/** Factory regressions use static definitions without loading a Controller or a Remote runtime. */ + +import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { createTypertRemoteInfo, isTypertRemoteMethod } from '../src/remote-info.ts' +import type { TypertClientRemote, TypertRemoteCodecSpec, TypertRemoteMethodSpec } from '../src/types.ts' + +const text: TypertRemoteCodecSpec = { + mode: 'strict', typeSymbol: '@fixture/types#Text', create: () => z.string(), jsonSchema: { type: 'string' }, +} + +function definition(overrides: Partial = {}): TypertRemoteMethodSpec { + return { + service: 'goals', invocation: { kind: 'direct' }, description: 'Create a goal.', + parameters: [{ name: 'request', source: 'json', description: 'Goal name.', codec: text }], + result: text, + ...overrides, + } +} + +describe('lazy Remote info', () => { + it('returns a class instance and defers its index, schemas, and projections independently', () => { + let inventoryReads = 0 + let inputProjectionReads = 0 + let outputProjectionReads = 0 + const create = vi.fn(() => z.string()) + const input = { ...text, create, get jsonSchema() { inputProjectionReads++; return { type: 'string' } } } + const output = { ...text, description: 'Created name.', get jsonSchema() { outputProjectionReads++; return { type: 'string' } } } + const spec = { + package: '@fixture/goals', + get methods() { inventoryReads++; return { 'goals/create': definition({ parameters: [{ name: 'request', source: 'json', codec: input }], result: output }) } }, + } + const info = createTypertRemoteInfo(spec) + expect(Object.getPrototypeOf(info)).not.toBe(Object.prototype) + expect(info.package).toBe('@fixture/goals') + expect(inventoryReads).toBe(0) + expect(create).not.toHaveBeenCalled() + + const method = info.methods['goals/create']! + expect(inventoryReads).toBe(1) + expect(info.descriptors[0]).toBe(method) + expect(info.methods).toBe(info.methods) + expect(info.descriptors).toBe(info.descriptors) + expect(inventoryReads).toBe(1) + expect(inputProjectionReads + outputProjectionReads).toBe(0) + expect(create).not.toHaveBeenCalled() + expect(method).not.toHaveProperty('metadata') + expect(method).not.toHaveProperty('output') + expect(method.mode).toBeUndefined() + expect(isTypertRemoteMethod(method)).toBe(true) + + const parser = method.input.create() + expect(method.input.create()).toBe(parser) + expect(create).toHaveBeenCalledTimes(1) + expect(parser.parse({ request: 'ship' })).toEqual({ request: 'ship' }) + expect(inputProjectionReads + outputProjectionReads).toBe(0) + const projection = method.input.jsonSchema + expect(projection).toEqual({ type: 'object', properties: { request: { type: 'string' } }, required: ['request'], additionalProperties: false }) + expect(method.input.jsonSchema).toBe(projection) + const reads = inputProjectionReads + expect(method.input.jsonSchemaError).toBeUndefined() + expect(inputProjectionReads).toBe(reads) + expect(outputProjectionReads).toBe(0) + expect(method.result.create).toBe(output.create) + const resultProjection = method.result.jsonSchema + expect(resultProjection).toEqual({ type: 'string', description: 'Created name.' }) + expect(method.result.jsonSchema).toBe(resultProjection) + expect(outputProjectionReads).toBe(1) + }) + + it('preserves named omissions, lookup aliases, strict outer fields, and positional holes', () => { + const info = createTypertRemoteInfo({ package: '@fixture/goals', methods: { + 'goals/create': definition({ + parameters: [ + { name: 'agent', wire: 'agentId', source: 'lookup', lookup: 'agent', acceptsUndefined: true, description: 'Owning Agent.', codec: text }, + { name: 'label', source: 'json', acceptsUndefined: true, codec: text }, + ], + cancellation: { parameter: 'signal' }, + scope: { context: 'agent', wire: 'agentId' }, + }), + } }) + const method = info.methods['goals/create']! + const parser = method.input.create() + expect(parser.parse({ agentId: 'agent' })).toEqual({ agentId: 'agent' }) + expect(parser.parse({ agentId: 'agent', label: undefined })).toEqual({ agentId: 'agent', label: undefined }) + for (const invalid of [null, [], {}, { agentId: undefined }, { agentId: 'agent', label: null }, { agentId: 'agent', extra: true }]) { + expect(() => parser.parse(invalid)).toThrow() + } + expect(method.input.jsonSchema?.required).toEqual(['agentId']) + expect(method.input.jsonSchema).toMatchObject({ properties: { agentId: { description: 'Owning Agent.' } } }) + const signal = new AbortController().signal + const promise = Promise.resolve({ ok: true as const, value: 'created' }) + const create = vi.fn(function (this: object, ...args: unknown[]) { + expect(this).toBe(namespace) + expect(args).toEqual(['agent', undefined, signal]) + return promise + }) + const namespace = { create } + const remote: object = { goals: namespace } + expect(method.invoke(remote as TypertClientRemote, { agentId: 'agent' }, signal)).toBe(promise) + expect(method.scope).toEqual({ context: 'agent', wire: 'agentId' }) + expect(create).toHaveBeenCalledTimes(1) + }) + + it('requires declared fields even when their parser accepts undefined and permits empty methods', () => { + const unknown = { ...text, create: () => z.unknown(), jsonSchema: {} } + const info = createTypertRemoteInfo({ package: '@fixture/goals', methods: { + 'goals/required': definition({ parameters: [{ name: 'value', source: 'json', codec: unknown }] }), + 'goals/empty': definition({ parameters: [] }), + } }) + expect(() => info.methods['goals/required']!.input.create().parse({})).toThrow() + const empty = info.methods['goals/empty']! + expect(empty.input.create().parse({})).toEqual({}) + expect(() => empty.input.create().parse({ extra: true })).toThrow() + expect(empty.input.jsonSchema?.required).toEqual([]) + const call = vi.fn(() => Promise.resolve({ ok: true as const, value: 'empty' })) + const remote: object = { goals: { empty: call } } + void empty.invoke(remote as TypertClientRemote, {}, new AbortController().signal) + expect(call).toHaveBeenCalledWith() + }) + + it('does not treat source or plain strict descriptors as generated named methods', () => { + const base = { + id: 'pkg#goals/read', namespace: 'goals', method: 'read', service: 'goals', + invocation: { kind: 'direct' as const }, parameters: [], result: { mode: 'src-json' as const }, + } + expect(isTypertRemoteMethod(base)).toBe(false) + const strict = { ...base, result: text } + expect(isTypertRemoteMethod(strict)).toBe(false) + const inputOnly = { ...strict, input: { create: () => z.strictObject({}) } } + expect(isTypertRemoteMethod(inputOnly)).toBe(false) + const notCallable = { ...inputOnly, invoke: undefined } + expect(isTypertRemoteMethod(notCallable)).toBe(false) + }) + + it('keeps Context-only descriptors without inventing a named direct caller', () => { + const info = createTypertRemoteInfo({ package: '@fixture/goals', methods: { + 'goals/rename': definition({ invocation: { kind: 'context', context: 'agent', wire: 'agentId', codec: text }, implementation: 'renameCurrent' }), + } }) + expect(Object.keys(info.methods)).toEqual([]) + expect(info.descriptors[0]).toMatchObject({ id: '@fixture/goals#goals/rename', namespace: 'goals', method: 'rename', implementation: 'renameCurrent' }) + expect(info.descriptors[0]).not.toHaveProperty('input') + expect(isTypertRemoteMethod(info.descriptors[0]!)).toBe(false) + }) + + it('returns the exact stream handle and looks up the current mounted method on each call', () => { + const uplink = { codec: text } + const info = createTypertRemoteInfo({ package: '@fixture/goals', methods: { + 'goals/watch': definition({ mode: 'stream', parameters: [], uplink }), + } }) + const handle = { async *[Symbol.asyncIterator]() { yield 'frame' }, send: vi.fn(), end: vi.fn(), dispose: vi.fn() } + const namespace = { watch: () => handle } + const remote: object = { goals: namespace } + const method = info.methods['goals/watch']! + expect(method.invoke(remote as TypertClientRemote, {})).toBe(handle) + expect(method.uplink).toBe(uplink) + namespace.watch = () => { throw new Error('method withdrawn') } + expect(() => method.invoke(remote as TypertClientRemote, {})).toThrow('method withdrawn') + }) + + it('retains codecs and documentation when projection is unavailable, without masking parser failures', () => { + const create = vi.fn(() => { throw new Error('invalid schema factory') }) + const unsupported = { ...text, create, jsonSchema: undefined, jsonSchemaError: 'Unsupported tool type.' } + const decode = vi.fn((value: unknown) => value) + const encode = vi.fn((value: unknown) => value) + const info = createTypertRemoteInfo({ package: '@fixture/goals', methods: { + 'goals/create': definition({ + parameters: [{ name: 'request', source: 'json', description: 'Still documented.', codec: unsupported }], + result: { ...unsupported, description: 'Still a result.', decode, encode }, + }), + } }) + const method = info.methods['goals/create']! + expect(method.input.jsonSchema).toBeUndefined() + expect(method.input.jsonSchemaError).toBe('Unsupported tool type.') + expect(method.result.jsonSchema).toBeUndefined() + expect(method.result.jsonSchemaError).toBe('Unsupported tool type.') + expect(method.result.description).toBe('Still a result.') + expect(method.parameters[0]?.description).toBe('Still documented.') + expect(method.result.decode).toBe(decode) + expect(method.result.encode).toBe(encode) + expect(create).not.toHaveBeenCalled() + expect(() => method.input.create()).toThrow('invalid schema factory') + expect(() => method.input.create()).toThrow('invalid schema factory') + const unprojected = createTypertRemoteInfo({ package: '@fixture/goals', methods: { + 'goals/create': definition({ parameters: [{ name: 'request', source: 'json', codec: { ...text, jsonSchema: undefined } }] }), + } }).methods['goals/create']! + expect(unprojected.input.jsonSchemaError).toContain('request') + expect(unprojected.input.create().parse({ request: 'valid' })).toEqual({ request: 'valid' }) + }) +}) diff --git a/packages/typert/registry/README.i18n.yaml b/packages/typert/registry/README.i18n.yaml index 6c12a2aedc..e827f933d0 100644 --- a/packages/typert/registry/README.i18n.yaml +++ b/packages/typert/registry/README.i18n.yaml @@ -15,17 +15,17 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-typert-registry/use-this-package: - en: d885ccd8f13315f1 - zh: cea7c258f5f57e2d + en: 688f3656b0f62771 + zh: 223d00ab0dc9fe20 /deepseek-ai-dsh-typert-registry/use-this-package/minimal-setup: en: 1ec4b304a024bb3c zh: f17e796c26faabab /deepseek-ai-dsh-typert-registry/use-this-package/querying-schemas-and-reflection: - en: bc75ee0cfdd77fee - zh: f0fc5bfffe0cc063 + en: 26261448d6a9afef + zh: 2718e7cfdc2b58ba /deepseek-ai-dsh-typert-registry/use-this-package/registering-a-contribution: - en: 2b2f750a1b145d85 - zh: d15c55c01af1755a + en: 9ca425515904d5c9 + zh: b251226ecfa32002 /deepseek-ai-dsh-typert-registry/use-this-package/lookup-and-context-providers: en: bc9ea8f035ec1199 zh: d25f1dc16cfc8654 diff --git a/packages/typert/registry/README.md b/packages/typert/registry/README.md index b3be03a8a8..805c49427d 100644 --- a/packages/typert/registry/README.md +++ b/packages/typert/registry/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -Mount the registry in any Host or Client composition that stores or consumes generated Typert artifacts; it provides `ctx.typert`. There is no configuration. +Mount the registry in any Host or Client composition that stores or consumes generated Typert artifacts; it provides `ctx.typert`. There is no configuration. Node resolves `/client` to ordinary ESM for programmatic Cordis composition; browsers retain the Client bundle, and both use the same registry implementation. ### Minimal setup @@ -39,10 +39,14 @@ Load the registry plugin; the Client face is installed the same way by the Clien Consumers read schemas with `get(key)`, `resolve(key)`, or `list(filter?)` and package reflection with `getPackage(name, face?)` or `listPackages(filter?)`. The first schema read materializes one process-realm instance; later reads reuse it. `resolve()` distinguishes a malformed key, an absent package, and a registered package that contributes no schema under that name, each with its own error. `toJSONSchema(key)` projects the materialized Zod schema to JSON Schema without caching the projection. +Invocation information is available through existing `ctx.typert.local.get/list` and `ctx.typert.remotes.get/list` queries. They return the registered descriptor objects, including generated direct methods with `input`, `result`, and `invoke`; there is no separate metadata store or RPC API. Use `isTypertRemoteMethod(descriptor)` from `@deepseek-ai/dsh-typert-protocol/remote-info` to narrow the base descriptor. This guard exposes inputs as `object` and outputs as `unknown`, not endpoint-dependent generics. The [ordinary generated `TYPERT_REMOTE.methods` index](<../generator/README.md#ordinary-remote-metadata>) supplies precise per-method static types and aliases those same descriptors within its artifact instance. + ### Registering a contribution Generated artifacts register through the [loader](../loader/README.md) in Loader compositions; any other owner calls `ctx.typert.register(contribution)` directly and receives the exact disposer that withdraws it. Duplicate package-face identities, schema keys, invocation ids, or endpoints reject the whole batch before anything is committed. +Harness offline integrations register an ordinary `TYPERT_REMOTE` contribution with `ctx.typert.remotes.register(contribution)` in their profile Context. `$mount(contribution)` already performs that registration, so registering and mounting the same contribution in the same registry duplicates it and fails. Keep offline metadata ownership separate from the live connection-and-mount Context. Independent offline consumers may instead read `TYPERT_REMOTE.methods` without any Registry. Registry state belongs to its Context and process, is not shared across processes, and neither registration nor querying opens a connection. + ### Lookup and Context providers Remote calls resolve Host objects and scoped Contexts through `ctx.typert.lookups` and `ctx.typert.contexts`. `registerHost()` installs the Host wire declaration and its wire-to-Context resolver, while `configureHost()` replaces only that resolver. `registerClient()` installs the bidirectional Client adapter for the same merge-declared kind. Host-to-Client event sources carry their domain identity explicitly instead of projecting it back from a Context. diff --git a/packages/typert/registry/README.zh.md b/packages/typert/registry/README.zh.md index 75660beb9c..d1f8756bb0 100644 --- a/packages/typert/registry/README.zh.md +++ b/packages/typert/registry/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -在存储或消费生成 Typert 产物的任何 Host 或 Client 组合中挂载本注册表;它提供 `ctx.typert`。没有配置。 +在存储或消费生成 Typert 产物的任何 Host 或 Client 组合中挂载本注册表;它提供 `ctx.typert`。没有配置。Node 将 `/client` 解析为普通 ESM,供程序化 Cordis 组合使用;浏览器仍使用 Client bundle,两者使用相同的注册表实现。 ### 最小设置 @@ -39,10 +39,14 @@ kind: "package-reference" 消费方用 `get(key)`、`resolve(key)` 或 `list(filter?)` 读取 schema,用 `getPackage(name, face?)` 或 `listPackages(filter?)` 读取包反射。首次读取会物化一个进程 realm 内的 schema 实例,后续读取复用该实例。`resolve()` 能区分格式错误的键、未注册的包,以及已注册但未以该名称提供 schema 的包,各自给出不同的错误。`toJSONSchema(key)` 把已物化的 Zod schema 投影为 JSON Schema,但不缓存该投影。 +调用信息可通过现有的 `ctx.typert.local.get/list` 与 `ctx.typert.remotes.get/list` 查询读取。它们返回已注册的描述符对象,包括带有 `input`、`result` 和 `invoke` 的生成直接方法;不存在独立的元数据存储或 RPC API。使用 `@deepseek-ai/dsh-typert-protocol/remote-info` 提供的 `isTypertRemoteMethod(descriptor)` 收窄基础描述符。该守卫将输入暴露为 `object`、输出暴露为 `unknown`,不提供依赖端点的泛型。[普通生成产物的 `TYPERT_REMOTE.methods` 索引](<../generator/README.zh.md#ordinary-remote-metadata>)提供精确的各方法静态类型,并在该产物实例内引用同一批描述符。 + ### 注册贡献 生成产物在 Loader 组合中通过 [loader](../loader/README.zh.md) 注册;其他所有者直接调用 `ctx.typert.register(contribution)`,并获得撤销它的同一资源释放函数。重复的包与 face 组合键、schema 键、调用 id 或端点会在提交任何内容之前拒绝整个批次。 +Harness 离线集成在各自的 profile Context 中通过 `ctx.typert.remotes.register(contribution)` 注册普通 `TYPERT_REMOTE` 贡献。`$mount(contribution)` 已执行该注册,因此在同一个注册表中先注册再挂载同一贡献会因重复而失败。离线元数据所有权应与建立实际连接及挂载的 Context 分离。独立离线消费方也可以直接读取 `TYPERT_REMOTE.methods`,无需任何 Registry。注册表状态归所属 Context 与进程所有,不跨进程共享;注册和查询都不会打开连接。 + ### Lookup 与 Context 提供方 Remote 调用通过 `ctx.typert.lookups` 与 `ctx.typert.contexts` 解析 Host 对象与作用域 Context。`registerHost()` 安装 Host wire 声明及其 wire 到 Context 的 resolver,`configureHost()` 只替换该 resolver。`registerClient()` 为同一个 merge-declared kind 安装双向 Client adapter。Host 到 Client 的事件源显式携带领域 identity,而不从 Context 反向投影。 diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index a5f4e98079..986c16a894 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -20,6 +20,7 @@ }, "./client": { "types": "./lib/types/client/index.d.ts", + "node": "./lib/types/client/index.js", "default": "./lib/client.js" }, "./types": { diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 9df0c730e8..b3562dbb91 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -13,6 +13,7 @@ import type { TypertLookup, TypertRemoteContribution, } from '@deepseek-ai/dsh-typert-protocol' +import { createTypertRemoteInfo, isTypertRemoteMethod } from '@deepseek-ai/dsh-typert-protocol/remote-info' import { apply as applyClientRegistry, inject as clientRegistryInject } from '../src/client/index.ts' declare module '@deepseek-ai/dsh-typert-protocol' { @@ -99,6 +100,45 @@ function scopedInvocation(): InvocationDescriptor { } describe('TypertRegistry', () => { + it('indexes the same generated method objects and withdraws them with their owner', async () => { + const ctx = await makeCtx() + const codec = { mode: 'strict' as const, typeSymbol: '@fixture/types#Text', create: () => z.string(), jsonSchema: { type: 'string' } } + const info = createTypertRemoteInfo({ package: '@fixture/remote', methods: { + 'goals/create': { + service: 'goals', invocation: { kind: 'direct' }, description: 'Create a goal.', + parameters: [{ name: 'request', source: 'json', codec }], result: codec, + }, + } }) + const descriptor = info.methods['goals/create']! + expect(info.descriptors[0]).toBe(descriptor) + try { + const owner = ctx.plugin({ + inject: ['typert'], + apply(ctx) { + ctx.typert.register({ ...toolsContribution(), invocations: info.descriptors }) + ctx.typert.remotes.register(info) + }, + }) + await owner + for (const registry of [ctx.typert.local, ctx.typert.remotes]) { + const method = registry.get('goals/create') + expect(method).toBe(descriptor) + expect(registry.list()[0]).toBe(descriptor) + if (method === undefined || !isTypertRemoteMethod(method)) throw new Error('Generated method is missing') + expect(method.input.create().parse({ request: 'goal' })).toEqual({ request: 'goal' }) + expect(method.result).toBe(codec) + } + await owner.dispose() + for (const registry of [ctx.typert.local, ctx.typert.remotes]) { + expect(registry.get('goals/create')).toBeUndefined() + expect(registry.list()).toEqual([]) + } + expect(ctx.typert.local.hasSeen('goals/create')).toBe(true) + } finally { + await ctx.fiber.dispose() + } + }) + it('retains generated result decoders until the contribution is disposed', async () => { const ctx = await makeCtx() const decode = (value: unknown) => value diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 146a58fe8e..e6d45bb1e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1191,6 +1191,9 @@ importers: '@deepseek-ai/dsh-typert-protocol': specifier: workspace:* version: link:../../typert/protocol + '@deepseek-ai/dsh-util-crypto': + specifier: workspace:* + version: link:../../util/crypto '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -1219,9 +1222,6 @@ importers: '@deepseek-ai/dsh-typert-registry': specifier: workspace:* version: link:../../typert/registry - '@deepseek-ai/dsh-util-crypto': - specifier: workspace:* - version: link:../../util/crypto '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -1368,6 +1368,15 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:* version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:* + version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:* + version: link:../../session/session-projection + '@deepseek-ai/dsh-session-query': + specifier: workspace:* + version: link:../../session-query/session-query '@deepseek-ai/dsh-session-reference': specifier: workspace:* version: link:../../context/session-reference @@ -1380,12 +1389,21 @@ importers: '@deepseek-ai/dsh-typert-protocol': specifier: workspace:* version: link:../../typert/protocol + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:* + version: link:../../typert/registry '@deepseek-ai/dsh-user-approval': specifier: workspace:* version: link:../../interaction/user-approval '@deepseek-ai/dsh-user-questions': specifier: workspace:* version: link:../../interaction/user-questions + typescript: + specifier: ^6.0.3 + version: 6.0.3 + ws: + specifier: ^8.21.3 + version: 8.21.3 zod: specifier: 4.4.3 version: 4.4.3 @@ -14337,6 +14355,9 @@ importers: '@deepseek-ai/dsh-tool-cordis': specifier: workspace:* version: link:../../extensions/tool-cordis + '@deepseek-ai/dsh-tools': + specifier: workspace:* + version: link:../../core/tools '@deepseek-ai/dsh-typert-registry': specifier: workspace:* version: link:../registry @@ -14359,9 +14380,15 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:* version: link:../../boot/app-boot + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:* + version: link:../protocol '@deepseek-ai/dsh-typert-registry': specifier: workspace:* version: link:../registry + typescript: + specifier: ^6.0.3 + version: 6.0.3 zod: specifier: 4.4.3 version: 4.4.3 @@ -14371,6 +14398,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:* version: link:../../util/brand + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:~ diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index 434efaca09..e80797f675 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -314,6 +314,15 @@ describe('package payload constraints', () => { .toEqual([expect.stringContaining('package.json files must be')]) }) + it('publishes emitted JavaScript for an explicit Node Client entry', () => { + const client = { types: './lib/types/client/index.d.ts', default: './lib/client.js' } + expect(expectedDshPackageFiles({ exports: { './client': client } })) + .not.toContain('lib/types/**/*.js') + expect(expectedDshPackageFiles({ exports: { + './client': { ...client, node: './lib/types/client/index.js' }, + } })).toContain('lib/types/**/*.js') + }) + it.each([ { exports: { './locale/*.json': './locale/*.json' }, resources: ['locale/*.json'] }, { exports: { './search/locale/*.json': './resources/search/*.json' }, resources: ['resources/search/*.json'] }, diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index e4a038cf1b..6f08254d12 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -289,7 +289,7 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str // browser-safe source channels rehomed off src so plain Node can import // them without type stripping) publish the emitted JS alongside the // declarations. - ...usesEmittedTreeDefaults(manifest) ? ['lib/types/**/*.js'] : [], + ...usesEmittedTreeRuntime(manifest) ? ['lib/types/**/*.js'] : [], 'lib/types/**/*.d.ts', ...hasExportPair(manifest, './typert', './lib/typert.host.d.ts', './lib/typert.host.js') ? ['lib/typert.host.js', 'lib/typert.host.d.ts'] @@ -331,10 +331,13 @@ function exportDefault(manifest: PackageManifest, subpath: string): string | und return typeof target === 'string' ? target : undefined } -/** Whether any export's runtime default points into the tsc-emitted lib/types tree. */ -function usesEmittedTreeDefaults(manifest: PackageManifest): boolean { - return Object.keys(manifest.exports ?? {}).some(subpath => - exportDefault(manifest, subpath)?.startsWith('./lib/types/') === true) +/** Whether an export's default or explicit Node runtime uses the tsc-emitted tree. */ +function usesEmittedTreeRuntime(manifest: PackageManifest): boolean { + return Object.entries(manifest.exports ?? {}).some(([subpath, entry]) => { + const node = exportFields(entry)?.node + return exportDefault(manifest, subpath)?.startsWith('./lib/types/') === true + || typeof node === 'string' && node.startsWith('./lib/types/') + }) } /** Experimental manifest requirements, including explicit private exceptions. */ diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 885a8026d3..b088e5b467 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -170,9 +170,7 @@ "8f28ec738a702607e9ad458a76d5c695a459fed28009b6aac835ef4789026bfb": 1, "97b408a8e61b28b507aab6f709875826b82d6505d4b967aec56914e7f831b20b": 1, "a25e6d8d728e1de71699956f42f62a2e38af439317082fbb0354161ead666ab6": 1, - "b121cee14635b00f7891217013e4682493cf96f676710be60722fdf8b862aea3": 1, - "b428751e788e277116156e105c127f813dc300b61c6664b08d46e6c5ccdc3ae8": 1, - "da388e50e43adcb0b1331d55e3551780c382448686fe6a9dca486937c1667df0": 1 + "b121cee14635b00f7891217013e4682493cf96f676710be60722fdf8b862aea3": 1 }, "packages/api/gateway/tests/gateway.host.spec.ts": { "344e2cbbed2a8ef8d5356a24def92ac3453ffaa2efa58f5481d59206f5b79cbc": 1, @@ -2054,9 +2052,6 @@ "2f2c5a76501f35183d9708040f1f79cc30c8c9da5278823aa19ece7af67f5812": 1, "ec229b2914993fe3b9cabeffe88aaca92dac0aed964d27a4704cd64202044127": 1 }, - "packages/typert/protocol/src/index.ts": { - "35e98b658709d2dead42b6fc80150e0cac9075c55ea512cae80951983bd1f69c": 1 - }, "packages/typert/protocol/tests/protocol.spec.ts": { "418fc1acbd1aac8b6b9ba1ac25f30a4fc33d632e372085c73f396e76b9da883a": 1, "539af7158f6057075289d3b1543bd5b692887755287d81f91d9b9c7cc14cf9cb": 1, diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 9c2cf02a10..5f99a3e534 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -896,6 +896,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { 'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts', 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts', 'packages/api/remotes/tests/built-lib.e2e.ts', + 'packages/api/remotes/tests/session-client.e2e.ts', 'packages/experimental/agent-team/tests/built-lib.e2e.ts', // Built execution consumers: the only automated proof that package-name // imports reach their lib/ entrypoints under plain Node. The e2e lane runs diff --git a/scripts/verify-package-dependencies.spec.ts b/scripts/verify-package-dependencies.spec.ts index 3548871b00..5494cb4877 100644 --- a/scripts/verify-package-dependencies.spec.ts +++ b/scripts/verify-package-dependencies.spec.ts @@ -560,6 +560,33 @@ describe('face-aware source classification', () => { } }) + it('retains runtime dependencies reached by an explicit Node Client entry', () => { + const subject = sourceFacts({ + 'src/index.ts': 'export function apply() {}', + 'src/client/index.ts': "import 'client-node-runtime'", + 'src/client/browser.ts': "import 'browser-only'", + }, { + exports: { + './client': { + types: './lib/types/client/index.d.ts', + node: './lib/types/client/index.js', + default: './lib/client.js', + }, + './client/browser': './lib/client/browser.js', + }, + }) + + expect([...subject.hostRuntimeSourceUses.keys()]).toEqual(['client-node-runtime']) + expect(expectedPackageDependencies(subject).get('client-node-runtime')?.section).toBe('dependencies') + expect(expectedPackageDependencies(subject).get('browser-only')?.section).toBe('devDependencies') + }) + + it('rejects an explicit Node Client entry without its source', () => { + expect(() => sourceFacts({ 'src/index.ts': 'export function apply() {}' }, { + exports: { './client': { node: './lib/types/client/index.js', default: './lib/client.js' } }, + })).toThrow('Host export ./client has no source entry') + }) + it.each([ './lib/missing.js', { types: './lib/types/missing.d.ts', default: './lib/renamed.js' }, diff --git a/scripts/verify-package-dependencies.ts b/scripts/verify-package-dependencies.ts index 50e3ebcb5c..59df626937 100644 --- a/scripts/verify-package-dependencies.ts +++ b/scripts/verify-package-dependencies.ts @@ -362,11 +362,16 @@ function hostSourceEntries(root: string, pkg: WorkspacePackageManifest): string[ const exports = pkg.manifest.exports if (exports === null || typeof exports !== 'object') return [...entries] for (const [subpath, declaration] of Object.entries(exports as Record)) { - if (subpath === '.' || subpath === './package.json' || /^\.\/(?:client|src)(?:\/|$)/.test(subpath)) continue + if (subpath === '.' || subpath === './package.json' || /^\.\/src(?:\/|$)/.test(subpath)) continue + let runtimeDeclaration = declaration + if (/^\.\/client(?:\/|$)/.test(subpath)) { + if (declaration === null || typeof declaration !== 'object' || !('node' in declaration)) continue + runtimeDeclaration = declaration.node + } const types = declaration !== null && typeof declaration === 'object' && 'types' in declaration ? declaration.types : undefined - for (const runtime of nodeExportTargets(declaration)) { + for (const runtime of nodeExportTargets(runtimeDeclaration)) { const generatedFace = subpath === './typert' ? 'host' : subpath === './remote' ? 'remote-client' : undefined if (generatedFace !== undefined && runtime === `./lib/typert.${generatedFace}.js` diff --git a/tsconfig.base.json b/tsconfig.base.json index 0d5ec2c48a..8e65cfb83d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -125,6 +125,7 @@ "@deepseek-ai/dsh-api-workspace-controller/types": ["./packages/api/workspace-controller/src/types.ts"], "@deepseek-ai/dsh-typert-protocol": ["./packages/typert/protocol/src/index.ts"], "@deepseek-ai/dsh-typert-protocol/types": ["./packages/typert/protocol/src/types.ts"], + "@deepseek-ai/dsh-typert-protocol/remote-info": ["./packages/typert/protocol/src/remote-info.ts"], "@deepseek-ai/dsh-typert-loader": ["./packages/typert/loader/src/index.ts"], "@deepseek-ai/dsh-typert-registry/types": ["./packages/typert/registry/src/types.ts"], "@deepseek-ai/dsh-typert-generator": ["./packages/typert/generator/src/index.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index f6e4e3309a..b11984cbf2 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -224,6 +224,8 @@ "scripts/*.client.spec.ts", "scripts/client-bundle-css.spec.ts", "packages/typert/generator/tests/fixtures/**", + // Compiled against ordinary generated artifacts by session-client.e2e.ts. + "packages/api/remotes/tests/fixtures/session-client/**", "scripts/client-bundle-purity.spec.ts" ], "references": [ From 570520862d9bf1a8e53317f966df874683d1b17d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:00:49 +0800 Subject: [PATCH 04/17] refactor(typert): let consumers select input schema strictness --- ...ompiler-independent-typert-model.i18n.yaml | 4 +- ...07-27-compiler-independent-typert-model.md | 2 +- ...27-compiler-independent-typert-model.zh.md | 2 +- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 4 +- ...026-08-02-typert-remote-method-calls.zh.md | 4 +- ...ote-metadata-and-passive-clients.i18n.yaml | 28 +- ...ert-remote-metadata-and-passive-clients.md | 47 ++- ...-remote-metadata-and-passive-clients.zh.md | 47 ++- docs/subsystems/typert.i18n.yaml | 4 +- docs/subsystems/typert.md | 6 +- docs/subsystems/typert.zh.md | 6 +- .../gateway/tests/strict-source.host.spec.ts | 2 +- packages/api/remotes/README.i18n.yaml | 4 +- packages/api/remotes/README.md | 2 +- packages/api/remotes/README.zh.md | 2 +- .../tests/fixtures/session-client/client.ts | 9 +- .../tests/fixtures/session-client/run.mjs | 16 +- .../tests/fixtures/session-client/typing.ts | 5 + packages/typert/generator/README.i18n.yaml | 4 +- packages/typert/generator/README.md | 8 +- packages/typert/generator/README.zh.md | 8 +- packages/typert/generator/src/analyzer.ts | 10 +- packages/typert/generator/src/emitter.ts | 45 +- packages/typert/generator/src/model.ts | 2 - .../generator/src/remote-json-schema.ts | 6 +- .../remote-model/typert-protocol.d.ts | 8 +- .../generator/tests/remote-metadata.spec.ts | 384 ++++++++++++++---- .../generator/tests/remote-model.spec.ts | 6 +- packages/typert/loader/README.i18n.yaml | 4 +- packages/typert/loader/README.md | 2 +- packages/typert/loader/README.zh.md | 2 +- packages/typert/loader/src/index.ts | 6 +- .../typert/loader/tests/lazy-info.spec.ts | 25 +- packages/typert/loader/tests/loader.spec.ts | 22 +- packages/typert/protocol/README.i18n.yaml | 4 +- packages/typert/protocol/README.md | 8 +- packages/typert/protocol/README.zh.md | 8 +- packages/typert/protocol/src/index.ts | 12 +- packages/typert/protocol/src/remote-info.ts | 24 +- packages/typert/protocol/src/types.ts | 18 +- .../typert/protocol/tests/protocol.spec.ts | 8 +- .../typert/protocol/tests/remote-info.spec.ts | 86 ++-- packages/typert/registry/README.i18n.yaml | 4 +- packages/typert/registry/README.md | 2 +- packages/typert/registry/README.zh.md | 2 +- packages/typert/registry/tests/typert.spec.ts | 4 +- 47 files changed, 626 insertions(+), 294 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml index 7e093f823b..d852fcea7c 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml @@ -18,5 +18,5 @@ en: fa503dcfaa6f438f zh: 26052212a124e133 /agent-note-compiler-independent-typert-type-model/consequences: - en: 62bcfee24881dd11 - zh: 97867e4be4c2e390 + en: eb116721d7736818 + zh: cec38e1474ff8fce diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md index ae255d08b2..d2aa76a643 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md @@ -52,4 +52,4 @@ New generation targets and static checks can reuse the same TypeGraph, and busin Explicit package opt-in keeps artifact publication and exports under package ownership. Repository orchestration may still run the whole-workspace Host contract pass for every opted-in package; that pass remains owned by the later Remote Gateway Agent Note. The static Cordis catalogs remain reproducible from the canonical model without coupling `tool-cordis` to runtime registry state. `ctx.typert` reflects only artifacts mounted in the current runtime, and unloading does not control Zod instances that consumers retain after materializing them. -Ordinary Host and Remote artifacts reuse this model to emit one static method spec per endpoint. A pure factory returns a lazy info instance whose direct method index and descriptor list share objects; the existing Registry retains those descriptors, while `TYPERT_REMOTE.methods` supplies exact static types. The [unified Typert/Remote metadata note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.md) records this delivery mechanism and the incomplete downstream adaptation. Declaration and face-isolation rules remain unchanged; separate Programs and processes do not share object identity. +Ordinary Host and Remote artifacts reuse this model to emit one static method spec per endpoint. Each parameter has normal `codec` and `strictCodec` variants derived from the same checker-resolved type model; neither analysis nor the model carries a per-method input policy. A pure factory returns a lazy info instance whose direct method index and descriptor list share objects; the existing Registry retains those descriptors, while `TYPERT_REMOTE.methods` supplies exact static types. The [unified Typert/Remote metadata note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.md) records this delivery mechanism and the incomplete downstream adaptation. Declaration and face-isolation rules remain unchanged; separate Programs and processes do not share object identity. diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md index 1b45bdb28e..ccbdd92cb4 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md @@ -52,4 +52,4 @@ Zod emitter 对支持的节点和各类 literal 逐类执行成功与失败 pars 包级显式 opt-in 使产物发布与 exports 由各包自行管理。仓库编排仍可为每个已 opt-in 的包运行全仓 Host 约定 pass;该 pass 仍由后续 Remote Gateway Agent Note 负责说明。静态 Cordis catalog 可从标准模型复现,同时不把 `tool-cordis` 与运行时注册表状态耦合。`ctx.typert` 只反映当前运行时中已挂载的产物;对于消费方物化后仍持有的 Zod 实例,卸载流程无法控制。 -普通 Host 与 Remote 产物复用此模型,为每个端点生成一份静态方法 spec。纯 factory 返回惰性 info 实例,其直接方法索引与描述符列表共享对象;既有 Registry 保留这些描述符,而 `TYPERT_REMOTE.methods` 提供精确静态类型。[统一 Typert/Remote 元数据 note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.zh.md)记录该交付机制及尚未完成的下游适配。声明与编译面隔离规则保持不变;独立 Program 和进程不共享对象身份。 +普通 Host 与 Remote 产物复用此模型,为每个端点生成一份静态方法 spec。每个参数都有从同一 checker 解析后类型模型派生的普通 `codec` 和 `strictCodec` 变体;分析过程与模型均不携带逐方法输入策略。纯 factory 返回惰性 info 实例,其直接方法索引与描述符列表共享对象;既有 Registry 保留这些描述符,而 `TYPERT_REMOTE.methods` 提供精确静态类型。[统一 Typert/Remote 元数据 note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.zh.md)记录该交付机制及尚未完成的下游适配。声明与编译面隔离规则保持不变;独立 Program 和进程不共享对象身份。 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 7b98e80d31..82599fc976 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -18,8 +18,8 @@ en: b9394d5ef98cc82a zh: 1fce07fe150722ee /agent-note-typert-gateway-targeted-method-calls/decorators-and-the-explicit-gateway-facet: - en: 84090e2490059b9f - zh: dfad2c936cdb6463 + en: 668ac5fbbbfd928f + zh: f85915e316b6b434 /agent-note-typert-gateway-targeted-method-calls/lookup-and-remote-scope-registration: en: 9f7448898cf5cadc zh: 419573c589ed6f7d diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 6ae801dc28..0857fce198 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -87,11 +87,11 @@ A method that cooperatively supports cancellation declares `signal: AbortSignal` ## Decorators and the explicit Gateway facet -A decorator states Remote participation, an optional export name, stream delivery, and whether generated request-object codecs reject unknown fields. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteScope('agent', 'create')` are external method names; the decorated member may be the business method itself or an adapter such as `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `TypertRemoteService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertRemote` field keeps the binding visible on the runtime instance. +A decorator states Remote participation, an optional export name, and stream delivery; `@Remote` options contain only `name` and `mode`. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteScope('agent', 'create')` are external method names; the decorated member may be the business method itself or an adapter such as `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `TypertRemoteService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertRemote` field keeps the binding visible on the runtime instance. In SRC mode, the decorator records the method name and invocation mode in a versioned descriptor on the Service prototype. The descriptor uses a stable string property name, so `remoteMethods()` can read markers produced by another installed copy of `dsh-typert-protocol`; it writes nothing to the Service instance, constructor, or method function. -Ordinary artifacts construct a lazy Remote info instance from one static method spec per endpoint. Its direct `TypertRemoteMethod` objects retain descriptor fields and add named `input` and `invoke`; `result` is the only output codec. Existing Registry queries retain those objects, while `TYPERT_REMOTE.methods` supplies exact static types. Strict source startup prebuilds and loads ordinary Typert artifacts; fallback cannot recover erased schemas. The [unified metadata note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.md) records this delivery mechanism and the incomplete consumer adaptation. Forwarded-event participation remains an explicit composition choice. +Ordinary artifacts construct a lazy Remote info instance from one static method spec per endpoint. Its direct `TypertRemoteMethod` objects retain descriptor fields and add named `input(options?)` and `invoke`. Consumers select matching strict parsing and projection with `input({ strict: true })`; `input()` preserves normal handling. Host parameter codecs and the single normal `result` codec are unchanged. Existing Registry queries retain those objects, while `TYPERT_REMOTE.methods` supplies exact static types. Strict source startup prebuilds and loads ordinary Typert artifacts; fallback cannot recover erased schemas. The [unified metadata note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.md) records this delivery mechanism and the incomplete consumer adaptation. Forwarded-event participation remains an explicit composition choice. In LIB mode, the Typert compiler performs strict method discovery, type resolution, and descriptor generation. It accepts a literal service key in `TypertRemoteService`'s direct `super()` call or the explicit binding fallback; generation neither rewrites business source nor injects hidden registration metadata. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 8d7eca4a96..21921350d2 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -87,11 +87,11 @@ export class ScopedGoalService extends TypertRemoteService { ## Decorator 与显式 Gateway facet -Decorator 表达 Remote 参与、可选导出名称、流式投递,以及生成的请求对象 codec 是否拒绝未知字段,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteScope('agent', 'create')` 的参数是外部方法名;被装饰成员既可以是业务方法本身,也可以是 `remoteExportCreate` 这样的适配器。未给别名时才使用成员名作为外部方法名。继承 `TypertRemoteService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertRemote` 字段使运行时实例上的绑定保持可见。 +Decorator 表达 Remote 参与、可选导出名称和流式投递;`@Remote` 选项仅包含 `name` 和 `mode`。它不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteScope('agent', 'create')` 的参数是外部方法名;被装饰成员既可以是业务方法本身,也可以是 `remoteExportCreate` 这样的适配器。未给别名时才使用成员名作为外部方法名。继承 `TypertRemoteService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertRemote` 字段使运行时实例上的绑定保持可见。 SRC 模式下,decorator 把方法名和调用模式记录在 Service prototype 上的带版本描述符中。描述符使用稳定的字符串属性名,因此 `remoteMethods()` 可以读取 `dsh-typert-protocol` 另一个已安装副本生成的标记;它不会向 Service 实例、constructor 或方法函数写入任何内容。 -普通产物从每个端点唯一的静态方法 spec 构造惰性 Remote info 实例。其直接 `TypertRemoteMethod` 对象保留描述符字段,并增加具名 `input` 和 `invoke`;`result` 是唯一输出 codec。既有 Registry 查询保留这些对象,`TYPERT_REMOTE.methods` 则提供精确静态类型。严格源码启动预构建并加载普通 Typert 产物;fallback 无法恢复被擦除的 schema。[统一元数据 note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.zh.md)记录该交付机制及尚未完成的消费方适配。转发事件参与仍是显式的组合选择。 +普通产物从每个端点唯一的静态方法 spec 构造惰性 Remote info 实例。其直接 `TypertRemoteMethod` 对象保留描述符字段,并增加具名 `input(options?)` 和 `invoke`。消费方通过 `input({ strict: true })` 选择配套的严格解析与投影;`input()` 保留普通处理。Host 参数 codec 与唯一的普通 `result` codec 保持不变。既有 Registry 查询保留这些对象,`TYPERT_REMOTE.methods` 则提供精确静态类型。严格源码启动预构建并加载普通 Typert 产物;fallback 无法恢复被擦除的 schema。[统一元数据 note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.zh.md)记录该交付机制及尚未完成的消费方适配。转发事件参与仍是显式的组合选择。 LIB 的严格方法发现、类型解析和 descriptor 生成由 Typert compiler 完成。它接受 `TypertRemoteService` 直接 `super()` 调用中的字面量 service key,或显式 binding 回退;生成过程不改写业务源码,也不注入隐藏注册元数据。 diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.i18n.yaml index c8e46c4a48..980432ebbc 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.i18n.yaml @@ -6,32 +6,32 @@ en: 4b9c7921ba23adf9 zh: 24015b341a7befa3 /agent-note-unified-typert-remote-metadata-and-passive-clients/problem: - en: 154cf4633d54dbfe - zh: 50755266546a873f + en: 7d5096b832023e28 + zh: bd2b3f5b182d998b /agent-note-unified-typert-remote-metadata-and-passive-clients/technical-architecture: - en: 2b2f20ece33959dd - zh: 3179782919f858ee + en: 23fa081ffc7d9489 + zh: 0128d572e821227f /agent-note-unified-typert-remote-metadata-and-passive-clients/proposal: en: 5e8a34a91fb50448 zh: ec5a7a4f3b1fcc2e /agent-note-unified-typert-remote-metadata-and-passive-clients/proposal/shared-generation-two-access-modes: - en: e6ca44352c040f21 - zh: 385236cd488fddf2 + en: e2d2722046cd42cd + zh: b9ad3260ac636ccb /agent-note-unified-typert-remote-metadata-and-passive-clients/proposal/downstream-migration-targets: - en: bcaaab505630d7cb - zh: 799eb1cd2627ae8c + en: 1c5c713b8e8f380a + zh: 58c0318383ab69db /agent-note-unified-typert-remote-metadata-and-passive-clients/proposal/paired-usage-examples: - en: 778a32f7d7e08245 - zh: f07c79ff93de45eb + en: 119995a2ef0fc875 + zh: 7af1e7c0d1105d28 /agent-note-unified-typert-remote-metadata-and-passive-clients/proposal/bounded-reproduction-on-this-branch: - en: c735bfb5da7ead9a - zh: 4e9d1c5728bbbc10 + en: 9a1cb50b9576fe89 + zh: 11187b2db921ab8e /agent-note-unified-typert-remote-metadata-and-passive-clients/alternatives-considered: en: 7ec48c492b1e4859 zh: a751968903a7f711 /agent-note-unified-typert-remote-metadata-and-passive-clients/acceptance-criteria: - en: 42cdcc600e85ad8c - zh: 6e34ee25cdc55aae + en: d2153a512cdb2fd6 + zh: 7c5e5e013b1345da /agent-note-unified-typert-remote-metadata-and-passive-clients/risks: en: 74f3a5b36d5609b2 zh: 0ecc15d505511e8a diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.md b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.md index b48cb493bc..c18834925e 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.md +++ b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.md @@ -6,9 +6,9 @@ English | [中文](2026-09-20-unified-typert-remote-metadata-and-passive-clients ## Problem -CLI help, argument parsing, and MCP tool discovery need local method descriptions, named-input parsers, tool JSON Schemas, and result metadata before connecting to a controlled Host. Browser, Node, and independent consumers also need typed invocations, while strict Hosts must enforce the same generated constraints. These requirements do not imply runtime TypeScript analysis, Host Service activation, or another business schema maintained by each consumer. +CLI help, argument parsing, and MCP tool discovery need local method descriptions, named-input parsers, tool JSON Schemas, and result metadata before connecting to a controlled Host. Browser, Node, and independent consumers also need typed invocations. Tool consumers need stricter input-object validation without changing ordinary Host dispatch. These requirements do not imply runtime TypeScript analysis, Host Service activation, or another business schema maintained by each consumer. -The original #4644 [source-definition decision](../../implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.md) delivers this information through `emitSourceDef`, committed generated files, and `./remote-source`. Host bindings carry those definitions; source generation has its own inventory, freshness, publication, and exclusion rules. This note supersedes only that delivery mechanism. The original note remains intact because its passive-client and carrier-ownership decisions still apply. +The original #4644 [source-definition decision](../../implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.md) delivers this information through `emitSourceDef`, committed generated files, and `./remote-source`. Host bindings carry those definitions; source generation has its own inventory, freshness, publication, and exclusion rules. This note replaces that delivery mechanism and makes input strictness consumer-owned. The original note remains intact because its passive-client and carrier-ownership decisions still apply. Both delivery paths derive from the same [compiler-independent model](../../implemented/architecture/2026-07-27-compiler-independent-typert-model.md). Separate Host and Client Programs require generated cross-face declarations, not committed generated source. An explicit prebuild is acceptable when exact schemas are needed; ordinary ESM artifacts can supply offline metadata without a live Host. The [targeted-call decision](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) still owns receiver lookup, Gateway dispatch, and contribution lifetimes. @@ -22,15 +22,17 @@ Both delivery paths derive from the same [compiler-independent model](../../impl [Registry](../../../../packages/typert/registry/src/service.ts) instances belong to their Cordis Context and process. Host and Client entries share implementation, not state. Existing local/Remote `get()` and `list()` preserve the registered descriptor objects; registration retains atomic duplicate checks, notifications, tombstones, and fiber-owned disposal. No Registry API, store, subscription, metadata RPC, or cross-process synchronization is added. Separate Programs and processes do not share object identity. -The [emitter](../../../../packages/typert/generator/src/emitter.ts) writes one static `TypertRemoteMethodSpec` per endpoint and calls `createTypertRemoteInfo({ package, methods })` from the published pure `@deepseek-ai/dsh-typert-protocol/remote-info` entry. The endpoint key supplies `namespace` and `method`, and `id` combines it with the package. Each spec keeps the service, receiver, ordered parameters, single `result` codec, and optional stream, scope, uplink, cancellation, documentation, and source-location fields. Parameter `wire` defaults to `name`; parameter documentation stays on `parameters[].description`. There is no nested `metadata`, separate `output` or `parameterDescriptions`, or top-level `REMOTE_METHODS` export. +The [emitter](../../../../packages/typert/generator/src/emitter.ts) writes one static `TypertRemoteMethodSpec` per endpoint and calls `createTypertRemoteInfo({ package, methods })` from the published pure `@deepseek-ai/dsh-typert-protocol/remote-info` entry. The endpoint key supplies `namespace` and `method`, and `id` combines it with the package. Each spec keeps the service, receiver, ordered parameters, single `result` codec, and optional stream, scope, uplink, cancellation, documentation, and source-location fields. Every parameter has both `codec` and `strictCodec` from the same checker-resolved type model, with no per-method policy in the analyzer or model. Parameter `wire` defaults to `name`; parameter documentation stays on `parameters[].description`. There is no nested `metadata`, separate `output` or `parameterDescriptions`, or top-level `REMOTE_METHODS` export. The [factory](../../../../packages/typert/protocol/src/remote-info.ts) returns an actual class instance, not a class constructor or eager object literal. Construction retains the spec only. The first `descriptors` or `methods` read, including mounting or querying, builds and caches one shared index. Each `methods[endpoint]` aliases the exact direct descriptor object; Context-only receivers remain in descriptors and scoped declarations. Direct methods with scoped projections retain their scope fields but their generic named invoker requires an unscoped Remote. -A direct method derives its named `input` from ordered parameter codecs and uses `result` for successful unary values or stream items, including binary `encode`/`decode`. The named Zod schema is built and cached at first `input.create()`; its JSON Schema projection is assembled and cached only when read. Individual generated Zod factories are also lazy and success-cached. The raw spec requires Zod factories internally, while consumer-facing `TypertSchema` promises only `parse(value: unknown): T`. JSON Schema remains an optional generator projection, not runtime `z.toJSONSchema()` conversion. Imports and metadata/schema access never invoke the network; the protocol root re-exports the factory too but imports Cordis. +A direct method's `input(options?: TypertRemoteInputOptions)` returns a cached `TypertRemoteValue`; `strict?: boolean` is the only option. `method.input()` and `{ strict: false }` use normal codecs: declared objects accept and strip unknown fields. `{ strict: true }` rejects undeclared fields recursively. Both named-argument envelopes reject extra arguments; explicit JSON and dictionary keys stay open, and constrained dictionary values still validate. Consumers select one view for matching `input.create().parse(raw)` and `input.jsonSchema`. Host dispatch and the single `result` codec retain normal behavior, including binary `encode`/`decode`. -Registry queries return base descriptors. `isTypertRemoteMethod(descriptor)` from the pure leaf or protocol root narrows direct capabilities with `object`/`unknown` types; exact endpoint types come from `TYPERT_REMOTE.methods`, not dynamic lookup. Unary `mode` is omitted; streaming `mode` is `'stream'`. `invoke(remote, input, signal?)` resolves the mounted namespace method at call time, preserving its `this`, optional positional holes, and signal. It forwards the same full `Promise>` or `RemoteStreamHandle` without an async wrapper; stream iteration, `send`, `end`, and `dispose` remain available. +Each input-policy view, its named parser, and its JSON Schema projection are independently lazy and cached. Individual generated Zod factories are also lazy and success-cached. The raw spec requires Zod factories internally, while consumer-facing `TypertSchema` promises only `parse(value: unknown): T`. JSON Schema remains an optional generator projection, not runtime `z.toJSONSchema()` conversion. The pure leaf depends only on Zod at runtime, not Host source or transport. Imports and metadata/schema access never invoke the network; the protocol root re-exports the factory too but imports Cordis. -Documentation and JSON Schema are optional. Unsupported projection records `jsonSchemaError` while preserving parsers and independent documentation; only the dedicated projection error is caught. A consumer explicitly selecting a tool requires its description and supported input JSON Schema. Output parsing remains required even when optional output JSON Schema is absent. Native `dsh-tools` registration additionally requires `output.schema`: a parser alone does not satisfy it. These consumer requirements must not prevent an otherwise valid Remote method from being generated. +Registry queries return base descriptors. `isTypertRemoteMethod(descriptor)` from the pure leaf or protocol root narrows direct capabilities with `object`/`unknown` types; exact endpoint types come from `TYPERT_REMOTE.methods`, not dynamic lookup. Unary `mode` is omitted; streaming `mode` is `'stream'`. `invoke(remote, input, signal?)` resolves the mounted namespace method at call time, preserving its `this`, optional positional holes, and signal. It forwards the same full `Promise>` or `RemoteStreamHandle` without an async wrapper; stream iteration, `send`, `end`, and `dispose` remain available. The fifth `TypertRemoteMethod` generic retains the carrier-decoded unary byte type independently of the result parser's authored output. + +Documentation and JSON Schema are optional. Each unsupported input variant or result projection records its own `jsonSchemaError` while preserving parsers and independent documentation; only the dedicated projection error is caught. A consumer explicitly selecting a tool requires its description and supported input JSON Schema. Output parsing remains required even when optional output JSON Schema is absent. Native `dsh-tools` registration additionally requires `output.schema`: a parser alone does not satisfy it. These consumer requirements must not prevent an otherwise valid Remote method from being generated. ## Proposal @@ -40,9 +42,9 @@ Complete the ordinary-artifact migration in the downstream Session-client, CLI, Framework consumers load ordinary metadata into their own Registry. Independent consumers may import `./remote` directly without Cordis, Host Service activation, or a connection. Both read the same generated business definition. Keep separate Host/Client Programs and analyze all contributors of each face so focused emission retains external declaration augmentations. Registration does not expose every reflected method as a callable endpoint or tool. -Strict source startup explicitly prebuilds and loads ordinary `./typert` through Loader. Limited descriptor-free fallback still supports basic arguments, signals, cancellation, streams, JSON conversion, and unary byte attachments, but cannot recover erased fields, constraints, or tool documentation. Once a strict local descriptor has been observed, its withdrawal must refuse invocation rather than downgrade to fallback. `rejectUnknownFields` is an independent generation-only request-codec policy, not a runtime marker or a tool-selection flag. +Strict source startup explicitly prebuilds and loads ordinary `./typert` through Loader. Limited descriptor-free fallback still supports basic arguments, signals, cancellation, streams, JSON conversion, and unary byte attachments, but cannot recover erased fields, constraints, or tool documentation. Once a strict local descriptor has been observed, its withdrawal must refuse invocation rather than downgrade to fallback. `@Remote` accepts only `name` and `mode`; consumers choose input policy without a separate developer annotation. -Host metadata readiness remains composition-owned. A Host requiring strict validation must await local registration before serving calls; `hasSeen()` does not protect an endpoint before its first registration. Returning a lazy info instance neither completes asynchronous Loader discovery nor establishes readiness. Loader validation must also avoid forcing derived input projections merely to register a contribution. +Host metadata readiness remains composition-owned. A Host requiring strict validation must await local registration before serving calls; `hasSeen()` does not protect an endpoint before its first registration. Returning a lazy info instance neither completes asynchronous Loader discovery nor establishes readiness. Loader validates static codec specs and callable `input`/`invoke` without calling the selector, parser factories, or projections to register a contribution. Connection owns authenticated HTTP and physical WebSockets; Gateway owns framing and dispatch. `forwardedEvents: true` explicitly opts into Host event forwarding and interaction waterfalls. Otherwise `$on()` and event-supervised `$stream()` fail promptly, while generated streaming methods remain available. Importing, registering, listing tools, or calling methods does not acquire interaction-answer ownership. Node hooks are instance-owned and disposal is awaited. @@ -53,12 +55,12 @@ The inspected #4111 tree contains Session-client, CLI, MCP, and native-tool cons | Consumer | Metadata adaptation | Execution retained | |---|---|---| | Browser / Node Remote callers | Import ordinary `./remote`; mount once in the live Context. | Generated namespaces, Gateway, authenticated Connection, and target controllers. | -| Offline CLI help / parsing | Register before target discovery in the CLI profile's metadata Context. | No connection for help; a separate live Context mounts when calling. | -| MCP discovery / calls | Read selected Registry entries; retain consumer-owned tool descriptions and envelopes. | `tools/list` is offline; `tools/call` uses the same Session client. External MCP clients receive protocol JSON, not Registry modules. | +| Offline CLI help / parsing | Register before target discovery in the CLI profile's metadata Context; select `input({ strict: true })`. | No connection for help; a separate live Context mounts when calling. | +| MCP discovery / calls | Read selected Registry entries with `input({ strict: true })`; retain consumer-owned tool descriptions and envelopes. | `tools/list` is offline; `tools/call` uses the same Session client. External MCP clients receive protocol JSON, not Registry modules. | | Native tools | Read Host local method info; require the tool registration's `output.schema`. | Parse at the tool boundary, then call same-process controllers directly. | | Independent adapters | Read typed `TYPERT_REMOTE.methods` directly. | Use their chosen Remote runtime only when invoking. | -Current-session selection and attachment preparation remain consumer-owned; validate completed business arguments with generated parsers. The downstream `follow` adapter's `activate: false` policy applies where that API exists; this branch's current `follow` has no such option. Aborting observation does not cancel accepted Session work. Native direct dispatch also does not remove unrelated business dependencies such as the downstream controller's pending-interaction access through Gateway. +Current-session selection and attachment preparation remain consumer-owned; CLI/MCP intake validates completed business arguments with the same strict input view used during selection. The downstream `follow` adapter's `activate: false` policy applies where that API exists; this branch's current `follow` has no such option. Aborting observation does not cancel accepted Session work. Native direct dispatch also does not remove unrelated business dependencies such as the downstream controller's pending-interaction access through Gateway. ### Paired usage examples @@ -77,7 +79,7 @@ page(request: SessionPageRequest, signal: AbortSignal): Promise { // Ordinary delivery; no generated-source import or constructor definitions. // Constructor: super(ctx, 'sessionController', { namespace: 'session' }) -@Remote({ name: 'page', rejectUnknownFields: true }) +@Remote({ name: 'page' }) page(request: SessionPageRequest, signal: AbortSignal): Promise { return this.history.page(request, signal) } @@ -116,13 +118,14 @@ if (page === undefined || !isTypertRemoteMethod(page)) { } ``` -The registration belongs to that plugin's fiber. Help uses `description` and `input`; the typed facade can retain type-only imports from ordinary generated declarations. Explicit tool selection does not mutate registered metadata. +The registration belongs to that plugin's fiber. Help uses `description` and `input({ strict: true })`; the typed facade can retain type-only imports from ordinary generated declarations. Explicit tool selection does not mutate registered metadata. **CLI input and dispatch.** After local `current` resolution and attachment preparation, either metadata access path uses the same invocation sequence: ```ts ignore-check if (page.mode === 'stream') throw new Error('session/page must be unary') -const arguments_ = page.input.create().parse(raw) +const input = page.input({ strict: true }) +const arguments_ = input.create().parse(raw) const result = await page.invoke(remote, arguments_, signal) if (!result.ok) throw result.error const output = page.result.create().parse(result.value) @@ -131,19 +134,20 @@ const output = page.result.create().parse(result.value) **MCP discovery and calls.** Selection validates tool-specific requirements; generation itself does not require them. ```ts ignore-check +const input = page.input({ strict: true }) if (!page.description) throw new Error('session/page has no tool description') -if (page.input.jsonSchema === undefined) throw new Error('session/page has no supported tool input schema') +if (input.jsonSchema === undefined) throw new Error('session/page has no supported tool input schema') const tool = { name: 'remote_session_page', description: page.description, - inputSchema: page.input.jsonSchema, + inputSchema: input.jsonSchema, ...(page.result.jsonSchema === undefined ? {} : { outputSchema: page.result.jsonSchema }), } ``` -This compact object shows only the metadata dependency. The real MCP adapter retains its `{ arguments, current, content }` input and `{ ok, value }` / failure output envelopes. Listing requires no connection; calling performs intake, local preparation, generated parsing, invocation, and output parsing. Optional output projection does not remove output validation. +This compact object shows only the metadata dependency. The real MCP adapter retains its `{ arguments, current, content }` input and `{ ok, value }` / failure output envelopes. Listing requires no connection; calling performs intake, local preparation, parsing through `input.create()`, invocation, and output parsing. Optional output projection does not remove output validation. -**Native tools.** Query `ctx.typert.local.get('session/page')` and narrow it with `isTypertRemoteMethod` instead of reading the source table. Require supported input and result projections for native registration, including its mandatory `output.schema`; optional MCP output projection is not sufficient here. Once the facade obtains a typed request, both designs execute the same local call: +**Native tools.** Query `ctx.typert.local.get('session/page')` and narrow it with `isTypertRemoteMethod` instead of reading the source table. Native tools can select `method.input({ strict: true })` for matching parsing and input projection. Native registration still requires supported input and result projections, including core Tool's mandatory `output.schema`; optional MCP output projection is not sufficient here. Once the facade obtains a typed request, both designs execute the same local call: ```ts ignore-check const value = await ctx.sessionController.page(request, signal) @@ -154,7 +158,7 @@ const value = await ctx.sessionController.page(request, signal) ```ts ignore-check import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' const page = TYPERT_REMOTE.methods['session/page'] -const arguments_ = page.input.create().parse(raw) +const arguments_ = page.input().create().parse(raw) const result = await page.invoke(remote, arguments_, signal) ``` @@ -167,7 +171,7 @@ pnpm dsh --profile ### Bounded reproduction on this branch -The test-local [Session client](../../../../packages/api/remotes/tests/fixtures/session-client/client.ts) and [MCP adapter](../../../../packages/api/remotes/tests/fixtures/session-client/mcp.ts) extract the downstream access pattern for current `session/page` and `session/follow`. They use Registry metadata, current-session injection, explicit unsupported-content rejection, generic parsers, and ordinary invocation. They do not add future controller operations, a public package/bin, an alternate application launcher, or the complete downstream application. +The test-local [Session client](../../../../packages/api/remotes/tests/fixtures/session-client/client.ts) and [MCP adapter](../../../../packages/api/remotes/tests/fixtures/session-client/mcp.ts) extract the downstream access pattern for current `session/page` and `session/follow`. They use Registry metadata, `input({ strict: true })` for both selection and intake, current-session injection, explicit unsupported-content rejection, and ordinary invocation. They do not add future controller operations, a public package/bin, an alternate application launcher, or the complete downstream application. The [artifact test](../../../../packages/api/remotes/tests/session-client.e2e.ts) compiles only its local consumer fixture against ordinary declarations, then runs plain Node. Its [runtime fixture](../../../../packages/api/remotes/tests/fixtures/session-client/run.mjs) composes the real SessionController, live Session/query services, Gateway, and Connection over HTTP/WebSocket. This is a manually assembled bounded integration, not a Loader/profile composition or the complete CLI/MCP protocol server. The separate Loader test uses an authored artifact fixture; the strict-source Gateway test uses an authored ordinary contribution. Neither is represented as generated full-application evidence. @@ -183,7 +187,8 @@ The [artifact test](../../../../packages/api/remotes/tests/session-client.e2e.ts ## Acceptance criteria - Ordinary artifacts emit one static method spec per endpoint and return lazy class instances; direct method indexes alias descriptor objects, with full stream/binary typing, separate compiler faces, and full-face augmentation retention. -- Descriptor indexes, named-input schemas, and derived projections materialize only when requested and cache their results; Loader validation does not force derived input projections. +- Descriptor indexes and each input-policy view, parser, and projection are independently lazy and cached; Loader validates static specs and callable helpers without calling selectors, parsers, or projections. +- Every input parameter emits normal and strict codecs from the same model; consumers select policy without a method annotation. Both envelopes reject extra named arguments; strict input recursively closes declared objects without closing JSON or dictionary keys or changing Host dispatch and results. - Registry identity, atomic registration, withdrawal, and disposal remain intact; metadata inspection and method calls stay passive. - Missing documentation or unsupported optional projection does not disable generation; selected tools reject missing requirements and still parse outputs. - Strict source/artifact invocation and limited fallback have separate tests; a withdrawn strict descriptor never enables fallback. diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.zh.md b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.zh.md index b8b9713133..38087a4374 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.zh.md +++ b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.zh.md @@ -6,9 +6,9 @@ Status: proposed ## Problem -CLI 帮助、参数解析和 MCP 工具发现需要在连接受控 Host 前取得本地方法说明、具名输入解析器、工具 JSON Schema 和结果元数据。Browser、Node 与独立消费方还需要类型化调用,而严格 Host 必须执行相同的生成约束。这些需求不意味着运行时 TypeScript 分析、Host Service 激活,或每个消费方另行维护业务 schema。 +CLI 帮助、参数解析和 MCP 工具发现需要在连接受控 Host 前取得本地方法说明、具名输入解析器、工具 JSON Schema 和结果元数据。Browser、Node 与独立消费方还需要类型化调用。工具消费方需要更严格的输入对象校验,但不能改变普通 Host 分发。这些需求不意味着运行时 TypeScript 分析、Host Service 激活,或每个消费方另行维护业务 schema。 -#4644 原有的[源码定义决策](../../implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.zh.md)通过 `emitSourceDef`、提交的生成文件和 `./remote-source` 交付这些信息。Host 绑定携带定义;源码生成另有清单、新鲜度、发布和排除规则。本 note 只替代该交付机制。原 note 保持不变,因为其中的被动客户端与载体所有权决策仍然适用。 +#4644 原有的[源码定义决策](../../implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.zh.md)通过 `emitSourceDef`、提交的生成文件和 `./remote-source` 交付这些信息。Host 绑定携带定义;源码生成另有清单、新鲜度、发布和排除规则。本 note 替代该交付机制,并将输入严格性改为由消费方选择。原 note 保持不变,因为其中的被动客户端与载体所有权决策仍然适用。 两种交付路径均来自同一个[编译器无关模型](../../implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md)。独立的 Host 与 Client Program 要求生成跨编译面声明,而非提交生成源码。需要精确 schema 时,显式预构建是可接受的;普通 ESM 产物无需运行中的 Host 即可提供离线元数据。[定向调用决策](../../implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)仍负责接收者查找、Gateway 分发和贡献生命周期。 @@ -22,15 +22,17 @@ CLI 帮助、参数解析和 MCP 工具发现需要在连接受控 Host 前取 [Registry](../../../../packages/typert/registry/src/service.ts) 实例属于各自的 Cordis Context 和进程。Host 与 Client 入口共享实现,而非状态。既有本地/Remote `get()` 与 `list()` 保留已注册的描述符对象;注册保留原子重复检查、通知、tombstone 和 fiber 管理的释放。不新增 Registry API、存储、订阅、元数据 RPC 或跨进程同步。独立 Program 和进程不共享对象身份。 -[发射器](../../../../packages/typert/generator/src/emitter.ts)为每个端点写入一份静态 `TypertRemoteMethodSpec`,并调用已发布纯入口 `@deepseek-ai/dsh-typert-protocol/remote-info` 提供的 `createTypertRemoteInfo({ package, methods })`。端点键提供 `namespace` 和 `method`,`id` 由包名与端点键组合。每份 spec 保留 service、接收者、有序参数、唯一的 `result` codec,以及可选的流模式、scope、uplink、取消、文档和源码位置字段。参数 `wire` 默认使用 `name`;参数文档保留在 `parameters[].description`。不存在嵌套 `metadata`、独立 `output` 或 `parameterDescriptions`,也不导出顶层 `REMOTE_METHODS`。 +[发射器](../../../../packages/typert/generator/src/emitter.ts)为每个端点写入一份静态 `TypertRemoteMethodSpec`,并调用已发布纯入口 `@deepseek-ai/dsh-typert-protocol/remote-info` 提供的 `createTypertRemoteInfo({ package, methods })`。端点键提供 `namespace` 和 `method`,`id` 由包名与端点键组合。每份 spec 保留 service、接收者、有序参数、唯一的 `result` codec,以及可选的流模式、scope、uplink、取消、文档和源码位置字段。每个参数都有从同一 checker 解析后类型模型派生的 `codec` 和 `strictCodec`,分析器与模型不携带逐方法策略。参数 `wire` 默认使用 `name`;参数文档保留在 `parameters[].description`。不存在嵌套 `metadata`、独立 `output` 或 `parameterDescriptions`,也不导出顶层 `REMOTE_METHODS`。 [factory](../../../../packages/typert/protocol/src/remote-info.ts)返回真正的类实例,而非类构造函数或立即求值的对象字面量。构造时只保留 spec。首次读取 `descriptors` 或 `methods` 时(包括挂载或查询时)才构建并缓存一个共享索引。每个 `methods[endpoint]` 就是同一个直接方法描述符对象;仅使用 Context 接收者的方法保留在描述符和作用域声明中。带作用域投影的直接方法保留 scope 字段,但其通用具名调用器要求无作用域 Remote。 -直接方法从有序参数 codec 派生具名 `input`,并使用 `result` 表示成功一元值或流条目,包括二进制 `encode`/`decode`。具名 Zod schema 在首次 `input.create()` 时构建并缓存;其 JSON Schema 投影只在读取时组装并缓存。生成的独立 Zod factory 同样惰性求值且只缓存成功结果。原始 spec 内部要求 Zod factory,而面向消费方的 `TypertSchema` 只承诺 `parse(value: unknown): T`。JSON Schema 仍是可选的生成器投影,而非运行时 `z.toJSONSchema()` 转换。导入和元数据/schema 读取绝不发起网络调用;协议根入口也重新导出 factory,但会导入 Cordis。 +直接方法的 `input(options?: TypertRemoteInputOptions)` 返回缓存的 `TypertRemoteValue`;唯一选项是 `strict?: boolean`。`method.input()` 与 `{ strict: false }` 使用普通 codec:已声明对象接受并剥离未知字段。`{ strict: true }` 递归拒绝未声明字段。两种具名参数封装都拒绝额外参数;显式 JSON 与字典键保持开放,有约束的字典值仍然校验。消费方选择一个视图,通过 `input.create().parse(raw)` 与 `input.jsonSchema` 取得匹配的解析和投影。Host 分发与唯一的 `result` codec 保留普通行为,包括二进制 `encode`/`decode`。 -Registry 查询返回基础描述符。纯叶入口或协议根入口的 `isTypertRemoteMethod(descriptor)` 以 `object`/`unknown` 类型收窄直接调用能力;精确端点类型来自 `TYPERT_REMOTE.methods`,而非动态查询。一元 `mode` 省略;流式 `mode` 为 `'stream'`。`invoke(remote, input, signal?)` 在调用时解析已挂载的命名空间方法,保留其 `this`、可选位置参数空位和 signal。它不使用 async 包装,直接转发同一个完整 `Promise>` 或 `RemoteStreamHandle`;流迭代、`send`、`end` 和 `dispose` 均保留。 +每种输入策略的视图、具名解析器及 JSON Schema 投影都独立惰性求值并缓存。生成的独立 Zod factory 同样惰性求值且只缓存成功结果。原始 spec 内部要求 Zod factory,而面向消费方的 `TypertSchema` 只承诺 `parse(value: unknown): T`。JSON Schema 仍是可选的生成器投影,而非运行时 `z.toJSONSchema()` 转换。纯叶入口运行时仅依赖 Zod,不依赖 Host 源码或传输。导入和元数据/schema 读取绝不发起网络调用;协议根入口也重新导出 factory,但会导入 Cordis。 -文档和 JSON Schema 可选。不支持的投影记录 `jsonSchemaError`,同时保留解析器及独立文档;只捕获专用投影错误。显式选择工具的消费方要求工具说明和受支持的输入 JSON Schema。即使缺少可选输出 JSON Schema,输出解析仍然必需。原生 `dsh-tools` 注册额外要求 `output.schema`:仅有解析器并不满足要求。这些消费方要求不得阻止本来有效的 Remote 方法生成。 +Registry 查询返回基础描述符。纯叶入口或协议根入口的 `isTypertRemoteMethod(descriptor)` 以 `object`/`unknown` 类型收窄直接调用能力;精确端点类型来自 `TYPERT_REMOTE.methods`,而非动态查询。一元 `mode` 省略;流式 `mode` 为 `'stream'`。`invoke(remote, input, signal?)` 在调用时解析已挂载的命名空间方法,保留其 `this`、可选位置参数空位和 signal。它不使用 async 包装,直接转发同一个完整 `Promise>` 或 `RemoteStreamHandle`;流迭代、`send`、`end` 和 `dispose` 均保留。`TypertRemoteMethod` 的第五个泛型参数独立于结果解析器的原始输出,保留载体解码后的一元字节类型。 + +文档和 JSON Schema 可选。每个不受支持的输入变体或结果投影记录各自的 `jsonSchemaError`,同时保留解析器及独立文档;只捕获专用投影错误。显式选择工具的消费方要求工具说明和受支持的输入 JSON Schema。即使缺少可选输出 JSON Schema,输出解析仍然必需。原生 `dsh-tools` 注册额外要求 `output.schema`:仅有解析器并不满足要求。这些消费方要求不得阻止本来有效的 Remote 方法生成。 ## Proposal @@ -40,9 +42,9 @@ Registry 查询返回基础描述符。纯叶入口或协议根入口的 `isType 框架消费方将普通元数据加载到自己的 Registry。独立消费方可以直接导入 `./remote`,无需 Cordis、Host Service 激活或连接。两者读取同一份生成业务定义。保留独立的 Host/Client Program,并分析每个编译面的所有贡献方,使局部生成保留外部声明合并。注册不会将每个反射方法都暴露为可调用 endpoint 或工具。 -严格源码启动显式预构建,并通过 Loader 加载普通 `./typert`。有限的无 descriptor fallback 仍支持基本参数、signal、取消、流式调用、JSON 转换和一元字节附件,但无法恢复被擦除的字段、约束或工具文档。一旦观察到严格的本地 descriptor,撤销后必须拒绝调用,而不是降级到 fallback。`rejectUnknownFields` 是独立的、仅生成期使用的请求 codec 策略,不是运行时 marker 或工具选择标志。 +严格源码启动显式预构建,并通过 Loader 加载普通 `./typert`。有限的无 descriptor fallback 仍支持基本参数、signal、取消、流式调用、JSON 转换和一元字节附件,但无法恢复被擦除的字段、约束或工具文档。一旦观察到严格的本地 descriptor,撤销后必须拒绝调用,而不是降级到 fallback。`@Remote` 只接受 `name` 和 `mode`;消费方选择输入策略,无需另加开发者注解。 -Host 元数据就绪仍由组合负责。要求严格校验的 Host 必须等待本地注册完成后再接受调用;`hasSeen()` 不会保护首次注册之前的端点。返回惰性 info 实例既不完成异步 Loader 发现,也不建立就绪保证。Loader 校验同样不得仅为注册贡献就强制计算派生输入投影。 +Host 元数据就绪仍由组合负责。要求严格校验的 Host 必须等待本地注册完成后再接受调用;`hasSeen()` 不会保护首次注册之前的端点。返回惰性 info 实例既不完成异步 Loader 发现,也不建立就绪保证。Loader 校验静态 codec spec 及可调用的 `input`/`invoke`,注册贡献时不调用选择器、解析器 factory 或投影。 Connection 拥有认证 HTTP 和物理 WebSocket;Gateway 拥有帧和分发。`forwardedEvents: true` 显式选择 Host 事件转发和交互 waterfall(瀑布式事件)。否则 `$on()` 和由事件监督的 `$stream()` 立即失败,而生成的流式方法仍然可用。导入、注册、列举工具或调用方法不会取得交互回答所有权。Node 钩子由实例拥有,释放必须等待完成。 @@ -53,12 +55,12 @@ Connection 拥有认证 HTTP 和物理 WebSocket;Gateway 拥有帧和分发。 | 消费方 | 元数据适配 | 保留的执行方式 | |---|---|---| | Browser / Node Remote 调用方 | 导入普通 `./remote`;在实际调用 Context 中挂载一次。 | 生成 namespace、Gateway、认证 Connection 和目标 controller。 | -| 离线 CLI 帮助 / 解析 | 在 CLI profile 的元数据 Context 中,于目标发现之前注册。 | 帮助不连接;调用时由独立的实际调用 Context 挂载。 | -| MCP 发现 / 调用 | 读取选中的 Registry 条目;保留消费方自有工具说明和 envelope。 | `tools/list` 离线;`tools/call` 使用相同 Session client。外部 MCP 客户端收到协议 JSON,而不是 Registry 模块。 | +| 离线 CLI 帮助 / 解析 | 在 CLI profile 的元数据 Context 中,于目标发现之前注册;选择 `input({ strict: true })`。 | 帮助不连接;调用时由独立的实际调用 Context 挂载。 | +| MCP 发现 / 调用 | 读取选中的 Registry 条目并选择 `input({ strict: true })`;保留消费方自有工具说明和 envelope。 | `tools/list` 离线;`tools/call` 使用相同 Session client。外部 MCP 客户端收到协议 JSON,而不是 Registry 模块。 | | 原生工具 | 读取 Host 本地方法信息;要求工具注册的 `output.schema`。 | 在工具边界解析,然后直接调用同进程 controller。 | | 独立适配器 | 直接读取类型化 `TYPERT_REMOTE.methods`。 | 仅调用时使用其选择的 Remote 运行时。 | -当前 Session 选择和附件准备仍由消费方拥有;用生成解析器验证补全后的业务参数。下游 `follow` 适配器的 `activate: false` 策略只适用于具有该 API 的版本;当前分支的 `follow` 没有此选项。中止观察不等于取消已接受的 Session 工作。原生直接分发也不会移除无关的业务依赖,例如下游 controller 通过 Gateway 访问待处理交互。 +当前 Session 选择和附件准备仍由消费方拥有;CLI/MCP 输入接收使用选择时的同一严格输入视图验证补全后的业务参数。下游 `follow` 适配器的 `activate: false` 策略只适用于具有该 API 的版本;当前分支的 `follow` 没有此选项。中止观察不等于取消已接受的 Session 工作。原生直接分发也不会移除无关的业务依赖,例如下游 controller 通过 Gateway 访问待处理交互。 ### 新旧接入示例 @@ -77,7 +79,7 @@ page(request: SessionPageRequest, signal: AbortSignal): Promise { // Ordinary delivery; no generated-source import or constructor definitions. // Constructor: super(ctx, 'sessionController', { namespace: 'session' }) -@Remote({ name: 'page', rejectUnknownFields: true }) +@Remote({ name: 'page' }) page(request: SessionPageRequest, signal: AbortSignal): Promise { return this.history.page(request, signal) } @@ -116,13 +118,14 @@ if (page === undefined || !isTypertRemoteMethod(page)) { } ``` -注册属于该插件的 fiber。帮助使用 `description` 和 `input`;类型化 facade 可以保留对普通生成声明的 type-only 导入。显式工具选择不修改已注册元数据。 +注册属于该插件的 fiber。帮助使用 `description` 和 `input({ strict: true })`;类型化 facade 可以保留对普通生成声明的 type-only 导入。显式工具选择不修改已注册元数据。 **CLI 输入与分发。** 本地 `current` 解析和附件准备之后,两种元数据访问方式使用同样的调用步骤: ```ts ignore-check if (page.mode === 'stream') throw new Error('session/page must be unary') -const arguments_ = page.input.create().parse(raw) +const input = page.input({ strict: true }) +const arguments_ = input.create().parse(raw) const result = await page.invoke(remote, arguments_, signal) if (!result.ok) throw result.error const output = page.result.create().parse(result.value) @@ -131,19 +134,20 @@ const output = page.result.create().parse(result.value) **MCP 发现与调用。** 选择时验证工具特有要求;生成过程本身不要求它们。 ```ts ignore-check +const input = page.input({ strict: true }) if (!page.description) throw new Error('session/page has no tool description') -if (page.input.jsonSchema === undefined) throw new Error('session/page has no supported tool input schema') +if (input.jsonSchema === undefined) throw new Error('session/page has no supported tool input schema') const tool = { name: 'remote_session_page', description: page.description, - inputSchema: page.input.jsonSchema, + inputSchema: input.jsonSchema, ...(page.result.jsonSchema === undefined ? {} : { outputSchema: page.result.jsonSchema }), } ``` -这个简化对象只展示元数据依赖。真实 MCP 适配器保留 `{ arguments, current, content }` 输入及 `{ ok, value }` / 失败输出 envelope。列举无需连接;调用执行输入接收、本地准备、生成解析、调用及输出解析。可选输出投影不会移除输出验证。 +这个简化对象只展示元数据依赖。真实 MCP 适配器保留 `{ arguments, current, content }` 输入及 `{ ok, value }` / 失败输出 envelope。列举无需连接;调用执行输入接收、本地准备、通过 `input.create()` 解析、调用及输出解析。可选输出投影不会移除输出验证。 -**原生工具。** 查询 `ctx.typert.local.get('session/page')`,并用 `isTypertRemoteMethod` 收窄,而非读取源码方法表。原生注册要求受支持的输入与结果投影,包括必需的 `output.schema`;MCP 的可选输出投影在这里并不足够。facade 取得类型化请求后,两种设计执行同样的本地调用: +**原生工具。** 查询 `ctx.typert.local.get('session/page')`,并用 `isTypertRemoteMethod` 收窄,而非读取源码方法表。原生工具可以选择 `method.input({ strict: true })`,取得匹配的解析与输入投影。原生注册仍要求受支持的输入与结果投影,包括 core Tool 必需的 `output.schema`;MCP 的可选输出投影在这里并不足够。facade 取得类型化请求后,两种设计执行同样的本地调用: ```ts ignore-check const value = await ctx.sessionController.page(request, signal) @@ -154,7 +158,7 @@ const value = await ctx.sessionController.page(request, signal) ```ts ignore-check import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' const page = TYPERT_REMOTE.methods['session/page'] -const arguments_ = page.input.create().parse(raw) +const arguments_ = page.input().create().parse(raw) const result = await page.invoke(remote, arguments_, signal) ``` @@ -167,7 +171,7 @@ pnpm dsh --profile ### 当前分支上的有限复现 -测试专用 [Session client](../../../../packages/api/remotes/tests/fixtures/session-client/client.ts) 和 [MCP 适配器](../../../../packages/api/remotes/tests/fixtures/session-client/mcp.ts)为当前 `session/page` 与 `session/follow` 提取下游访问方式。它们使用 Registry 元数据、当前 Session 注入、显式拒绝不支持的 content、通用解析器和普通调用。不添加未来 controller 操作、公开 package/bin、另一套应用启动器或完整下游应用。 +测试专用 [Session client](../../../../packages/api/remotes/tests/fixtures/session-client/client.ts) 和 [MCP 适配器](../../../../packages/api/remotes/tests/fixtures/session-client/mcp.ts)为当前 `session/page` 与 `session/follow` 提取下游访问方式。它们使用 Registry 元数据,以 `input({ strict: true })` 同时用于选择与输入接收,并保留当前 Session 注入、显式拒绝不支持的 content 和普通调用。不添加未来 controller 操作、公开 package/bin、另一套应用启动器或完整下游应用。 [产物测试](../../../../packages/api/remotes/tests/session-client.e2e.ts)仅将本地消费方 fixture 对普通声明编译,再使用普通 Node 运行。[运行时 fixture](../../../../packages/api/remotes/tests/fixtures/session-client/run.mjs)将真实 SessionController、活 Session/query 服务、Gateway 和 Connection 通过 HTTP/WebSocket 组合。这是手动组装的有限集成,不是 Loader/profile 组合或完整 CLI/MCP 协议服务器。独立的 Loader 测试使用手写产物 fixture;严格源码 Gateway 测试使用手写普通 contribution。两者都不被表述为生成的完整应用验证。 @@ -183,7 +187,8 @@ pnpm dsh --profile ## Acceptance criteria - 普通产物为每个端点生成一份静态方法 spec,并返回惰性类实例;直接方法索引引用描述符对象,保留完整流式/二进制类型、独立编译面及完整编译面的声明合并。 -- 描述符索引、具名输入 schema 和派生投影仅在请求时物化并缓存结果;Loader 校验不强制计算派生输入投影。 +- 描述符索引与每种输入策略的视图、解析器和投影都独立惰性求值并缓存;Loader 校验静态 spec 及可调用辅助能力,不调用选择器、解析器或投影。 +- 每个输入参数从同一模型生成普通与严格 codec;消费方无需方法注解即可选择策略。两种封装都拒绝额外具名参数;严格输入递归关闭已声明对象,但不关闭 JSON 或字典键,也不改变 Host 分发和结果。 - Registry 身份、原子注册、撤销和释放保持不变;元数据检视和方法调用保持被动。 - 缺少文档或不支持可选投影不阻止生成;选中的工具拒绝缺少必需信息的条目,并仍然解析输出。 - 严格源码/产物调用与有限 fallback 分别测试;撤销严格 descriptor 绝不启用 fallback。 diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index 9b9832b652..bb46fc7d0b 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.i18n.yaml @@ -9,8 +9,8 @@ en: 8725fa62f36364d7 zh: 0f2ff4bdf13ff55b /typert-remote-calls/invocation-descriptors: - en: 47102028a89bcaf3 - zh: 4e1692c92cd7906a + en: 21e88d68154c2c92 + zh: 8c9e05c65ef0f34d /typert-remote-calls/typert-registry: en: 29f578ba733cfd5a zh: c486ff4ac2d1e3b4 diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 461852b7ac..6bea7bf887 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -40,11 +40,11 @@ interface TypertLookupDefinition { An `InvocationDescriptor` is local reflection, not a wire message. Host and consumer builds generate corresponding descriptors; the request sends only the endpoint and named `args`. Strict codecs carry generated schema factories, while SRC codecs enforce JSON-safe inputs without structural type recovery. Unary result codecs can supply `encode()` for byte-containing subtrees and `decode()` for recursive native-byte validation; Client declarations narrow every `Uint8Array` to `ArrayBuffer` backing. Pure JSON results need neither byte detection nor Client decoding. Cancellation is an out-of-band carrier signal injected after business parameters and never enters `args`. -`createTypertRemoteInfo` returns a lazy class instance implementing `TypertRemoteInfo`, which extends the mountable `TypertRemoteContribution`. Its `methods` index aliases the direct objects in `descriptors`: each `TypertRemoteMethod` adds named `input` and `invoke`, retains documentation on the method and its parameters, and uses `result` as its only output codec. Unary `mode` is absent; stream `mode` is `'stream'`. Context-only receivers remain in descriptors and scoped declarations. Registry queries return base descriptors; `isTypertRemoteMethod` narrows generated direct capabilities without recovering endpoint-specific generics. +`createTypertRemoteInfo` returns a lazy class instance implementing `TypertRemoteInfo`, which extends the mountable `TypertRemoteContribution`. Its `methods` index aliases the direct objects in `descriptors`: each `TypertRemoteMethod` adds named `input(options?)` and `invoke`, retains documentation on the method and its parameters, and uses `result` as its only output codec. Unary `mode` is absent; stream `mode` is `'stream'`. Context-only receivers remain in descriptors and scoped declarations. Registry queries return base descriptors; `isTypertRemoteMethod` narrows generated direct capabilities without recovering endpoint-specific generics. -`TypertSchema` guarantees parsing, not arbitrary Zod operations, although the raw factory spec requires Zod factories. Missing documentation or an unsupported optional JSON Schema projection does not invalidate an ordinary method. The info index, named-input schema, and derived projections are cached at their respective first accesses; no metadata or schema access invokes the network. The [protocol reference](<../../packages/typert/protocol/README.md>) defines these APIs, and the [generator reference](<../../packages/typert/generator/README.md#ordinary-remote-metadata>) defines their separate Host `./typert`, consumer `./remote`, and Client `./client/typert` artifacts. +`TypertSchema` guarantees parsing, not arbitrary Zod operations, although the raw factory spec requires Zod factories. Missing documentation or an unsupported optional JSON Schema projection does not invalidate an ordinary method. The info index and each input-policy view, parser, and derived projection are independently lazy and cached; no metadata or schema access invokes the network. The [protocol reference](<../../packages/typert/protocol/README.md>) defines these APIs, and the [generator reference](<../../packages/typert/generator/README.md#ordinary-remote-metadata>) defines their separate Host `./typert`, consumer `./remote`, and Client `./client/typert` artifacts. -`TypertRemoteSpec` holds a package and endpoint-keyed `TypertRemoteMethodSpec` values. Ordered `TypertRemoteParameterSpec` entries contain strict `TypertRemoteCodecSpec` factories; the runtime derives identities, wire-name defaults, and named-call views from these facts. `TypertRemoteValue` exposes parsing and optional documentation/projection, while `RemoteDecoded` describes carrier-reconstructed unary results without narrowing arbitrary parser outputs. +`TypertRemoteSpec` holds a package and endpoint-keyed `TypertRemoteMethodSpec` values. Each ordered `TypertRemoteParameterSpec` contains normal `codec` and `strictCodec` factories from the same checker-resolved type model; the runtime derives identities, wire-name defaults, and named-call views. `TypertRemoteInputOptions` has only `strict?: boolean`; `method.input(options?)` selects a cached `TypertRemoteValue` with matching parsing and optional documentation/projection, without changing Host dispatch or `result`. `RemoteDecoded` describes carrier-reconstructed unary results without narrowing arbitrary parser outputs. ```ts type-equiv /** Codec attached to one invocation parameter or result. */ diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index d3ace78010..72cba0f04c 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -40,11 +40,11 @@ interface TypertLookupDefinition { `InvocationDescriptor` 是本地反射信息,不是 wire message。Host 与消费方构建会生成彼此对应的 descriptor;请求只发送 endpoint 与具名 `args`。strict codec 携带生成的 schema factory,SRC codec 则在不恢复结构类型的前提下强制要求输入为 JSON 安全值。一元结果 codec 可提供 `encode()` 处理含字节的子树,并通过 `decode()` 递归校验原生字节;Client 声明将每个 `Uint8Array` 限定为以 `ArrayBuffer` 为底层缓冲区。纯 JSON 结果无需字节识别或 Client 解码。取消通过带外 carrier signal 表达:它在业务参数之后注入,绝不进入 `args`。 -`createTypertRemoteInfo` 返回实现 `TypertRemoteInfo` 的惰性类实例,后者扩展可挂载的 `TypertRemoteContribution`。其 `methods` 索引引用 `descriptors` 中的直接方法对象:每个 `TypertRemoteMethod` 增加具名 `input` 和 `invoke`,在方法及参数上保留文档,并以 `result` 作为唯一输出 codec。一元 `mode` 缺省;流式 `mode` 为 `'stream'`。仅使用 Context 接收器的方法保留在描述符和作用域声明中。Registry 查询返回基础描述符;`isTypertRemoteMethod` 收窄生成的直接调用能力,但不会恢复各端点的精确泛型。 +`createTypertRemoteInfo` 返回实现 `TypertRemoteInfo` 的惰性类实例,后者扩展可挂载的 `TypertRemoteContribution`。其 `methods` 索引引用 `descriptors` 中的直接方法对象:每个 `TypertRemoteMethod` 增加具名 `input(options?)` 和 `invoke`,在方法及参数上保留文档,并以 `result` 作为唯一输出 codec。一元 `mode` 缺省;流式 `mode` 为 `'stream'`。仅使用 Context 接收器的方法保留在描述符和作用域声明中。Registry 查询返回基础描述符;`isTypertRemoteMethod` 收窄生成的直接调用能力,但不会恢复各端点的精确泛型。 -`TypertSchema` 保证解析能力,不保证任意 Zod 操作,尽管原始 factory spec 要求 Zod factory。缺少文档或不支持可选的 JSON Schema 投影,不会使普通方法失效。info 索引、具名输入 schema 和派生投影分别在首次访问时缓存;读取元数据或 schema 绝不发起网络调用。[协议参考](<../../packages/typert/protocol/README.zh.md>)定义这些 API,[生成器参考](<../../packages/typert/generator/README.zh.md#ordinary-remote-metadata>)定义它们彼此分离的 Host `./typert`、消费方 `./remote` 和 Client `./client/typert` 产物。 +`TypertSchema` 保证解析能力,不保证任意 Zod 操作,尽管原始 factory spec 要求 Zod factory。缺少文档或不支持可选的 JSON Schema 投影,不会使普通方法失效。info 索引与每种输入策略的视图、解析器和派生投影都独立惰性求值并缓存;读取元数据或 schema 绝不发起网络调用。[协议参考](<../../packages/typert/protocol/README.zh.md>)定义这些 API,[生成器参考](<../../packages/typert/generator/README.zh.md#ordinary-remote-metadata>)定义它们彼此分离的 Host `./typert`、消费方 `./remote` 和 Client `./client/typert` 产物。 -`TypertRemoteSpec` 保存包名与按端点键控的 `TypertRemoteMethodSpec`。有序的 `TypertRemoteParameterSpec` 条目包含严格的 `TypertRemoteCodecSpec` factory;运行时从这些事实派生标识、默认 wire 名称和具名调用视图。`TypertRemoteValue` 提供解析及可选文档/投影,`RemoteDecoded` 则描述 carrier 重建的一元结果,不会收窄任意解析器输出。 +`TypertRemoteSpec` 保存包名与按端点键控的 `TypertRemoteMethodSpec`。每个有序 `TypertRemoteParameterSpec` 条目都包含从同一 checker 解析后类型模型生成的普通 `codec` 和 `strictCodec` factory;运行时派生标识、默认 wire 名称和具名调用视图。`TypertRemoteInputOptions` 仅包含 `strict?: boolean`;`method.input(options?)` 选择缓存的 `TypertRemoteValue`,提供匹配的解析及可选文档/投影,而不改变 Host 分发或 `result`。`RemoteDecoded` 则描述 carrier 重建的一元结果,不会收窄任意解析器输出。 ```ts type-equiv /** Codec attached to one invocation parameter or result. */ diff --git a/packages/api/gateway/tests/strict-source.host.spec.ts b/packages/api/gateway/tests/strict-source.host.spec.ts index dc0d654838..2bd9c35a17 100644 --- a/packages/api/gateway/tests/strict-source.host.spec.ts +++ b/packages/api/gateway/tests/strict-source.host.spec.ts @@ -28,7 +28,7 @@ class ItemsService extends Service { super(ctx, 'items') this.typertRemote = bindTypertRemote(this, 'items') } - @Remote({ rejectUnknownFields: true }) + @Remote read(request: { title: string }): string { this.calls++ return request.title diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index 31efa03564..2051c31982 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/README.i18n.yaml @@ -21,8 +21,8 @@ en: d6d82bf0e55ae5e9 zh: c3a0c80f8b115039 /deepseek-ai-dsh-api-remotes/build-boundary: - en: bbce545687d036b9 - zh: db1a6edf3508e9bb + en: 17f0dfe5163dba43 + zh: 1f33dcb4a3b1df76 /deepseek-ai-dsh-api-remotes/model-experience: en: 6a60ae036c71e76f zh: bf5e86a5ac5b4be4 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index 99f9cbe4d9..5b8639feea 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -59,7 +59,7 @@ That exception is not just a `files` entry. The root `tsconfig.base.json` maps ` The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; split only when the two source sets require different compiler faces. -The [Session consumer artifact test](tests/session-client.e2e.ts) independently compiles a test-local Registry/CLI/MCP adaptation against ordinary declarations, then runs plain Node with real SessionController HTTP/WebSocket calls. It covers current `session/page` and `session/follow`, offline tool selection, input rejection, current-session injection, cancellation, and disposal. Its manual Context composition is not a supported application entry or a full downstream Loader/profile integration; unavailable attachment preparation rejects explicitly. +The [Session consumer artifact test](tests/session-client.e2e.ts) independently compiles a test-local Registry/CLI/MCP adaptation against ordinary declarations, then runs plain Node with real SessionController HTTP/WebSocket calls. Its CLI/MCP selection and intake both use `method.input({ strict: true })`, pairing JSON Schema with the same view's parser without tightening Host dispatch. It covers current `session/page` and `session/follow`, offline tool selection, input rejection, current-session injection, cancellation, and disposal. Its manual Context composition is not a supported application entry or a full downstream Loader/profile integration; unavailable attachment preparation rejects explicitly. ## Model Experience diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index 71a5ea30e6..c88f1fc378 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -59,7 +59,7 @@ Host entry 为每条 Client 流独立注册一组 allowlist listener 和一个 包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project,并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle;只有两组源码需要不同 compiler face 时才拆分。 -[Session 消费方产物测试](tests/session-client.e2e.ts)将测试专用 Registry/CLI/MCP 适配对普通声明独立编译,再使用普通 Node 发起真实 SessionController HTTP/WebSocket 调用。它覆盖当前 `session/page` 和 `session/follow`、离线工具选择、输入拒绝、当前 Session 注入、取消及释放。其手动 Context 组合不是受支持的应用入口,也不是完整下游 Loader/profile 集成;不可用的附件准备会显式拒绝。 +[Session 消费方产物测试](tests/session-client.e2e.ts)将测试专用 Registry/CLI/MCP 适配对普通声明独立编译,再使用普通 Node 发起真实 SessionController HTTP/WebSocket 调用。其 CLI/MCP 选择与输入接收均使用 `method.input({ strict: true })`,将 JSON Schema 与同一视图的解析器配套使用,而不收紧 Host 分发。它覆盖当前 `session/page` 和 `session/follow`、离线工具选择、输入拒绝、当前 Session 注入、取消及释放。其手动 Context 组合不是受支持的应用入口,也不是完整下游 Loader/profile 集成;不可用的附件准备会显式拒绝。 ## 模型体验 diff --git a/packages/api/remotes/tests/fixtures/session-client/client.ts b/packages/api/remotes/tests/fixtures/session-client/client.ts index 5ec9786c2e..3f40de67bc 100644 --- a/packages/api/remotes/tests/fixtures/session-client/client.ts +++ b/packages/api/remotes/tests/fixtures/session-client/client.ts @@ -47,14 +47,15 @@ export function sessionOperations(registry: TypertRemoteRegistry) { if (!descriptor.description || !descriptor.parameters.find(parameter => parameter.wire === 'request')?.description) { throw new Error(`Selected tool ${endpoint} requires method and request documentation`) } - if (descriptor.input.jsonSchema === undefined) { - throw new Error(`Selected tool ${endpoint} requires an input JSON Schema: ${descriptor.input.jsonSchemaError ?? 'unavailable'}`) + const input = descriptor.input({ strict: true }) + if (input.jsonSchema === undefined) { + throw new Error(`Selected tool ${endpoint} requires an input JSON Schema: ${input.jsonSchemaError ?? 'unavailable'}`) } return descriptor } function inputSchema(endpoint: Endpoint): Readonly> { - const schema = method(endpoint).input.jsonSchema + const schema = method(endpoint).input({ strict: true }).jsonSchema const branches = [false, true].map((current) => { const argumentsSchema = structuredClone(object(schema)) if (current) { @@ -88,7 +89,7 @@ export function sessionOperations(registry: TypertRemoteRegistry) { if (!callerSessionId) throw new Error('current requires caller Session identity') args.request = { ...object(args.request), address: { kind: 'session', sessionId: callerSessionId } } } - return method(endpoint).input.create().parse(args) + return method(endpoint).input({ strict: true }).create().parse(args) } return { method, inputSchema, prepare, list: () => selectedEndpoints.map(endpoint => ({ endpoint, method: method(endpoint) })) } diff --git a/packages/api/remotes/tests/fixtures/session-client/run.mjs b/packages/api/remotes/tests/fixtures/session-client/run.mjs index 32629c3923..b60b99dd13 100644 --- a/packages/api/remotes/tests/fixtures/session-client/run.mjs +++ b/packages/api/remotes/tests/fixtures/session-client/run.mjs @@ -60,10 +60,13 @@ try { assert.equal('metadata' in method, false) assert.ok(method.description) assert.ok(method.parameters.find(parameter => parameter.wire === 'request').description) - assert.equal(typeof method.input.create().parse, 'function') + assert.equal(typeof method.input({ strict: true }).create().parse, 'function') assert.equal(typeof method.result.create().parse, 'function') } - const originalSchema = structuredClone(TYPERT_REMOTE.methods['session/page'].input.jsonSchema) + const normalSchema = TYPERT_REMOTE.methods['session/page'].input().jsonSchema + assert.equal(normalSchema.properties.request.additionalProperties, true) + const originalSchema = structuredClone(TYPERT_REMOTE.methods['session/page'].input({ strict: true }).jsonSchema) + assert.equal(originalSchema.properties.request.additionalProperties, false) const tool = pageTool(operations) assert.equal(tool.name, 'remote_session_page') assert.equal(tool.description, 'Read Session or subagent history without starting it. Records are oldest first. Use the opening follow cursor as throughSeq and the first record\'s sequence as beforeSeq to read older history when hasMore is true. maxMessages limits complete messages; turnWindow requests minimum messages and turns within that limit. To target the calling Session, set current: true and omit its target from arguments.request. This requires caller identity from the selected server.') @@ -72,7 +75,7 @@ try { const inputOnly = mcpSchemas(operations.inputSchema('session/page')) assert.equal('outputSchema' in inputOnly, false) assert.deepEqual(inputOnly.inputSchema, tool.inputSchema) - assert.deepEqual(TYPERT_REMOTE.methods['session/page'].input.jsonSchema, originalSchema) + assert.deepEqual(TYPERT_REMOTE.methods['session/page'].input({ strict: true }).jsonSchema, originalSchema) assert.equal(counters.http + counters.sockets, 0) assert.throws(() => operations.prepare('session/page', { arguments: { request: { throughSeq: -1 } }, current: true }), /caller Session identity/) assert.throws(() => operations.prepare('session/page', { arguments: {}, content: [] }), /does not accept local content/) @@ -176,9 +179,16 @@ try { assert.deepEqual(await calls.call('session/page', local), direct.value) assert.equal(counters.http, 2) assert.equal(counters.sockets, 0) + const extraArguments = { request: { ...args.request, maxMesssages: 1 } } + const ordinary = await TYPERT_REMOTE.methods['session/page'].invoke(client.remote, extraArguments) + assert.equal(ordinary.ok, true, 'consumer strictness must not tighten ordinary Host dispatch') + assert.deepEqual(ordinary.value, direct.value) const malformed = await TYPERT_REMOTE.methods['session/page'].invoke(client.remote, { request: { address, throughSeq: 'bad' } }) assert.equal(malformed.ok, false) const before = counters.http + const misspelled = await callPageTool(calls, { arguments: extraArguments }) + assert.equal(misspelled.isError, true) + assert.equal(counters.http, before, 'strict CLI/MCP intake rejects unknown fields before HTTP') const invalid = await callPageTool(calls, { arguments: { request: { throughSeq: 'bad' } }, current: true }) assert.equal(invalid.isError, true) assert.equal(invalid.structuredContent.ok, false) diff --git a/packages/api/remotes/tests/fixtures/session-client/typing.ts b/packages/api/remotes/tests/fixtures/session-client/typing.ts index 991ffee348..867b8a45c6 100644 --- a/packages/api/remotes/tests/fixtures/session-client/typing.ts +++ b/packages/api/remotes/tests/fixtures/session-client/typing.ts @@ -10,6 +10,11 @@ declare const output: PageOutput const unary: Promise> = TYPERT_REMOTE.methods['session/page'].invoke(remote, input) const more: boolean = output.hasMore const records: readonly object[] = output.records +const strictInput: PageArguments = TYPERT_REMOTE.methods['session/page'].input({ strict: true }).create().parse({}) +const normalInput: PageArguments = TYPERT_REMOTE.methods['session/page'].input().create().parse({}) +// @ts-expect-error Schema strictness is a consumer boolean, not an arbitrary policy string. +TYPERT_REMOTE.methods['session/page'].input({ strict: 'yes' }) +void [strictInput, normalInput] // @ts-expect-error Page arguments retain the generated request fields. void TYPERT_REMOTE.methods['session/page'].invoke(remote, { request: { throughSeq: 'bad' } }) // @ts-expect-error Page output has no cursor; the follow snapshot supplies it. diff --git a/packages/typert/generator/README.i18n.yaml b/packages/typert/generator/README.i18n.yaml index a293579d0d..9a763d4ae6 100644 --- a/packages/typert/generator/README.i18n.yaml +++ b/packages/typert/generator/README.i18n.yaml @@ -21,8 +21,8 @@ en: c7078becc8f12609 zh: 4b8838b559ecb677 /deepseek-ai-dsh-typert-generator/use-this-package/ordinary-remote-metadata: - en: 54db44190806bcca - zh: a85281e2dfadb7c2 + en: 306e8835ee10e704 + zh: f8ad8f08e1316b16 /deepseek-ai-dsh-typert-generator/use-this-package/analyzing-a-workspace-statically: en: 8227097859169cdf zh: 249790ed62e8558a diff --git a/packages/typert/generator/README.md b/packages/typert/generator/README.md index 92ba7beb3d..84af4f2e53 100644 --- a/packages/typert/generator/README.md +++ b/packages/typert/generator/README.md @@ -46,15 +46,15 @@ After the build, `lib/typert.host.js` and `lib/typert.host.d.ts` exist and the [ ### Ordinary Remote metadata -The emitter writes one static `TypertRemoteMethodSpec` per endpoint and calls the pure `createTypertRemoteInfo` factory. Ordinary consumer `./remote` artifacts retain their default export and `TYPERT_REMOTE`, typed as `TypertRemoteInfo` with precise `TypertRemoteMethod` entries in `TYPERT_REMOTE.methods`. Each entry is the same object as its direct descriptor in `TYPERT_REMOTE.descriptors`; Context-only receivers remain in descriptors and scoped declarations. There is no separate `REMOTE_METHODS` export, nested `metadata`, or second `output` codec. The [protocol](<../protocol/README.md>) owns the factory and method API. +The emitter writes one static `TypertRemoteMethodSpec` per endpoint and calls the pure `createTypertRemoteInfo` factory. Ordinary consumer `./remote` artifacts retain their default export and `TYPERT_REMOTE`, typed as `TypertRemoteInfo` with precise `TypertRemoteMethod` entries in `TYPERT_REMOTE.methods`. Each entry is the same object as its direct descriptor in `TYPERT_REMOTE.descriptors`; Context-only receivers remain in descriptors and scoped declarations. There is no separate `REMOTE_METHODS` export, nested `metadata`, or second `output` codec. The [protocol](<../protocol/README.md>) owns the factory and method API. -The factory returns a class instance that retains the spec until its `descriptors` or `methods` getter builds their shared index. Named input schemas and projections are cached on demand, and generated individual Zod factories also materialize and cache on first use. Ordinary Host `./typert` builds a private info instance whose descriptors are exposed by the `TYPERT.invocations` getter. Host and consumer artifacts share definitions, not object identity across Programs or processes. +The factory returns a class instance that retains the spec until its `descriptors` or `methods` getter builds their shared index. Each consumer-selected input-policy view, parser, and projection is independently lazy and cached, and generated individual Zod factories also materialize and cache on first use. Ordinary Host `./typert` builds a private info instance whose descriptors are exposed by the `TYPERT.invocations` getter. Host and consumer artifacts share definitions, not object identity across Programs or processes. Generated declarations use type-only imports for owner and schema types. Importing metadata neither activates the Host Service nor opens a network connection. Framework consumers query the existing [registry](<../registry/README.md>); standalone consumers can import `./remote` directly. Source compositions requiring strict validation prebuild ordinary artifacts and register `./typert` through the [loader](<../loader/README.md>); descriptor-free source discovery cannot recover erased type constraints, documentation, or schemas. -Missing JSDoc and unsupported tool JSON Schema do not block otherwise valid ordinary generation or its strict codecs. Metadata retains parsers and available documentation independently of projection: an unsupported input or output projection omits `jsonSchema` and records `jsonSchemaError`. A tool consumer explicitly selects methods and requires its documentation and supported input schema; an output parser is required, but output JSON Schema is optional unless that consumer requires it. Metadata alone does not make every Remote method a tool. +Missing JSDoc and unsupported tool JSON Schema do not block otherwise valid ordinary generation or its strict codecs. Metadata retains parsers and available documentation independently of projection: each unsupported input variant or output projection omits `jsonSchema` and records its own `jsonSchemaError`. A tool consumer explicitly selects methods and requires its documentation and supported input schema; an output parser is required, but output JSON Schema is optional unless that consumer requires it. Metadata alone does not make every Remote method a tool. -`@Remote({ rejectUnknownFields: true })` independently closes declared request objects in generated codecs and supported input JSON Schemas unless a declaration explicitly contains an index signature. Other methods and result objects retain their existing policy; every metadata input parser and supported input JSON Schema closes the top-level named-argument object. Tool projection supports scalars, objects, arrays, and disjoint unions; canonical `JsonValue` and string-keyed JSON dictionaries need no recursive references. Numeric bounds and other semantic rules absent from TypeScript remain with the owning operation. +Every generated parameter spec contains both `codec` and `strictCodec`, derived from the same checker-resolved type model; the analyzer and model carry no per-method input policy. `method.input()` and `{ strict: false }` select the normal codec used by Host dispatch, whose declared objects accept and strip unknown fields. `method.input({ strict: true })` selects recursively closed declared objects for both parsing and supported JSON Schema. Both named-argument envelopes reject extra arguments; explicit JSON and dictionary keys remain open, while constrained dictionary values still validate. `result` keeps its single normal codec. Tool projection supports scalars, objects, arrays, and disjoint unions; canonical `JsonValue` and string-keyed JSON dictionaries need no recursive references. Numeric bounds and other semantic rules absent from TypeScript remain with the owning operation. `WorkspaceTypertGenerator` analyzes all contributors of each selected face before filtering emitted packages, so a package subset retains the same declaration merges and output bytes as a complete build. diff --git a/packages/typert/generator/README.zh.md b/packages/typert/generator/README.zh.md index a74fbeb830..8fbbe1084b 100644 --- a/packages/typert/generator/README.zh.md +++ b/packages/typert/generator/README.zh.md @@ -46,15 +46,15 @@ files: ### 普通 Remote 元数据 -发射器为每个端点写入一份静态 `TypertRemoteMethodSpec`,并调用纯 `createTypertRemoteInfo` factory。普通消费方 `./remote` 产物保留默认导出和 `TYPERT_REMOTE`,其类型为 `TypertRemoteInfo`,`TYPERT_REMOTE.methods` 中的条目具有精确的 `TypertRemoteMethod` 类型。每个条目就是 `TYPERT_REMOTE.descriptors` 中同一个直接方法描述符对象;仅使用 Context 接收器的方法保留在描述符和作用域声明中。不存在独立的 `REMOTE_METHODS` 导出、嵌套 `metadata` 或第二份 `output` codec。[协议](<../protocol/README.zh.md>)负责 factory 和方法 API。 +发射器为每个端点写入一份静态 `TypertRemoteMethodSpec`,并调用纯 `createTypertRemoteInfo` factory。普通消费方 `./remote` 产物保留默认导出和 `TYPERT_REMOTE`,其类型为 `TypertRemoteInfo`,`TYPERT_REMOTE.methods` 中的条目具有精确的 `TypertRemoteMethod` 类型。每个条目就是 `TYPERT_REMOTE.descriptors` 中同一个直接方法描述符对象;仅使用 Context 接收器的方法保留在描述符和作用域声明中。不存在独立的 `REMOTE_METHODS` 导出、嵌套 `metadata` 或第二份 `output` codec。[协议](<../protocol/README.zh.md>)负责 factory 和方法 API。 -factory 返回类实例,并保留 spec,直到 `descriptors` 或 `methods` getter 构建两者共享的索引。具名输入 schema 和投影按需缓存,生成的独立 Zod factory 同样在首次使用时物化并缓存。普通 Host `./typert` 创建私有 info 实例,由 `TYPERT.invocations` getter 暴露其描述符。Host 与消费方产物共享定义,不承诺跨 Program 或进程的对象身份。 +factory 返回类实例,并保留 spec,直到 `descriptors` 或 `methods` getter 构建两者共享的索引。消费方选择的每种输入策略视图、解析器和投影都独立惰性求值并缓存,生成的独立 Zod factory 同样在首次使用时物化并缓存。普通 Host `./typert` 创建私有 info 实例,由 `TYPERT.invocations` getter 暴露其描述符。Host 与消费方产物共享定义,不承诺跨 Program 或进程的对象身份。 生成的声明对拥有者类型和 schema 类型使用仅类型导入。导入元数据既不会激活 Host Service,也不会打开网络连接。框架内消费方查询现有[注册表](<../registry/README.zh.md>);独立消费方可以直接导入 `./remote`。要求严格校验的源码组合预先构建普通产物,并通过 [loader](<../loader/README.zh.md>) 注册 `./typert`;无描述符的源码发现无法恢复已擦除的类型约束、文档或 schema。 -缺少 JSDoc 或不支持工具 JSON Schema,不会阻止其他方面有效的普通生成及其严格 codec。元数据独立于投影保留解析器与可用文档:不支持的输入或输出投影省略 `jsonSchema`,并记录 `jsonSchemaError`。工具消费方显式选择方法,并要求其所需文档和受支持的输入 schema;输出解析器是必需的,但输出 JSON Schema 是可选的,除非该消费方要求提供。仅有元数据不会让每个 Remote 方法自动成为工具。 +缺少 JSDoc 或不支持工具 JSON Schema,不会阻止其他方面有效的普通生成及其严格 codec。元数据独立于投影保留解析器与可用文档:每个不受支持的输入变体或输出投影都省略 `jsonSchema`,并记录各自的 `jsonSchemaError`。工具消费方显式选择方法,并要求其所需文档和受支持的输入 schema;输出解析器是必需的,但输出 JSON Schema 是可选的,除非该消费方要求提供。仅有元数据不会让每个 Remote 方法自动成为工具。 -`@Remote({ rejectUnknownFields: true })` 独立地在生成的 codec 与受支持的输入 JSON Schema 中关闭已声明请求对象,除非声明显式包含索引签名。其他方法与结果对象保留现有策略;每个元数据输入解析器与受支持的输入 JSON Schema 都关闭顶层命名参数对象。工具投影支持标量、对象、数组和互斥联合类型;规范的 `JsonValue` 与字符串键 JSON 字典无需递归引用。TypeScript 未表达的数值范围与其他语义规则仍由操作拥有者负责。 +每个生成参数 spec 都包含从同一个 checker 解析后类型模型派生的 `codec` 和 `strictCodec`;分析器与模型不携带逐方法输入策略。`method.input()` 与 `{ strict: false }` 选择 Host 分发使用的普通 codec,其已声明对象接受并剥离未知字段。`method.input({ strict: true })` 为解析和受支持的 JSON Schema 选择递归关闭的已声明对象。两种具名参数封装都拒绝额外参数;显式 JSON 与字典键保持开放,有约束的字典值仍然校验。`result` 保留唯一的普通 codec。工具投影支持标量、对象、数组和互斥联合类型;规范的 `JsonValue` 与字符串键 JSON 字典无需递归引用。TypeScript 未表达的数值范围与其他语义规则仍由操作拥有者负责。 `WorkspaceTypertGenerator` 先分析每个选中 face 的所有贡献方,再筛选要生成产物的包,因此包子集保留与完整构建相同的声明合并结果和输出字节。 diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 885a1fef5a..21b3cf58fc 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -678,7 +678,6 @@ interface DirectRemoteMarker { readonly kind: 'direct' readonly exportName?: string readonly mode?: 'stream' - readonly rejectUnknownFields?: true } class FaceAnalyzer { @@ -1282,7 +1281,6 @@ class FaceAnalyzer { } return { ...documentation, - ...(invocation.kind === 'direct' && invocation.rejectUnknownFields ? { rejectUnknownFields: true as const } : {}), id: `${registration.name}#${binding.namespace}/${exportedMethod}`, service: binding.service, namespace: binding.namespace, @@ -1419,20 +1417,18 @@ class FaceAnalyzer { marker = { kind: 'direct', exportName } } else { if (!ts.isObjectLiteralExpression(argument) || argument.properties.length === 0) { - this.fail(argument, 'Remote() options require name, mode: "stream", or rejectUnknownFields: true') + this.fail(argument, 'Remote() options require name or mode: "stream"') } marker = { kind: 'direct' } for (const property of argument.properties) { if (!ts.isPropertyAssignment(property)) this.fail(property, 'Remote() options must use named values') const name = memberName(property.name) - if (name === 'rejectUnknownFields' && property.initializer.kind === ts.SyntaxKind.TrueKeyword) { - marker = { ...marker, rejectUnknownFields: true } - } else if (name === 'mode' && stringLiteralValue(property.initializer) === 'stream') marker = { ...marker, mode: 'stream' } + if (name === 'mode' && stringLiteralValue(property.initializer) === 'stream') marker = { ...marker, mode: 'stream' } else if (name === 'name') { const value = stringLiteralValue(property.initializer) if (value === undefined || !isRemoteSegment(value)) this.fail(property, 'Remote() name must be an endpoint segment') marker = { ...marker, exportName: value } - } else this.fail(property, 'Remote() options require name, mode: "stream", or rejectUnknownFields: true') + } else this.fail(property, 'Remote() options require name or mode: "stream"') } } } else if (ts.isCallExpression(expression) diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index ccd080e36b..e3b348d5d3 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -332,14 +332,16 @@ export class FaceModelEmitter { lines.push(` source: ${quote(parameter.source)},`) if (parameter.lookup !== undefined) lines.push(` lookup: ${quote(parameter.lookup)},`) if (parameter.boundary.acceptsUndefined) lines.push(' acceptsUndefined: true,') - lines.push(` codec: ${indent(strictCodec( - parameter.boundary, - schemas.boundary(parameterBoundaryKey(invocation, index)), - undefined, - toolProjection(() => remoteJsonSchema( - this.renderer, parameter.boundary.codecType, invocation.rejectUnknownFields === true, parameter.boundary.acceptsUndefined, - )), - ), 6).trimStart()},`) + for (const strict of [false, true]) { + lines.push(` ${strict ? 'strictCodec' : 'codec'}: ${indent(strictCodec( + parameter.boundary, + schemas.boundary(parameterBoundaryKey(invocation, index, strict)), + undefined, + toolProjection(() => remoteJsonSchema( + this.renderer, parameter.boundary.codecType, strict, parameter.boundary.acceptsUndefined, + )), + ), 6).trimStart()},`) + } lines.push(' },') }) lines.push(' ],') @@ -576,9 +578,10 @@ function toolProjection(project: () => Record): string { function invocationSchemas( renderer: TypeGraphRenderer, schemas: readonly SchemaModel[], roots: readonly BoundarySchemaRoot[], ): SchemaArtifact { - const ordinary = new SchemaEmitter(renderer, schemas, roots.filter(root => !root.closedObjects)).emit() + const identifiers = new Set() + const ordinary = new SchemaEmitter(renderer, schemas, roots.filter(root => !root.closedObjects), {}, identifiers).emit() const closedRoots = roots.filter(root => root.closedObjects) - const closed = new SchemaEmitter(renderer, [], closedRoots, { closedObjects: true }).emit() + const closed = new SchemaEmitter(renderer, [], closedRoots, { closedObjects: true }, identifiers).emit() const keys = new Set(closedRoots.map(root => root.key)) return { definitions: [...ordinary.definitions, ...closed.definitions], exports: ordinary.exports, boundary: key => keys.has(key) ? closed.boundary(key) : ordinary.boundary(key), @@ -708,6 +711,7 @@ class SchemaEmitter { private readonly schemas: readonly SchemaModel[], private readonly boundaries: readonly BoundarySchemaRoot[], private readonly options: { readonly closedObjects?: boolean } = {}, + identifiers = new Set(), ) { const declarations = new Map() for (const schema of schemas) { @@ -721,7 +725,6 @@ class SchemaEmitter { } } this.declarations = renderer.graph.declarations.filter(declaration => declarations.has(declaration.id)) - const identifiers = new Set() for (const declaration of this.declarations) { const base = `${options.closedObjects ? 'closed$' : ''}${safeIdentifier(declaration.name)}$schema` let name = base @@ -936,6 +939,7 @@ class SchemaEmitter { ): string { const properties: string[] = [] const indices: string[] = [] + let stringIndexValue: string | undefined let symbolMembers = 0 for (const member of members) { if (member.static || member.visibility !== 'public') continue @@ -951,10 +955,10 @@ class SchemaEmitter { if (member.signature.parameters.length !== 1 || parameter === undefined) { this.fail(subject, 'index signature must have exactly one key parameter') } - indices.push(this.readonly( - `z.record(${this.typeSchema(parameter.type, substitutions)}, ${this.typeSchema(member.signature.returns, substitutions)})`, - member.readonly, - )) + const key = this.typeSchema(parameter.type, substitutions) + const value = this.typeSchema(member.signature.returns, substitutions) + if (key === 'z.string()') stringIndexValue = value + indices.push(this.readonly(`z.record(${key}, ${value})`, member.readonly)) continue } if (member.kind !== 'property') this.fail(subject, `${member.kind} member ${member.name} is not data-schema projectable`) @@ -974,6 +978,8 @@ class SchemaEmitter { const index = indices[0] if (index === undefined) return object if (properties.length === 0) return index + // Declared properties can narrow index values; only additional keys use the index schema. + if (this.options.closedObjects && stringIndexValue !== undefined) return `${object}.catchall(${stringIndexValue})` return `z.intersection(${object}, ${index})` } @@ -1052,9 +1058,8 @@ function invocationBoundaryRoots(invocations: readonly InvocationModel[]): Bound result.push({ key: contextBoundaryKey(invocation), type: invocation.invocation.boundary.codecType }) } invocation.parameters.forEach((parameter, index) => { - result.push({ key: parameterBoundaryKey(invocation, index), type: parameter.boundary.codecType, - ...(invocation.rejectUnknownFields ? { closedObjects: true as const } : {}), - }) + result.push({ key: parameterBoundaryKey(invocation, index), type: parameter.boundary.codecType }) + result.push({ key: parameterBoundaryKey(invocation, index, true), type: parameter.boundary.codecType, closedObjects: true }) }) if (invocation.uplink !== undefined) { result.push({ key: uplinkBoundaryKey(invocation), type: invocation.uplink.boundary.codecType }) @@ -1072,8 +1077,8 @@ function uplinkBoundaryKey(invocation: InvocationModel): string { return `${invocation.id}:uplink` } -function parameterBoundaryKey(invocation: InvocationModel, index: number): string { - return `${invocation.id}:parameter:${String(index)}` +function parameterBoundaryKey(invocation: InvocationModel, index: number, strict = false): string { + return `${invocation.id}:parameter:${String(index)}${strict ? ':strict' : ''}` } function resultBoundaryKey(invocation: InvocationModel): string { diff --git a/packages/typert/generator/src/model.ts b/packages/typert/generator/src/model.ts index 035248a61a..31b32a4e42 100644 --- a/packages/typert/generator/src/model.ts +++ b/packages/typert/generator/src/model.ts @@ -133,8 +133,6 @@ export interface InvocationModel extends DocumentationModel { readonly method: string readonly implementation?: string readonly mode?: 'stream' - /** Reject unknown fields in declared request objects, independently of consumer selection. */ - readonly rejectUnknownFields?: true readonly invocation: | { readonly kind: 'direct' } | { diff --git a/packages/typert/generator/src/remote-json-schema.ts b/packages/typert/generator/src/remote-json-schema.ts index 1c60e9ed59..6ce8066f24 100644 --- a/packages/typert/generator/src/remote-json-schema.ts +++ b/packages/typert/generator/src/remote-json-schema.ts @@ -24,12 +24,12 @@ interface Schema extends Record { * Project one resolved Remote type without weakening unsupported constraints. * @param renderer - complete analyzed type graph. * @param type - resolved wire type. - * @param rejectUnknownFields - close declared request objects with `additionalProperties: false`. + * @param closedObjects - close declared request objects with `additionalProperties: false`. * @param optional - omit a top-level undefined alternative supplied by an optional argument. * @returns an acyclic schema in the command/tool subset. */ export function remoteJsonSchema( - renderer: TypeGraphRenderer, type: TypeNodeId, rejectUnknownFields = false, optional = false, + renderer: TypeGraphRenderer, type: TypeNodeId, closedObjects = false, optional = false, ): Record { const active = new Set() const fail = (subject: string): never => { throw new RemoteJsonSchemaError(`typert Remote JSON Schema: ${subject}`) } @@ -96,7 +96,7 @@ export function remoteJsonSchema( properties[name] = member.description === undefined ? schema : { ...schema, description: member.description } if (!member.optional) required.push(name) } - return { type: 'object', properties, required, additionalProperties: indexed || !rejectUnknownFields } + return { type: 'object', properties, required, additionalProperties: indexed || !closedObjects } } const union = (input: Schema[]): Schema => { if (input.some(branch => Object.keys(branch).length === 0)) return {} diff --git a/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts b/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts index dad47d0666..1aafd6e2e6 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts @@ -81,11 +81,15 @@ declare module '@deepseek-ai/dsh-typert-protocol' { readonly sourceLocation?: { readonly file: string; readonly line: number; readonly column: number } } + export interface TypertRemoteInputOptions { + readonly strict?: boolean + } + export type TypertRemoteMethod = Omit & (Mode extends 'stream' ? { readonly mode: 'stream' } : { readonly mode?: never }) & { readonly invocation: { readonly kind: 'direct' } - readonly input: TypertRemoteValue + input(options?: TypertRemoteInputOptions): TypertRemoteValue readonly result: Extract, { readonly mode: 'strict' }> invoke(remote: TypertClientRemote, input: Input, signal?: AbortSignal): Mode extends 'stream' ? RemoteStreamHandle : Promise> @@ -135,7 +139,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { context: ClassMethodDecoratorContext Result>, ): void - export function Remote(option: string | { readonly mode?: 'stream'; readonly name?: string; readonly rejectUnknownFields?: true }): + export function Remote(option: string | { readonly mode?: 'stream'; readonly name?: string }): ( method: (this: This, ...args: Args) => Result, context: ClassMethodDecoratorContext Result>, diff --git a/packages/typert/generator/tests/remote-metadata.spec.ts b/packages/typert/generator/tests/remote-metadata.spec.ts index 7068baa48d..5c138313b5 100644 --- a/packages/typert/generator/tests/remote-metadata.spec.ts +++ b/packages/typert/generator/tests/remote-metadata.spec.ts @@ -31,7 +31,7 @@ interface RuntimeDescriptor { } interface RuntimeMethod extends RuntimeDescriptor { - readonly input: RuntimeValue + input(options?: { readonly strict?: boolean }): RuntimeValue invoke(remote: object, input: object, signal?: AbortSignal): unknown } @@ -61,7 +61,7 @@ function fixture(): string { * @param signal - request lifetime. * @returns the created reference. */ - @Remote({ rejectUnknownFields: true }) + @Remote `)) editFile(root, 'packages/remote/src/types.ts', source => source.replace( ' readonly title: string', ' /** Human-readable goal name. */\n readonly title: string', @@ -89,7 +89,7 @@ function assertConsumerTypechecks(root: string, dts: string, source: string): vo } describe('Remote method metadata', { timeout: 60_000 }, () => { - it('keeps selected and full-face artifacts identical when another package augments the result', async () => { + it('keeps selected and full-face artifacts identical when another package augments inputs and results', async () => { const root = fixture() const extension = join(root, 'packages/extension') mkdirSync(join(extension, 'src'), { recursive: true }) @@ -109,6 +109,7 @@ describe('Remote method metadata', { timeout: 60_000 }, () => { /** @typert schema */ export interface ExtensionMarker { readonly enabled: boolean } declare module '@fixture/remote/types' { + interface CreateGoalRequest { readonly extension?: { readonly label: string } } interface CreateGoalResult { readonly extension?: string } } `) @@ -122,7 +123,19 @@ declare module '@fixture/remote/types' { expect(selected.map(artifact => artifact.package)).toEqual(['@fixture/remote']) expect(selected[0]).toEqual(complete.find(artifact => artifact.package === '@fixture/remote')) const generated = await executable(selected[0]!.remote!.js) - const output = generated.TYPERT_REMOTE.methods['goals/create']!.result.create() + const method = generated.TYPERT_REMOTE.methods['goals/create']! + const input = { agentId: 'agent', request: { title: 'ship', extension: { label: 'installed' } } } + for (const strict of [false, true]) { + const view = method.input({ strict }) + expect(view.create().parse(input)).toEqual(input) + expect(view.jsonSchema).toMatchObject({ properties: { request: { + properties: { extension: { additionalProperties: !strict } }, + } } }) + } + const extra = { ...input, request: { ...input.request, extension: { label: 'installed', extra: true } } } + expect(method.input().create().parse(extra)).toEqual(input) + expect(() => method.input({ strict: true }).create().parse(extra)).toThrow() + const output = method.result.create() expect(output.parse({ ref: 'created', extension: 'installed' })).toEqual({ ref: 'created', extension: 'installed' }) expect(() => output.parse({ ref: 'created', extension: 1 })).toThrow() }) @@ -154,27 +167,32 @@ declare module '@fixture/remote/types' { { name: 'agent', wire: 'agentId', description: 'owning Agent.' }, { name: 'request', wire: 'request', description: 'goal fields.' }, ]) - expect(current.input.jsonSchema).toEqual(method.input.jsonSchema) - expect(current.input.jsonSchema).toMatchObject({ properties: { - request: { description: 'goal fields.', additionalProperties: false, properties: { title: { description: 'Human-readable goal name.' } } }, - } }) + for (const strict of [false, true]) { + const input = current.input({ strict }) + expect(input.jsonSchema).toEqual(method.input({ strict }).jsonSchema) + expect(input.jsonSchema).toMatchObject({ properties: { + request: { description: 'goal fields.', additionalProperties: !strict, properties: { title: { description: 'Human-readable goal name.' } } }, + } }) + expect(input.jsonSchemaError).toBeUndefined() + assertSupportedJsonSchema(input.jsonSchema) + expect(input.create().parse({ agentId: 'agent', request: { title: 'ship' } })).toEqual({ agentId: 'agent', request: { title: 'ship' } }) + for (const invalid of [ + { request: { title: 'ship' } }, + { agentId: 'agent' }, + { agentId: 'agent', request: { title: 'ship' }, extra: true }, + ]) expect(() => input.create().parse(invalid)).toThrow() + } + expect(current.input().create().parse({ agentId: 'agent', request: { title: 'ship', extra: true } })) + .toEqual({ agentId: 'agent', request: { title: 'ship' } }) + expect(() => current.input({ strict: true }).create().parse({ agentId: 'agent', request: { title: 'ship', extra: true } })).toThrow() + expect(current.parameters[1]!.codec.create().parse({ title: 'ship', extra: true })).toEqual({ title: 'ship' }) expect(current.result.description).toBe('the created reference.') expect(current.result.jsonSchema).toEqual(method.result.jsonSchema) expect(current.result.jsonSchema?.description).toBe('the created reference.') - expect(current.input.jsonSchemaError).toBeUndefined() expect(current.result.jsonSchemaError).toBeUndefined() - assertSupportedJsonSchema(current.input.jsonSchema) assertSupportedJsonSchema(current.result.jsonSchema) expect(current.result.create().parse({ ref: 'created' })).toEqual({ ref: 'created' }) expect(() => current.result.create().parse({ ref: 1 })).toThrow() - expect(current.input.create().parse({ agentId: 'agent', request: { title: 'ship' } })).toEqual({ agentId: 'agent', request: { title: 'ship' } }) - for (const input of [ - { request: { title: 'ship' } }, - { agentId: 'agent', request: { title: 'ship', extra: true } }, - { agentId: 'agent' }, - { agentId: 'agent', request: { title: 'ship' }, extra: true }, - ]) expect(() => current.input.create().parse(input)).toThrow() - expect(() => current.parameters[1]!.codec.create().parse({ title: 'ship', extra: true })).toThrow() const create = vi.fn(async () => ({ ok: true, value: { ref: 'created' } })) const signal = new AbortController().signal await expect(current.invoke({ goals: { create } }, { agentId: 'agent', request: { title: 'ship' } }, signal)) @@ -203,34 +221,117 @@ declare module '@fixture/remote/types' { expect(artifact.remote.js.match(/'goals\/create':/g)).toHaveLength(1) expect(artifact.remote.js).not.toMatch(/\b(?:id|namespace|method|input|invoke|metadata|output):/) expect(artifact.remote.js).not.toContain("wire: 'request'") - for (const source of [artifact.js, artifact.remote.js]) { - const imports = ts.createSourceFile('artifact.js', source, ts.ScriptTarget.ESNext, true, ts.ScriptKind.JS) - .statements.filter(ts.isImportDeclaration).map((statement) => { - if (!ts.isStringLiteral(statement.moduleSpecifier)) throw new Error('nonliteral import') - return statement.moduleSpecifier.text - }) + for (const [index, source] of [artifact.js, artifact.remote.js].entries()) { + const tree = ts.createSourceFile('artifact.js', source, ts.ScriptTarget.ESNext, true, ts.ScriptKind.JS) + const imports = tree.statements.filter(ts.isImportDeclaration).map((statement) => { + if (!ts.isStringLiteral(statement.moduleSpecifier)) throw new Error('nonliteral import') + return statement.moduleSpecifier.text + }) expect(imports.sort()).toEqual(['@deepseek-ai/dsh-typert-protocol/remote-info', 'zod']) + const parameterSpecs: ts.ObjectLiteralExpression[] = [] + const collectParameters = (node: ts.Node): void => { + if (ts.isPropertyAssignment(node) && node.name.getText(tree) === 'parameters') { + if (!ts.isArrayLiteralExpression(node.initializer)) throw new Error('nonliteral parameters') + for (const parameter of node.initializer.elements) { + if (!ts.isObjectLiteralExpression(parameter)) throw new Error('nonliteral parameter') + parameterSpecs.push(parameter) + } + } + ts.forEachChild(node, collectParameters) + } + collectParameters(tree) + expect(parameterSpecs).toHaveLength(4) + for (const parameter of parameterSpecs) { + const codecs = parameter.properties.filter(ts.isPropertyAssignment) + .filter(property => ['codec', 'strictCodec'].includes(property.name.getText(tree))) + expect(codecs.map(property => property.name.getText(tree))).toEqual(['codec', 'strictCodec']) + for (const codec of codecs) expect(ts.isObjectLiteralExpression(codec.initializer)).toBe(true) + } const states = [...source.matchAll(/^let ([\w$]+\$value)$/gm)].map(match => match[1]!) expect(states.length).toBeGreaterThan(0) - const generated = await executableRemoteModule<{ readSchemaStates(): readonly unknown[] }>( - `${source}\nexport const readSchemaStates = () => [${states.join(', ')}]\n`, - ) + const generated = await executableRemoteModule(`${source}\nexport const readSchemaStates = () => [${states.join(', ')}]\n`) expect(generated.readSchemaStates()).toEqual(states.map(() => undefined)) + const method = index === 0 + ? generated.TYPERT!.invocations.find(descriptor => descriptor.id.endsWith('/create')) as RuntimeMethod + : generated.TYPERT_REMOTE.methods['goals/create']! + const first = method.input({ strict: index === 0 }) + const second = method.input({ strict: index !== 0 }) + expect(generated.readSchemaStates()).toEqual(states.map(() => undefined)) + first.create() + const firstStates = generated.readSchemaStates() + expect(firstStates.some(state => state !== undefined)).toBe(true) + second.create() + expect(generated.readSchemaStates().some((state, position) => state !== undefined && firstStates[position] === undefined)).toBe(true) + expect(first.create()).toBe(first.create()) + expect(second.create()).toBe(second.create()) } }) - it('keeps request objects open unless rejectUnknownFields is enabled', async () => { + it('generates normal and strict input views from plain Remote methods without changing result codecs', async () => { const root = fixture() - editFile(root, 'packages/remote/src/index.ts', source => source.replace('@Remote({ rejectUnknownFields: true })', '@Remote')) + editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string', `readonly title: string + readonly metadata: { readonly label: string } + readonly items?: readonly { readonly label: string }[]`)) + editFile(root, 'packages/remote/src/index.ts', source => source.replace(' constructor() {', ` @Remote + echo(request: CreateGoalRequest): CreateGoalRequest { return request } + + constructor() {`)) const [artifact] = new WorkspaceTypertGenerator(root).generate() const generated = await executable(artifact!.remote!.js) const method = generated.TYPERT_REMOTE.methods['goals/create']! - expect(method.input.jsonSchema).toMatchObject({ additionalProperties: false, properties: { request: { additionalProperties: true } } }) - const extra = { agentId: 'agent', request: { title: 'ship', extra: true } } - expect(validateJsonSchemaValue(method.input.jsonSchema!, extra)).toEqual([]) - expect(method.input.create().parse(extra)).toEqual({ agentId: 'agent', request: { title: 'ship' } }) - expect(generated.TYPERT_REMOTE.descriptors[0]!.parameters[1]!.codec.create().parse(extra.request)).toEqual({ title: 'ship' }) - expect(() => method.input.create().parse({ ...extra, unexpected: true })).toThrow() + const normal = method.input() + const strict = method.input({ strict: true }) + expect(method.input()).toBe(normal) + expect(method.input({})).toBe(normal) + expect(method.input({ strict: false })).toBe(normal) + expect(method.input({ strict: true })).toBe(strict) + expect(strict).not.toBe(normal) + expect(normal.create()).toBe(normal.create()) + expect(strict.create()).toBe(strict.create()) + expect(strict.create()).not.toBe(normal.create()) + expect(normal.jsonSchema).toBe(normal.jsonSchema) + expect(strict.jsonSchema).toBe(strict.jsonSchema) + expect(strict.jsonSchema).not.toBe(normal.jsonSchema) + for (const [view, additionalProperties] of [[normal, true], [strict, false]] as const) { + assertSupportedJsonSchema(view.jsonSchema) + expect(view.jsonSchema).toMatchObject({ additionalProperties: false, properties: { request: { + additionalProperties, + properties: { + metadata: { additionalProperties }, + items: { items: { additionalProperties } }, + }, + } } }) + } + const input = { agentId: 'agent', request: { title: 'ship', metadata: { label: 'app payload' }, items: [{ label: 'first' }] } } + expect(strict.create().parse(input)).toEqual(input) + for (const request of [ + { ...input.request, extra: true }, + { ...input.request, metadata: { ...input.request.metadata, extra: true } }, + { ...input.request, items: [{ label: 'first', extra: true }] }, + ]) { + const extra = { ...input, request } + expect(validateJsonSchemaValue(normal.jsonSchema!, extra)).toEqual([]) + expect(normal.create().parse(extra)).toEqual(input) + expect(validateJsonSchemaValue(strict.jsonSchema!, extra)).not.toEqual([]) + expect(() => strict.create().parse(extra)).toThrow() + expect(method.parameters[1]!.codec.create().parse(request)).toEqual(input.request) + } + for (const view of [normal, strict]) { + expect(() => view.create().parse({ ...input, unexpected: true })).toThrow() + expect(validateJsonSchemaValue(view.jsonSchema!, { ...input, unexpected: true })).not.toEqual([]) + } + const echo = generated.TYPERT_REMOTE.methods['goals/echo']! + const extraRequest = { ...input.request, metadata: { label: 'app payload', extra: true } } + expect(echo.input().create().parse({ request: extraRequest })).toEqual({ request: input.request }) + expect(() => echo.input({ strict: true }).create().parse({ request: extraRequest })).toThrow() + expect(echo.result.create().parse(extraRequest)).toEqual(input.request) + expect(echo.result.jsonSchema).toMatchObject({ additionalProperties: true, properties: { metadata: { additionalProperties: true } } }) + const create = vi.fn(async () => ({ ok: true, value: { ref: 'created' } })) + await method.invoke({ goals: { create } }, input) + expect(create).toHaveBeenCalledWith('agent', input.request, undefined) }) it('generates undocumented methods with parsers and optional source documentation', async () => { @@ -240,9 +341,11 @@ declare module '@fixture/remote/types' { expect(method.description).toBeUndefined() expect(method.parameters.map(parameter => parameter.description)).toEqual([undefined, undefined]) expect(method.result.description).toBeUndefined() - expect(method.input.jsonSchemaError).toBeUndefined() + for (const view of [method.input(), method.input({ strict: true })]) { + expect(view.jsonSchemaError).toBeUndefined() + expect(view.create().parse({ agentId: 'agent', request: { title: 'ship' } })).toEqual({ agentId: 'agent', request: { title: 'ship' } }) + } expect(method.result.jsonSchemaError).toBeUndefined() - expect(method.input.create().parse({ agentId: 'agent', request: { title: 'ship' } })).toEqual({ agentId: 'agent', request: { title: 'ship' } }) expect(method.result.create().parse({ ref: 'created' })).toEqual({ ref: 'created' }) }) @@ -257,7 +360,7 @@ declare module '@fixture/remote/types' { constructor() {`)}`) const [artifact] = new WorkspaceTypertGenerator(root).generate() assertConsumerTypechecks(root, artifact!.remote!.dts, `import { TYPERT_REMOTE } from '@fixture/remote/remote' -import type { InvocationDescriptor, TypertClientRemote, TypertRemoteInfo, TypertRemoteMethod, RemoteResult, RemoteStreamHandle, TypertRemoteScopeMap } from '@deepseek-ai/dsh-typert-protocol' +import type { InvocationDescriptor, TypertClientRemote, TypertRemoteInfo, TypertRemoteInputOptions, TypertRemoteMethod, TypertRemoteValue, RemoteResult, RemoteStreamHandle, TypertRemoteScopeMap } from '@deepseek-ai/dsh-typert-protocol' import type { CreateGoalRequest, CreateGoalResult } from '@fixture/remote/types' declare const remote: TypertClientRemote const info: TypertRemoteInfo = TYPERT_REMOTE @@ -274,7 +377,19 @@ const typeSymbol: string = method.result.typeSymbol const keys: Record = { 'goals/create': true, 'goals/watch': true, 'goals/bytes': true, 'goals/attach': true, } -const parsed: { readonly agentId: string; readonly request: CreateGoalRequest } = method.input.create().parse({}) +const parsed: { readonly agentId: string; readonly request: CreateGoalRequest } = method.input().create().parse({}) +const options: TypertRemoteInputOptions = { strict: true } +const strictInput: TypertRemoteValue = method.input(options) +const strictParsed: typeof parsed = strictInput.create().parse({}) +const normalInput: TypertRemoteValue = method.input({ strict: false }) +// @ts-expect-error Input options are readonly. +options.strict = false +// @ts-expect-error Strict selection accepts a boolean, not a string. +method.input({ strict: 'true' }) +// @ts-expect-error Input parsing requires selecting a view. +method.input.create() +// @ts-expect-error Strict input parsers retain the request type. +const wrongInput: { readonly request: string } = strictInput.create().parse({}) const output: CreateGoalResult = method.result.create().parse({}) const value: Promise> = method.invoke(remote, parsed, new AbortController().signal) // @ts-expect-error Invocation results retain the owner's result fields. @@ -282,8 +397,8 @@ const wrongResult: Promise> = method.invoke(remote, parsed) // @ts-expect-error Output parsers retain the owner's result fields. const wrongOutput: string = method.result.create().parse({}) const description: string | undefined = method.description -const schema: Readonly> | undefined = method.input.jsonSchema -const projectionError: string | undefined = method.input.jsonSchemaError +const schema: Readonly> | undefined = method.input().jsonSchema +const projectionError: string | undefined = method.input().jsonSchemaError const parameterDescription: string | undefined = method.parameters[0]?.description const resultDescription: string | undefined = method.result.description // @ts-expect-error The request retains the owner's title type. @@ -337,7 +452,7 @@ async function bytes() { void base64 } void [info, typedMethod, descriptor, descriptors, unaryMode, streamMode, direct, id, codecMode, typeSymbol, keys] -void [output, value, wrongResult, wrongOutput, description, schema, projectionError, parameterDescription, resultDescription, typedStream, typedWatch, bytes] +void [strictParsed, normalInput, wrongInput, output, value, wrongResult, wrongOutput, description, schema, projectionError, parameterDescription, resultDescription, typedStream, typedWatch, bytes] `) }) @@ -349,14 +464,36 @@ void [output, value, wrongResult, wrongOutput, description, schema, projectionEr const method = generated.TYPERT_REMOTE.methods['goals/create']! expect(method.description).toBe('Create a named goal.') expect(method.parameters.find(parameter => parameter.wire === 'request')?.description).toBe('goal fields.') - expect(method.input.jsonSchema).toBeUndefined() - expect(method.input.jsonSchemaError).toContain('selected union branches overlap') - for (const title of [{ first: 'a' }, { second: 'b' }]) { - const input = { agentId: 'agent', request: { title } } - expect(method.input.create().parse(input)).toEqual(input) - expect(generated.TYPERT_REMOTE.descriptors[0]!.parameters[1]!.codec.create().parse(input.request)).toEqual(input.request) + for (const view of [method.input(), method.input({ strict: true })]) { + expect(view.jsonSchema).toBeUndefined() + expect(view.jsonSchemaError).toContain('selected union branches overlap') + for (const title of [{ first: 'a' }, { second: 'b' }]) { + const input = { agentId: 'agent', request: { title } } + expect(view.create().parse(input)).toEqual(input) + expect(method.parameters[1]!.codec.create().parse(input.request)).toEqual(input.request) + } + expect(() => view.create().parse({ agentId: 'agent', request: { title: {} } })).toThrow() } - expect(() => method.input.create().parse({ agentId: 'agent', request: { title: {} } })).toThrow() + }) + + it('keeps recursive input parsers independent when JSON Schema cannot represent them', async () => { + const root = fixture() + editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string', + 'readonly title: string\n readonly next?: CreateGoalRequest')) + const [artifact] = new WorkspaceTypertGenerator(root).generate() + const generated = await executable(artifact!.remote!.js) + const method = generated.TYPERT_REMOTE.methods['goals/create']! + const input = { agentId: 'agent', request: { title: 'first', next: { title: 'second' } } } + for (const view of [method.input(), method.input({ strict: true })]) { + expect(view.jsonSchema).toBeUndefined() + expect(view.jsonSchemaError).toContain('recursive selected type') + expect(view.create().parse(input)).toEqual(input) + expect(() => view.create().parse({ agentId: 'agent', request: { title: 'first', next: { title: 2 } } })).toThrow() + } + const extra = { agentId: 'agent', request: { title: 'first', next: { title: 'second', extra: true } } } + expect(method.input().create().parse(extra)).toEqual(input) + expect(() => method.input({ strict: true }).create().parse(extra)).toThrow() + expect(method.parameters[1]!.codec.create().parse(extra.request)).toEqual(input.request) }) it('supports optional named arguments and parameterless methods', async () => { @@ -371,19 +508,24 @@ void [output, value, wrongResult, wrongOutput, description, schema, projectionEr const [artifact] = new WorkspaceTypertGenerator(root).generate() const generated = await executable(artifact!.remote!.js) const ready = generated.TYPERT_REMOTE.methods['goals/ready']! - expect(ready.input.jsonSchema).toEqual({ type: 'object', properties: {}, required: [], additionalProperties: false }) - expect(ready.input.create().parse({})).toEqual({}) - expect(() => ready.input.create().parse({ extra: true })).toThrow() + for (const view of [ready.input(), ready.input({ strict: true })]) { + expect(view.jsonSchema).toEqual({ type: 'object', properties: {}, required: [], additionalProperties: false }) + expect(view.create().parse({})).toEqual({}) + expect(() => view.create().parse({ extra: true })).toThrow() + } const callReady = vi.fn(async () => ({ ok: true, value: true })) await expect(ready.invoke({ goals: { ready: callReady } }, {})).resolves.toEqual({ ok: true, value: true }) expect(callReady).toHaveBeenCalledWith() const labelled = generated.TYPERT_REMOTE.methods['goals/labelled']! - expect(labelled.input.jsonSchema).toMatchObject({ required: ['id'], properties: { label: { type: 'string' } } }) - for (const input of [{ id: 'goal' }, { id: 'goal', label: 'ship' }]) { - expect(validateJsonSchemaValue(labelled.input.jsonSchema!, input)).toEqual([]) - expect(labelled.input.create().parse(input)).toEqual(input) + for (const view of [labelled.input(), labelled.input({ strict: true })]) { + expect(view.jsonSchema).toMatchObject({ required: ['id'], properties: { label: { type: 'string' } } }) + for (const input of [{ id: 'goal' }, { id: 'goal', label: 'ship' }]) { + expect(validateJsonSchemaValue(view.jsonSchema!, input)).toEqual([]) + expect(view.create().parse(input)).toEqual(input) + } + expect(() => view.create().parse({ label: 'ship' })).toThrow() + expect(() => view.create().parse({ id: 'goal', label: 1 })).toThrow() } - expect(() => labelled.input.create().parse({ id: 'goal', label: 1 })).toThrow() const callLabelled = vi.fn(async () => ({ ok: true, value: 'goal' })) await labelled.invoke({ goals: { labelled: callLabelled } }, { id: 'goal' }) expect(callLabelled).toHaveBeenCalledWith('goal', undefined) @@ -406,16 +548,19 @@ void ready const [artifact] = new WorkspaceTypertGenerator(root).generate() const generated = await executable(artifact!.remote!.js) const method = generated.TYPERT_REMOTE.methods['goals/create']! - const schema = method.input.jsonSchema + const strict = method.input({ strict: true }) + const schema = strict.jsonSchema assertSupportedJsonSchema(schema) for (const title of [{ kind: 'text', text: 'ship' }, { kind: 'count', count: 2 }]) { const input = { agentId: 'agent', request: { title } } expect(validateJsonSchemaValue(schema, input)).toEqual([]) - expect(method.input.create().parse(input)).toEqual(input) + expect(strict.create().parse(input)).toEqual(input) } const extra = { agentId: 'agent', request: { title: { kind: 'text', text: 'ship', count: 2 } } } expect(validateJsonSchemaValue(schema, extra)).not.toEqual([]) - expect(() => method.input.create().parse(extra)).toThrow() + expect(() => strict.create().parse(extra)).toThrow() + expect(validateJsonSchemaValue(method.input().jsonSchema!, extra)).toEqual([]) + expect(method.input().create().parse(extra)).toEqual({ agentId: 'agent', request: { title: { kind: 'text', text: 'ship' } } }) }) it('returns the ordinary stream handle without wrapping its uplink or disposal', async () => { @@ -457,13 +602,101 @@ void ready const [artifact] = new WorkspaceTypertGenerator(root).generate() const generated = await executable(artifact!.remote!.js) const method = generated.TYPERT_REMOTE.methods['goals/create']! - const schema = method.input.jsonSchema - assertSupportedJsonSchema(schema) - expect(JSON.stringify(schema)).not.toContain('$ref') - const input = { agentId: 'agent', request: { title: 'ship', payload: [null, { nested: [true, 3] }], labels: { tag: ['a', 'b'] } } } - expect(validateJsonSchemaValue(schema, input)).toEqual([]) - expect(method.input.create().parse(input)).toEqual(input) - expect(() => method.input.create().parse({ agentId: 'agent', request: { title: 'ship', payload: undefined } })).toThrow() + const input = { agentId: 'agent', request: { title: 'ship', payload: [null, { nested: [true, 3] }], labels: { tag: ['a', 'b'], extra: { nested: true } } } } + for (const strict of [false, true]) { + const view = method.input({ strict }) + const schema = view.jsonSchema + assertSupportedJsonSchema(schema) + expect(JSON.stringify(schema)).not.toContain('$ref') + expect(schema).toMatchObject({ properties: { request: { + additionalProperties: !strict, + properties: { payload: {}, labels: { type: 'object', additionalProperties: true } }, + } } }) + expect(validateJsonSchemaValue(schema, input)).toEqual([]) + expect(view.create().parse(input)).toEqual(input) + expect(() => view.create().parse({ agentId: 'agent', request: { title: 'ship', payload: undefined } })).toThrow() + } + }) + + it('keeps typed dictionary keys open when input JSON Schema is unsupported', async () => { + const root = fixture() + editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string', + 'readonly title: string\n readonly labels: Record')) + const [artifact] = new WorkspaceTypertGenerator(root).generate() + const generated = await executable(artifact!.remote!.js) + const method = generated.TYPERT_REMOTE.methods['goals/create']! + const input = { agentId: 'agent', request: { title: 'ship', labels: { first: 'a', extra: 'b' } } } + for (const view of [method.input(), method.input({ strict: true })]) { + expect(view.jsonSchema).toBeUndefined() + expect(view.jsonSchemaError).toContain('selected dictionaries require string keys and unconstrained JSON values') + expect(view.create().parse(input)).toEqual(input) + expect(() => view.create().parse({ ...input, request: { title: 'ship', labels: { first: 1 } } })).toThrow() + } + const extra = { ...input, request: { ...input.request, extra: true } } + expect(method.input().create().parse(extra)).toEqual(input) + expect(() => method.input({ strict: true }).create().parse(extra)).toThrow() + }) + + it('retains tuple input parsers under both policies without requiring a tool projection', async () => { + const root = fixture() + editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string', + 'readonly title: readonly [{ readonly first: string }, { readonly second: string }]')) + const [artifact] = new WorkspaceTypertGenerator(root).generate() + const generated = await executable(artifact!.remote!.js) + const method = generated.TYPERT_REMOTE.methods['goals/create']! + const input = { agentId: 'agent', request: { title: [{ first: 'one' }, { second: 'two' }] } } + for (const strict of [false, true]) { + const view = method.input({ strict }) + expect(view.create().parse(input)).toEqual(input) + expect(view.jsonSchema).toBeUndefined() + expect(view.jsonSchemaError).toContain('tuple') + } + const extra = { agentId: 'agent', request: { title: [{ first: 'one', extra: true }, { second: 'two' }] } } + expect(method.input().create().parse(extra)).toEqual(input) + expect(() => method.input({ strict: true }).create().parse(extra)).toThrow() + }) + + it('keeps declared dictionary properties more specific than the index value', async () => { + const root = fixture() + editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string', + 'readonly title: string\n readonly payload: { readonly fixed: { readonly a: string; readonly b: string }; readonly [key: string]: { readonly a: string } }')) + const [artifact] = new WorkspaceTypertGenerator(root).generate() + const generated = await executable(artifact!.remote!.js) + const method = generated.TYPERT_REMOTE.methods['goals/create']! + const input = { agentId: 'agent', request: { title: 'ship', payload: { fixed: { a: 'a', b: 'b' }, extra: { a: 'extra' } } } } + for (const strict of [false, true]) { + const view = method.input({ strict }) + expect(view.create().parse(input)).toEqual(input) + expect(view.jsonSchema).toBeUndefined() + expect(view.jsonSchemaError).toContain('selected dictionaries') + } + const withExtraValue = { ...input, request: { ...input.request, payload: { ...input.request.payload, extra: { a: 'extra', b: 'not declared' } } } } + expect(method.input().create().parse(withExtraValue)).toEqual(input) + expect(() => method.input({ strict: true }).create().parse(withExtraValue)).toThrow() + }) + + it('keeps normal and closed recursive helper identifiers distinct', async () => { + const root = fixture() + editFile(root, 'packages/remote/src/types.ts', source => `${source}\nexport interface Branch { readonly value: string; readonly next?: Branch }\nexport interface closed$Branch { readonly value: string; readonly next?: closed$Branch }\n`) + editFile(root, 'packages/remote/src/index.ts', source => `import type { Branch, closed$Branch } from './types.ts'\n${source.replace(' constructor() {', ` @Remote + recursive(request: Branch): closed$Branch { return request } + + constructor() {`)}`) + const [artifact] = new WorkspaceTypertGenerator(root).generate() + const input = { request: { value: 'first', next: { value: 'second' } } } + for (const source of [artifact!.js, artifact!.remote!.js]) { + await executableRemoteModule(source) + const variables = ts.createSourceFile('artifact.js', source, ts.ScriptTarget.ESNext, true, ts.ScriptKind.JS).statements + .filter(ts.isVariableStatement) + .flatMap(statement => statement.declarationList.declarations.map(declaration => declaration.name.getText())) + expect(new Set(variables).size).toBe(variables.length) + } + const generated = await executable(artifact!.remote!.js) + const method = generated.TYPERT_REMOTE.methods['goals/recursive']! + expect(method.input().create().parse(input)).toEqual(input) + expect(method.input({ strict: true }).create().parse(input)).toEqual(input) + expect(() => method.input({ strict: true }).create().parse({ request: { value: 'first', next: { value: 'second', extra: true } } })).toThrow() + expect(method.result.create().parse(input.request)).toEqual(input.request) }) it('projects real nominal primitive brands without treating the marker as an object', async () => { @@ -491,9 +724,11 @@ export type AgentId = Branded<'AgentId'> const [artifact] = new WorkspaceTypertGenerator(root).generate() const generated = await executable(artifact!.remote!.js) const method = generated.TYPERT_REMOTE.methods['goals/create']! - expect(method.input.jsonSchema).toMatchObject({ properties: { agentId: { type: 'string' } } }) - expect(method.input.create().parse({ agentId: 'agent', request: { title: 'ship' } })) - .toEqual({ agentId: 'agent', request: { title: 'ship' } }) + for (const view of [method.input(), method.input({ strict: true })]) { + expect(view.jsonSchema).toMatchObject({ properties: { agentId: { type: 'string' } } }) + expect(view.create().parse({ agentId: 'agent', request: { title: 'ship' } })) + .toEqual({ agentId: 'agent', request: { title: 'ship' } }) + } expect(artifact!.remote!.dts).toContain("readonly 'agentId': AgentId") }) @@ -508,13 +743,16 @@ export type AgentId = Branded<'AgentId'> const generated = await executable(artifact!.remote!.js) const method = generated.TYPERT_REMOTE.methods['goals/create']! const input = { agentId: 'agent', request: { title: 'ship', nested: { alpha: 'a', beta: 2 }, priority: 3 } } - const schema = method.input.jsonSchema + const strict = method.input({ strict: true }) + const schema = strict.jsonSchema assertSupportedJsonSchema(schema) expect(validateJsonSchemaValue(schema, input)).toEqual([]) - expect(method.input.create().parse(input)).toEqual(input) + expect(strict.create().parse(input)).toEqual(input) const extra = { ...input, request: { ...input.request, nested: { ...input.request.nested, extra: true } } } expect(validateJsonSchemaValue(schema, extra)).not.toEqual([]) - expect(() => method.input.create().parse(extra)).toThrow() + expect(() => strict.create().parse(extra)).toThrow() + expect(validateJsonSchemaValue(method.input().jsonSchema!, extra)).toEqual([]) + expect(method.input().create().parse(extra)).toEqual(input) }) it('retains output codecs and source docs when optional JSON Schema projection is unsupported', async () => { diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 0dafb01694..2ee980a746 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -540,7 +540,7 @@ export type {`, name: 'an unknown Remote mode', mode: 'duplex', method: 'async *attach(agent: Agent, signal: AbortSignal): AsyncIterable', - message: 'Remote\\(\\) options require name, mode: "stream", or rejectUnknownFields: true', + message: 'Remote\\(\\) options require name or mode: "stream"', }, ])('rejects $name', ({ mode, method, message }) => { const root = copyFixture() @@ -553,11 +553,11 @@ export type {`, expect(() => analyzeRemote(root, false)).toThrow(new RegExp(message)) }) - it.each(['committed', 'emitSourceDef'])('rejects the removed %s option', (option) => { + it.each(['committed', 'emitSourceDef', 'rejectUnknownFields'])('rejects the removed %s option', (option) => { const root = copyFixture() editFile(root, 'packages/remote/src/index.ts', source => source.replace(' @Remote\n', ` @Remote({ ${option}: true })\n`)) expect(() => analyzeRemote(root, false)) - .toThrow('Remote() options require name, mode: "stream", or rejectUnknownFields: true') + .toThrow('Remote() options require name or mode: "stream"') }) it('evaluates declaration-merged mapped and conditional boundaries for codecs without widening consumer types', async () => { diff --git a/packages/typert/loader/README.i18n.yaml b/packages/typert/loader/README.i18n.yaml index 92931f8cf0..bf6ee2c336 100644 --- a/packages/typert/loader/README.i18n.yaml +++ b/packages/typert/loader/README.i18n.yaml @@ -33,8 +33,8 @@ en: 03fc077e58271e18 zh: 20f8fc02008cee5e /deepseek-ai-dsh-typert-loader/understand-the-implementation/manifest-validation: - en: b16629b4ed1af907 - zh: 5db61c1a9b8606a8 + en: c7d93c72a224e8fb + zh: 51f0e4b8a043c238 /deepseek-ai-dsh-typert-loader/understand-the-implementation/caching-and-ownership: en: 38e781e052fdf44e zh: d8f6b4d119bf9856 diff --git a/packages/typert/loader/README.md b/packages/typert/loader/README.md index e2750ab900..b7b33a6665 100644 --- a/packages/typert/loader/README.md +++ b/packages/typert/loader/README.md @@ -68,7 +68,7 @@ The plugin is an incremental scanner mirroring the client-modules node half: eve ### Manifest validation -`validateTypertManifest()` checks artifacts before they enter the typed registry. A manifest must name its exporting package, carry face `host`, hold schema factories, and contain well-formed reflection records; invocation descriptors require strict codecs with factories. Generated direct-method `input` and `invoke` capabilities are validated on the descriptor itself, while `result` remains its sole output codec. Documentation and JSON Schema are optional. Validation inspects eager projection data without invoking lazy projection getters or constructing schemas; consumers request the derived projections they need. Every failure names the package and the defect. +`validateTypertManifest()` checks artifacts before they enter the typed registry. A manifest must name its exporting package, carry face `host`, hold schema factories, and contain well-formed reflection records; invocation descriptors require strict codecs with factories. Validation checks parameter `codec` and any supplied `strictCodec` specs and requires generated direct-method `input` and `invoke` to be callable; `result` remains the sole output codec. Documentation and JSON Schema are optional. Registration never calls the input selector, parser factories, or projection getters; validation inspects only supplied static projection data. Every failure names the package and the defect. ### Caching and ownership diff --git a/packages/typert/loader/README.zh.md b/packages/typert/loader/README.zh.md index eb4f1125f8..c791ab6685 100644 --- a/packages/typert/loader/README.zh.md +++ b/packages/typert/loader/README.zh.md @@ -68,7 +68,7 @@ kind: "package-reference" ### Manifest 校验 -`validateTypertManifest()` 在产物进入类型化注册表前执行检查。manifest 必须指名导出它的包、携带 `host` face、持有 schema factory,并包含格式正确的反射记录;调用描述符要求带 factory 的严格 codec。生成直接方法的 `input` 和 `invoke` 能力直接在描述符上验证,而 `result` 仍是唯一输出 codec。文档和 JSON Schema 可选。校验读取已提供的投影数据,不会调用惰性投影 getter 或构建 schema;消费方按需请求派生投影。每次失败都会指名包与缺陷。 +`validateTypertManifest()` 在产物进入类型化注册表前执行检查。manifest 必须指名导出它的包、携带 `host` face、持有 schema factory,并包含格式正确的反射记录;调用描述符要求带 factory 的严格 codec。校验检查参数 `codec` 及提供的 `strictCodec` spec,并要求生成直接方法的 `input` 和 `invoke` 可调用;`result` 仍是唯一输出 codec。文档和 JSON Schema 可选。注册绝不调用输入选择器、解析器 factory 或投影 getter;校验只检查已提供的静态投影数据。每次失败都会指名包与缺陷。 ### 缓存与归属 diff --git a/packages/typert/loader/src/index.ts b/packages/typert/loader/src/index.ts index 06391c9d2f..cd4e582c73 100644 --- a/packages/typert/loader/src/index.ts +++ b/packages/typert/loader/src/index.ts @@ -243,6 +243,7 @@ function requireInvocation(pkgName: string, value: unknown): void { } parameters.set(wire, parameter) requireStrictCodec(pkgName, parameter.codec, `invocation "${id}" parameter codec`) + if (parameter.strictCodec !== undefined) requireStrictCodec(pkgName, parameter.strictCodec, `invocation "${id}" strict input codec`) } if (invocation.cancellation !== undefined) { const cancellation = requireObject(pkgName, invocation.cancellation, `invocation "${id}" cancellation`) @@ -272,8 +273,9 @@ function requireInvocation(pkgName: string, value: unknown): void { if (receiver.kind !== 'direct') { throw new Error(`typert-loader: ${pkgName} invocation "${id}" input/invoke require a direct receiver`) } - const input = requireObject(pkgName, invocation.input, `invocation "${id}" input`) - requireSchemaProjection(pkgName, input, `invocation "${id}" input`) + if (typeof invocation.input !== 'function') { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" has no input() selector`) + } if (typeof invocation.invoke !== 'function') { throw new Error(`typert-loader: ${pkgName} invocation "${id}" has no invoke() function`) } diff --git a/packages/typert/loader/tests/lazy-info.spec.ts b/packages/typert/loader/tests/lazy-info.spec.ts index f843895840..120a39191b 100644 --- a/packages/typert/loader/tests/lazy-info.spec.ts +++ b/packages/typert/loader/tests/lazy-info.spec.ts @@ -1,32 +1,35 @@ -/** Loading a generated contribution must not request its consumer-owned lazy projections. */ +/** Loading a generated contribution must not request consumer-owned input views or projections. */ import { expect, it, vi } from 'vitest' import { z } from 'zod' import { createTypertRemoteInfo } from '@deepseek-ai/dsh-typert-protocol/remote-info' import { validateTypertManifest } from '../src/index.ts' -it('validates invocation facts without materializing parsers or derived tool projections', () => { +it('validates invocation facts without selecting input policy or materializing schemas', () => { const create = vi.fn(() => z.string()) const codec = { mode: 'strict' as const, typeSymbol: 'pkg#Text', create, jsonSchema: { type: 'string' } } const info = createTypertRemoteInfo({ package: 'pkg', methods: { 'goals/create': { service: 'goals', invocation: { kind: 'direct' }, - parameters: [{ name: 'request', source: 'json', codec }], + parameters: [{ name: 'request', source: 'json', codec, strictCodec: codec }], result: { ...codec, description: 'Created goal.' }, }, } }) const method = info.methods['goals/create']! - const inputSchema = vi.spyOn(method.input, 'jsonSchema', 'get') - const inputError = vi.spyOn(method.input, 'jsonSchemaError', 'get') + const normal = method.input() + const strict = method.input({ strict: true }) + const select = vi.spyOn(method, 'input') + const inputSchema = vi.spyOn(normal, 'jsonSchema', 'get') + const strictSchema = vi.spyOn(strict, 'jsonSchema', 'get') + const inputError = vi.spyOn(strict, 'jsonSchemaError', 'get') const outputSchema = vi.spyOn(method.result, 'jsonSchema', 'get') const manifest = { package: 'pkg', face: 'host', schemas: [], model: { services: [], events: [], objects: [] }, invocations: info.descriptors } expect(validateTypertManifest('pkg', manifest)).toBe(manifest) - expect(create).not.toHaveBeenCalled() - expect(inputSchema).not.toHaveBeenCalled() - expect(inputError).not.toHaveBeenCalled() - expect(outputSchema).not.toHaveBeenCalled() - expect(method.input.jsonSchema).toMatchObject({ properties: { request: { type: 'string' } } }) + for (const operation of [create, select, inputSchema, strictSchema, inputError, outputSchema]) { + expect(operation).not.toHaveBeenCalled() + } + expect(strict.jsonSchema).toMatchObject({ properties: { request: { type: 'string' } } }) expect(method.result.jsonSchema).toEqual({ type: 'string', description: 'Created goal.' }) - expect(method.input.create().parse({ request: 'ship' })).toEqual({ request: 'ship' }) + expect(strict.create().parse({ request: 'ship' })).toEqual({ request: 'ship' }) expect(create).toHaveBeenCalledTimes(1) }) diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index 0c282f7972..d94126b687 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -99,6 +99,7 @@ function invocationTypertSource(pkgName: string, factoryUrl: string): string { ' parameters: [{', " name: 'request', source: 'json', description: 'Goal title.',", ` codec: { mode: 'strict', typeSymbol: '${pkgName}/types#Request', create: Text, jsonSchema: { type: 'string' } },`, + ` strictCodec: { mode: 'strict', typeSymbol: '${pkgName}/types#Request', create: Text, jsonSchema: { type: 'string' } },`, ' }],', " cancellation: { parameter: 'signal' },", ' result: {', @@ -198,8 +199,8 @@ describe('typert loader', () => { expect(descriptor).not.toHaveProperty('metadata') expect(descriptor.description).toBe('Create a goal.') expect(descriptor.parameters[0]?.description).toBe('Goal title.') - expect(descriptor.input.create().parse({ request: 'ready' })).toEqual({ request: 'ready' }) - expect(() => descriptor.input.create().parse({ request: 1 })).toThrow() + expect(descriptor.input().create().parse({ request: 'ready' })).toEqual({ request: 'ready' }) + expect(() => descriptor.input().create().parse({ request: 1 })).toThrow() expect(descriptor.result.create().parse('created')).toBe('created') expect(descriptor.result.jsonSchema).toEqual({ type: 'string', description: 'Created goal.' }) expect(descriptor.parameters[0]?.codec.mode).toBe('strict') @@ -566,17 +567,17 @@ describe('validateTypertManifest', () => { }) it('validates flat method capabilities and eager projections without requiring tool schemas', () => { + const input = vi.fn(() => ({ create: () => z.object({ request: z.string() }) })) const method = { - ...strictInvocation(), invoke: () => undefined, - input: { create: () => z.object({ request: z.string() }) }, + ...strictInvocation(), invoke: () => undefined, input, result: { ...strictCodec('pkg#Result'), jsonSchemaError: 'Not projectable.' }, } const manifest = { ...completeManifest(zodish), invocations: [method] } expect(validateTypertManifest('pkg', manifest)).toBe(manifest) + expect(input).not.toHaveBeenCalled() const withDocs = { ...method, description: '', - parameters: method.parameters.map(parameter => ({ ...parameter, description: 'Goal title.' })), - input: { ...method.input, description: '', jsonSchema: { type: 'object' } }, + parameters: method.parameters.map(parameter => ({ ...parameter, description: 'Goal title.', strictCodec: parameter.codec })), result: { ...method.result, description: '' }, } expect(validateTypertManifest('pkg', { ...manifest, invocations: [withDocs] }).invocations[0]).toBe(withDocs) @@ -587,8 +588,13 @@ describe('validateTypertManifest', () => { { ...method, invoke: false }, { ...method, description: 1 }, { ...method, parameters: method.parameters.map(parameter => ({ ...parameter, description: false })) }, { ...method, input: null }, { ...method, input: { create: false } }, - { ...method, input: { ...method.input, jsonSchema: [] } }, - { ...method, input: { ...method.input, jsonSchemaError: false } }, + { ...method, parameters: method.parameters.map(parameter => ({ ...parameter, strictCodec: { ...parameter.codec, create: false } })) }, + { ...method, parameters: method.parameters.map(parameter => ({ + ...parameter, strictCodec: { ...parameter.codec, jsonSchema: [] }, + })) }, + { ...method, parameters: method.parameters.map(parameter => ({ + ...parameter, strictCodec: { ...parameter.codec, jsonSchemaError: false }, + })) }, { ...method, result: { ...method.result, jsonSchemaError: false } }, { ...method, result: { ...method.result, description: 1 } }, { ...method, invocation: { kind: 'context', context: 'agent', wire: 'agentId', codec: strictCodec('pkg#Agent') } }, diff --git a/packages/typert/protocol/README.i18n.yaml b/packages/typert/protocol/README.i18n.yaml index 0395bb32d9..2494160eeb 100644 --- a/packages/typert/protocol/README.i18n.yaml +++ b/packages/typert/protocol/README.i18n.yaml @@ -18,8 +18,8 @@ en: c479b5b665822198 zh: 1ffb789c18043baf /deepseek-ai-dsh-typert-protocol/use-this-package/exposing-a-host-method: - en: 6f0306f504fe11d4 - zh: c5e5f1588757127f + en: 6b91536e2e5e4792 + zh: da86a5bddda6f01f /deepseek-ai-dsh-typert-protocol/use-this-package/associating-host-objects-and-contexts-with-wire-identities: en: 3db384ae6f215632 zh: 56207184b09d4018 diff --git a/packages/typert/protocol/README.md b/packages/typert/protocol/README.md index a874b31327..9aa040cc6c 100644 --- a/packages/typert/protocol/README.md +++ b/packages/typert/protocol/README.md @@ -48,13 +48,13 @@ A unary method can return `Uint8Array` directly or within nested objects, arrays A stream method (`@Remote({ mode: 'stream' })`) returns `Iterable`, `AsyncIterable`, or `RemoteStream`. `In` declares the items the Client may send back on the same logical stream; the method reads them through `this.ctx.invocation.uplink()`, and the descriptor carries their codec. `RemoteInvocation` also names the receiving `service`, the calling `peer` (a `PeerScope` the connection layer admitted), and the carrier `signal`; `ctx.invocation` is `undefined` on a Context no Remote call derived: the first `bindTypertRemote()` binding in a tree, which every `TypertRemoteService` constructor makes, registers that accessor on the root. A generated Client stream method returns `RemoteStreamHandle`: the handle with `send`, `end`, and `dispose` beside the downlink iteration. Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through. -`@Remote({ rejectUnknownFields: true })` is an independent generation-only request-validation option, combinable with `name` and `mode: 'stream'`. It is neither a runtime marker nor a source-dispatch refusal flag; a source Host must load ordinary generated artifacts to enforce it. The [generator](<../generator/README.md#ordinary-remote-metadata>) owns publication, object policy, and optional tool-schema projection. +`@Remote` options contain only `name` and `mode`; input policy belongs to the consumer, not a method annotation. `method.input(options?: TypertRemoteInputOptions)` returns a cached `TypertRemoteValue`, with `strict?: boolean` as its only option. `method.input()` and `{ strict: false }` retain ordinary Remote handling: declared objects accept and strip unknown fields. `{ strict: true }` rejects undeclared fields recursively, while explicit JSON values and dictionary keys stay open and dictionary values retain their declared validation. Both named-argument envelopes reject extra arguments. Select one view (`const input = method.input({ strict: true })`) for matching `input.create().parse(raw)` and `input.jsonSchema`; this does not change Host dispatch. The [generator](<../generator/README.md#ordinary-remote-metadata>) owns publication and the paired codecs. -Ordinary `./remote` artifacts export `TYPERT_REMOTE` as a `TypertRemoteInfo`, extending the mountable `TypertRemoteContribution`. Its typed `methods[endpoint]` entries are the same direct descriptor objects found in `descriptors`, with derived `input` and `invoke(remote, input, signal?)`. Parameter documentation lives in `parameters[].description`; `result` is the single result codec, parser, and optional projection. Unary descriptors omit `mode`; streams use `'stream'`. Context-only receivers remain in `descriptors` and scoped declarations, not `methods`. +Ordinary `./remote` artifacts export `TYPERT_REMOTE` as a `TypertRemoteInfo`, extending the mountable `TypertRemoteContribution`. Its typed `methods[endpoint]` entries are the same direct descriptor objects found in `descriptors`, with derived `input(options?)` and `invoke(remote, input, signal?)`. Parameter documentation lives in `parameters[].description`; `result` is the single result codec, parser, and optional projection. Unary descriptors omit `mode`; streams use `'stream'`. Context-only receivers remain in `descriptors` and scoped declarations, not `methods`. -`createTypertRemoteInfo(spec)` from the published pure `@deepseek-ai/dsh-typert-protocol/remote-info` entry returns a class instance, not a constructor. It retains the static spec until `descriptors` or `methods` is read, then caches their shared index. Named input schemas are built and cached on first `input.create()`; their optional JSON Schema projection is derived and cached when read. The raw spec requires Zod factories, while consumer-facing `TypertSchema` promises only `parse(value: unknown): T`. JSON Schema comes from generator projections, not runtime `z.toJSONSchema()`. Metadata and schema access never invokes the network; the root also re-exports the factory but imports Cordis. +`createTypertRemoteInfo(spec)` from the published pure `@deepseek-ai/dsh-typert-protocol/remote-info` entry returns a class instance, not a constructor. Construction retains only the static spec; the first `descriptors` or `methods` read builds and caches their shared index. Each input-policy view, its parser, and its optional JSON Schema projection are independently lazy and cached; `method.input().create()` requests the ordinary parser. The raw spec requires Zod factories, while consumer-facing `TypertSchema` promises only `parse(value: unknown): T`. JSON Schema comes from generator projections, not runtime `z.toJSONSchema()`. Metadata and schema access never invokes the network; the root also re-exports the factory but imports Cordis. -The invoker takes a caller-owned, mounted, unscoped Client Remote and resolves its namespace method at call time. It preserves the namespace receiver, optional positional holes, and signal, forwarding the same `Promise>` or complete `RemoteStreamHandle` without an async wrapper. `DecodedOutput` defaults to the authored output; generation supplies `RemoteDecoded` only for byte-bearing unary methods, avoiding recursive remapping of ordinary JSON results; `result.create().parse()` retains the authored output type and does not normalize shared byte buffers. Documentation and parsing remain available independently of unsupported JSON Schema projection; `jsonSchemaError` explains an absent projection. Registry readers use `isTypertRemoteMethod(descriptor)` from the same pure entry to narrow a base descriptor to these direct capabilities. +The invoker takes a caller-owned, mounted, unscoped Client Remote and resolves its namespace method at call time. It preserves the namespace receiver, optional positional holes, and signal, forwarding the same `Promise>` or complete `RemoteStreamHandle` without an async wrapper. `DecodedOutput` defaults to the authored output; generation supplies `RemoteDecoded` only for byte-bearing unary methods, avoiding recursive remapping of ordinary JSON results; `result.create().parse()` retains the authored output type and does not normalize shared byte buffers. Documentation and parsing remain available when either input variant or the result lacks JSON Schema support; each variant's `jsonSchemaError` explains its absent projection. Registry readers use `isTypertRemoteMethod(descriptor)` from the same pure entry to narrow a base descriptor to these direct capabilities. ### Associating Host objects and Contexts with wire identities diff --git a/packages/typert/protocol/README.zh.md b/packages/typert/protocol/README.zh.md index c5f159451f..1f672d300c 100644 --- a/packages/typert/protocol/README.zh.md +++ b/packages/typert/protocol/README.zh.md @@ -48,13 +48,13 @@ export class GoalService extends TypertRemoteService { 流方法(`@Remote({ mode: 'stream' })`)返回 `Iterable`、`AsyncIterable` 或 `RemoteStream`。`In` 声明 Client 可以在同一条逻辑流上回送的项;方法通过 `this.ctx.invocation.uplink()` 读取它们,描述符携带其 codec。`RemoteInvocation` 还给出接收服务 `service`、发起调用的 `peer`(连接层接纳的一个 `PeerScope`)与载体 `signal`;非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`:树中第一个 `bindTypertRemote()` 绑定(每个 `TypertRemoteService` 构造函数都会建立一个)在根上注册该 accessor。生成的 Client 流方法返回 `RemoteStreamHandle`:在下行迭代之外提供 `send`、`end` 与 `dispose` 的句柄。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,原样透传。 -`@Remote({ rejectUnknownFields: true })` 是独立的、仅作用于生成阶段的请求校验选项,可与 `name` 和 `mode: 'stream'` 组合。它既不是运行时标记,也不是源码分发拒绝标志;源码 Host 必须加载普通生成产物才能执行该校验。[生成器](<../generator/README.zh.md#ordinary-remote-metadata>)负责发布、对象策略和可选的工具 schema 投影。 +`@Remote` 选项仅包含 `name` 和 `mode`;输入策略由消费方选择,而非方法注解。`method.input(options?: TypertRemoteInputOptions)` 返回缓存的 `TypertRemoteValue`,唯一选项是 `strict?: boolean`。`method.input()` 与 `{ strict: false }` 保留普通 Remote 处理方式:已声明对象接受并剥离未知字段。`{ strict: true }` 递归拒绝未声明字段,而显式 JSON 值和字典键保持开放,字典值仍按声明校验。两种具名参数封装都拒绝额外参数。选择一个视图(`const input = method.input({ strict: true })`),即可通过 `input.create().parse(raw)` 与 `input.jsonSchema` 取得匹配的解析和投影;这不会改变 Host 分发。[生成器](<../generator/README.zh.md#ordinary-remote-metadata>)负责发布及配套 codec。 -普通 `./remote` 产物将 `TYPERT_REMOTE` 导出为 `TypertRemoteInfo`,它扩展可挂载的 `TypertRemoteContribution`。类型化的 `methods[endpoint]` 条目就是 `descriptors` 中同一个直接方法描述符对象,并带有派生的 `input` 和 `invoke(remote, input, signal?)`。参数文档位于 `parameters[].description`;`result` 是唯一的结果 codec、解析器和可选投影。一元描述符省略 `mode`;流使用 `'stream'`。仅使用 Context 接收器的方法保留在 `descriptors` 和作用域声明中,不进入 `methods`。 +普通 `./remote` 产物将 `TYPERT_REMOTE` 导出为 `TypertRemoteInfo`,它扩展可挂载的 `TypertRemoteContribution`。类型化的 `methods[endpoint]` 条目就是 `descriptors` 中同一个直接方法描述符对象,并带有派生的 `input(options?)` 和 `invoke(remote, input, signal?)`。参数文档位于 `parameters[].description`;`result` 是唯一的结果 codec、解析器和可选投影。一元描述符省略 `mode`;流使用 `'stream'`。仅使用 Context 接收器的方法保留在 `descriptors` 和作用域声明中,不进入 `methods`。 -已发布的纯入口 `@deepseek-ai/dsh-typert-protocol/remote-info` 提供 `createTypertRemoteInfo(spec)`,返回类实例而非构造函数。它保留静态 spec,直到读取 `descriptors` 或 `methods` 时才构建并缓存两者共享的索引。具名输入 schema 在首次 `input.create()` 时构建并缓存;其可选 JSON Schema 投影在读取时派生并缓存。原始 spec 要求 Zod factory,而面向消费方的 `TypertSchema` 仅保证 `parse(value: unknown): T`。JSON Schema 来自生成器投影,而非运行时 `z.toJSONSchema()`。读取元数据和 schema 绝不发起网络调用;根入口也重新导出该 factory,但会导入 Cordis。 +已发布的纯入口 `@deepseek-ai/dsh-typert-protocol/remote-info` 提供 `createTypertRemoteInfo(spec)`,返回类实例而非构造函数。构造时只保留静态 spec;首次读取 `descriptors` 或 `methods` 时才构建并缓存两者共享的索引。每种输入策略的视图、解析器及可选 JSON Schema 投影都独立惰性求值并缓存;`method.input().create()` 请求普通解析器。原始 spec 要求 Zod factory,而面向消费方的 `TypertSchema` 仅保证 `parse(value: unknown): T`。JSON Schema 来自生成器投影,而非运行时 `z.toJSONSchema()`。读取元数据和 schema 绝不发起网络调用;根入口也重新导出该 factory,但会导入 Cordis。 -调用器接收调用方拥有、已挂载且无作用域的 Client Remote,并在调用时解析其命名空间方法。它保留命名空间接收者、可选位置参数空位和 signal,不使用 async 包装,直接转发同一个 `Promise>` 或完整 `RemoteStreamHandle`。`DecodedOutput` 默认采用原始输出类型;生成器仅为含字节的一元方法指定 `RemoteDecoded`,避免递归重映射普通 JSON 结果;`result.create().parse()` 保留原始输出类型,不会规范化共享字节缓冲区。文档与解析能力独立于不受支持的 JSON Schema 投影保留;`jsonSchemaError` 说明投影缺失的原因。Registry 读取方使用同一纯入口提供的 `isTypertRemoteMethod(descriptor)`,将基础描述符收窄为这些直接调用能力。 +调用器接收调用方拥有、已挂载且无作用域的 Client Remote,并在调用时解析其命名空间方法。它保留命名空间接收者、可选位置参数空位和 signal,不使用 async 包装,直接转发同一个 `Promise>` 或完整 `RemoteStreamHandle`。`DecodedOutput` 默认采用原始输出类型;生成器仅为含字节的一元方法指定 `RemoteDecoded`,避免递归重映射普通 JSON 结果;`result.create().parse()` 保留原始输出类型,不会规范化共享字节缓冲区。任一输入变体或结果不支持 JSON Schema 时,文档与解析能力仍然保留;各变体的 `jsonSchemaError` 说明其投影缺失的原因。Registry 读取方使用同一纯入口提供的 `isTypertRemoteMethod(descriptor)`,将基础描述符收窄为这些直接调用能力。 ### 把 Host 对象与 Context 关联到 wire identity diff --git a/packages/typert/protocol/src/index.ts b/packages/typert/protocol/src/index.ts index 9a9e5abe65..22d5036650 100644 --- a/packages/typert/protocol/src/index.ts +++ b/packages/typert/protocol/src/index.ts @@ -67,6 +67,7 @@ export type { TypertRemoteScopeNamespace, TypertRemoteContribution, TypertRemoteInfo, + TypertRemoteInputOptions, TypertRemoteMethod, TypertRemoteValue, TypertRemoteCodecSpec, @@ -114,14 +115,12 @@ export interface RemoteMethodMarker { readonly invocation: RemoteInvocationMarker } -/** Explicit endpoint, stream, and generated request-validation choices for one Remote method. */ +/** Explicit endpoint and stream choices for one Remote method. */ export interface RemoteMethodOptions { /** Exported endpoint method; omission retains the implementation member name. */ readonly name?: string /** `stream`: deliver each Iterable item over the shared logical-stream carrier. */ readonly mode?: 'stream' - /** Reject unknown fields in generated request-object codecs; source Hosts load the ordinary Typert artifact to enforce this. */ - readonly rejectUnknownFields?: true } type RemoteMethodDecorator = ( @@ -244,14 +243,13 @@ export function Remote( addMarkerInitializer(context, { kind: 'direct' }) } -const REMOTE_OPTION_KEYS = new Set(['name', 'mode', 'rejectUnknownFields']) +const REMOTE_OPTION_KEYS = new Set(['name', 'mode']) function validateRemoteOptions(options: RemoteMethodOptions): void { const keys = Reflect.ownKeys(options) if (keys.length === 0 || keys.some(key => typeof key !== 'string' || !REMOTE_OPTION_KEYS.has(key)) - || (Object.hasOwn(options, 'mode') && options.mode !== 'stream') - || (Object.hasOwn(options, 'rejectUnknownFields') && options.rejectUnknownFields !== true)) { - throw new TypeError('typert-protocol: Remote options require name, mode: "stream", or rejectUnknownFields: true') + || (Object.hasOwn(options, 'mode') && options.mode !== 'stream')) { + throw new TypeError('typert-protocol: Remote options require name or mode: "stream"') } if (options.name !== undefined) validateName('Remote export name', options.name) } diff --git a/packages/typert/protocol/src/remote-info.ts b/packages/typert/protocol/src/remote-info.ts index 5304d0b884..71209f2a7d 100644 --- a/packages/typert/protocol/src/remote-info.ts +++ b/packages/typert/protocol/src/remote-info.ts @@ -6,6 +6,7 @@ import type { TypertClientRemote, TypertRemoteCodecSpec, TypertRemoteInfo, + TypertRemoteInputOptions, TypertRemoteMethod, TypertRemoteParameterSpec, TypertRemoteSpec, @@ -54,8 +55,12 @@ class RemoteInfo implements TypertRemoteInfo { result: definition.result.description === undefined ? definition.result : new ResultValue(definition.result), } if (definition.invocation.kind === 'direct') { + const inputs: [NamedInput?, NamedInput?] = [] const direct = Object.assign(descriptor, { - input: new NamedInput(parameters), + input(options?: TypertRemoteInputOptions) { + const strict = options?.strict === true + return inputs[strict ? 1 : 0] ??= new NamedInput(parameters, strict) + }, invoke(remote: TypertClientRemote, input: object, signal?: AbortSignal) { const named = input as Readonly> const values = parameters.map(parameter => named[parameter.wire]) @@ -82,7 +87,8 @@ class RemoteInfo implements TypertRemoteInfo { */ export function isTypertRemoteMethod(descriptor: InvocationDescriptor): descriptor is Method { return descriptor.invocation.kind === 'direct' && descriptor.result.mode === 'strict' - && 'input' in descriptor && 'invoke' in descriptor && typeof descriptor.invoke === 'function' + && 'input' in descriptor && typeof descriptor.input === 'function' + && 'invoke' in descriptor && typeof descriptor.invoke === 'function' } function optional(parameter: Parameter): boolean { @@ -93,10 +99,14 @@ class NamedInput implements TypertRemoteValue { private schema?: z.ZodType private projection?: Pick - constructor(private readonly parameters: readonly Parameter[]) {} + constructor(private readonly parameters: readonly Parameter[], private readonly strict: boolean) {} + + private codec(parameter: Parameter): TypertRemoteCodecSpec { + return this.strict ? parameter.strictCodec : parameter.codec + } readonly create = (): z.ZodType => this.schema ??= z.strictObject(Object.fromEntries(this.parameters.map((parameter) => { - const value = parameter.codec.create() + const value = this.codec(parameter).create() return [parameter.wire, optional(parameter) ? value.optional() : value.nonoptional()] }))) @@ -105,15 +115,15 @@ class NamedInput implements TypertRemoteValue { private project(): NonNullable { if (this.projection !== undefined) return this.projection - const unavailable = this.parameters.find(parameter => parameter.codec.jsonSchema === undefined) + const unavailable = this.parameters.find(parameter => this.codec(parameter).jsonSchema === undefined) if (unavailable !== undefined) { - return this.projection = { jsonSchemaError: unavailable.codec.jsonSchemaError ?? `No JSON Schema for parameter ${unavailable.wire}` } + return this.projection = { jsonSchemaError: this.codec(unavailable).jsonSchemaError ?? `No JSON Schema for parameter ${unavailable.wire}` } } return this.projection = { jsonSchema: { type: 'object', properties: Object.fromEntries(this.parameters.map(parameter => [parameter.wire, { - ...parameter.codec.jsonSchema, + ...this.codec(parameter).jsonSchema, ...(parameter.description === undefined ? {} : { description: parameter.description }), }])), required: this.parameters.filter(parameter => !optional(parameter)).map(parameter => parameter.wire), diff --git a/packages/typert/protocol/src/types.ts b/packages/typert/protocol/src/types.ts index f7e6680df1..43743a34e8 100644 --- a/packages/typert/protocol/src/types.ts +++ b/packages/typert/protocol/src/types.ts @@ -458,14 +458,24 @@ export interface TypertRemoteValue { readonly jsonSchemaError?: string | undefined } +/** Consumer-owned input validation; never changes Host dispatch or the business method declaration. */ +export interface TypertRemoteInputOptions { + /** Reject undeclared fields in input objects; explicit dictionaries and JSON values remain open. */ + readonly strict?: boolean +} + /** A direct descriptor; DecodedOutput differs from its parser output only for carrier-reconstructed unary bytes. */ export type TypertRemoteMethod = Omit & (Mode extends 'stream' ? { readonly mode: 'stream' } : { readonly mode?: never }) & { /** Named invocation selects a direct Service method, not a Context receiver. */ readonly invocation: { readonly kind: 'direct' } - /** Named wire arguments derived from the ordered parameters and their codecs. */ - readonly input: TypertRemoteValue + /** + * Select matching named-input parsing and tool-schema projection without changing dispatch. + * @param options - consumer-owned strictness; omission preserves ordinary Remote object handling. + * @returns the cached view for this policy; its parser and projection remain lazy. + */ + input(options?: TypertRemoteInputOptions): TypertRemoteValue /** The descriptor's result or stream-item parser; parsing alone does not normalize native byte backing. */ readonly result: Extract, { readonly mode: 'strict' }> /** @@ -499,8 +509,10 @@ export interface TypertRemoteCodecSpec extends Omit { /** Explicit wire alias, notably for a Host lookup identity. */ readonly wire?: string - /** The only generated parser and projection definition for this parameter. */ + /** Ordinary Remote parser and projection for this parameter. */ readonly codec: TypertRemoteCodecSpec + /** Consumer-selected counterpart rejecting undeclared fields in explicit input objects. */ + readonly strictCodec: TypertRemoteCodecSpec } /** Static method facts; endpoint identity and named-call helpers are derived by the runtime factory. */ diff --git a/packages/typert/protocol/tests/protocol.spec.ts b/packages/typert/protocol/tests/protocol.spec.ts index 4be13eaaef..e013aedee7 100644 --- a/packages/typert/protocol/tests/protocol.spec.ts +++ b/packages/typert/protocol/tests/protocol.spec.ts @@ -309,11 +309,11 @@ describe('typert-protocol Remote declarations', () => { expect(() => Remote({ mode: 'stream', extra: true } as unknown as { mode: 'stream' })).toThrow('Remote options require') const bogusMode: string = 'duplex' expect(() => Remote({ mode: bogusMode } as { mode: 'stream' })) - .toThrow('Remote options require name, mode: "stream", or rejectUnknownFields: true') + .toThrow('Remote options require name or mode: "stream"') expect(() => Remote({ rejectUnknownFields: false } as never)).toThrow('Remote options require') expect(() => Remote({ strict: true } as never)).toThrow('Remote options require') expect(() => Remote({ committed: true } as never)).toThrow('Remote options require') - expect(() => Remote({ rejectUnknownFields: true })).not.toThrow() + expect(() => Remote({ rejectUnknownFields: true } as never)).toThrow('Remote options require') expect(() => Remote({})).toThrow('Remote options require') expect(() => RemoteScope('' as 'metaFixture')).toThrow('Scope key') expect(() => RemoteScope('metaFixture', 'bad/name')).toThrow('export name') @@ -328,12 +328,12 @@ describe('typert-protocol Remote declarations', () => { } }) - it('keeps codec generation options out of renamed stream markers', async () => { + it('retains transport identity and mode for renamed streams', async () => { const ctx = new Context() class ItemsService extends TypertRemoteService { constructor(owner: Context) { super(owner, 'itemsFixture') } - @Remote({ name: 'items', mode: 'stream', rejectUnknownFields: true }) + @Remote({ name: 'items', mode: 'stream' }) async *read(): AsyncIterable { yield 'first' } } try { diff --git a/packages/typert/protocol/tests/remote-info.spec.ts b/packages/typert/protocol/tests/remote-info.spec.ts index 089ed54e07..4a1dfac112 100644 --- a/packages/typert/protocol/tests/remote-info.spec.ts +++ b/packages/typert/protocol/tests/remote-info.spec.ts @@ -12,7 +12,7 @@ const text: TypertRemoteCodecSpec = { function definition(overrides: Partial = {}): TypertRemoteMethodSpec { return { service: 'goals', invocation: { kind: 'direct' }, description: 'Create a goal.', - parameters: [{ name: 'request', source: 'json', description: 'Goal name.', codec: text }], + parameters: [{ name: 'request', source: 'json', description: 'Goal name.', codec: text, strictCodec: text }], result: text, ...overrides, } @@ -28,7 +28,7 @@ describe('lazy Remote info', () => { const output = { ...text, description: 'Created name.', get jsonSchema() { outputProjectionReads++; return { type: 'string' } } } const spec = { package: '@fixture/goals', - get methods() { inventoryReads++; return { 'goals/create': definition({ parameters: [{ name: 'request', source: 'json', codec: input }], result: output }) } }, + get methods() { inventoryReads++; return { 'goals/create': definition({ parameters: [{ name: 'request', source: 'json', codec: input, strictCodec: input }], result: output }) } }, } const info = createTypertRemoteInfo(spec) expect(Object.getPrototypeOf(info)).not.toBe(Object.prototype) @@ -49,16 +49,16 @@ describe('lazy Remote info', () => { expect(method.mode).toBeUndefined() expect(isTypertRemoteMethod(method)).toBe(true) - const parser = method.input.create() - expect(method.input.create()).toBe(parser) + const parser = method.input().create() + expect(method.input().create()).toBe(parser) expect(create).toHaveBeenCalledTimes(1) expect(parser.parse({ request: 'ship' })).toEqual({ request: 'ship' }) expect(inputProjectionReads + outputProjectionReads).toBe(0) - const projection = method.input.jsonSchema + const projection = method.input().jsonSchema expect(projection).toEqual({ type: 'object', properties: { request: { type: 'string' } }, required: ['request'], additionalProperties: false }) - expect(method.input.jsonSchema).toBe(projection) + expect(method.input().jsonSchema).toBe(projection) const reads = inputProjectionReads - expect(method.input.jsonSchemaError).toBeUndefined() + expect(method.input().jsonSchemaError).toBeUndefined() expect(inputProjectionReads).toBe(reads) expect(outputProjectionReads).toBe(0) expect(method.result.create).toBe(output.create) @@ -68,26 +68,58 @@ describe('lazy Remote info', () => { expect(outputProjectionReads).toBe(1) }) + it('selects and caches independent parser/projection views without changing dispatch codecs', () => { + const create = vi.fn(() => z.object({ title: z.string() })) + const createStrict = vi.fn(() => z.strictObject({ title: z.string() })) + const codec = { ...text, create, jsonSchema: { type: 'object', properties: { title: { type: 'string' } }, required: ['title'], additionalProperties: true } } + const strictCodec = { ...codec, create: createStrict, jsonSchema: { ...codec.jsonSchema, additionalProperties: false } } + const info = createTypertRemoteInfo({ package: '@fixture/goals', methods: { + 'goals/create': definition({ parameters: [{ name: 'request', source: 'json', codec, strictCodec }] }), + } }) + const method = info.methods['goals/create']! + const normal = method.input() + const strict = method.input({ strict: true }) + expect(method.input({ strict: false })).toBe(normal) + expect(method.input({})).toBe(normal) + expect(method.input({ strict: true })).toBe(strict) + expect(strict).not.toBe(normal) + expect(create).not.toHaveBeenCalled() + expect(createStrict).not.toHaveBeenCalled() + expect(normal.jsonSchema).toMatchObject({ properties: { request: { additionalProperties: true } } }) + expect(strict.jsonSchema).toMatchObject({ properties: { request: { additionalProperties: false } } }) + expect(create).not.toHaveBeenCalled() + expect(createStrict).not.toHaveBeenCalled() + const extra = { request: { title: 'ship', ignored: true } } + expect(normal.create().parse(extra)).toEqual({ request: { title: 'ship' } }) + expect(create).toHaveBeenCalledTimes(1) + expect(createStrict).not.toHaveBeenCalled() + expect(() => strict.create().parse(extra)).toThrow() + expect(strict.create().parse({ request: { title: 'ship' } })).toEqual({ request: { title: 'ship' } }) + expect(createStrict).toHaveBeenCalledTimes(1) + expect(method.parameters[0]?.codec).toBe(codec) + expect(normal.create().parse(extra)).toEqual({ request: { title: 'ship' } }) + }) + it('preserves named omissions, lookup aliases, strict outer fields, and positional holes', () => { const info = createTypertRemoteInfo({ package: '@fixture/goals', methods: { 'goals/create': definition({ parameters: [ - { name: 'agent', wire: 'agentId', source: 'lookup', lookup: 'agent', acceptsUndefined: true, description: 'Owning Agent.', codec: text }, - { name: 'label', source: 'json', acceptsUndefined: true, codec: text }, + { name: 'agent', wire: 'agentId', source: 'lookup', lookup: 'agent', acceptsUndefined: true, description: 'Owning Agent.', codec: text, strictCodec: text }, + { name: 'label', source: 'json', acceptsUndefined: true, codec: text, strictCodec: text }, ], cancellation: { parameter: 'signal' }, scope: { context: 'agent', wire: 'agentId' }, }), } }) const method = info.methods['goals/create']! - const parser = method.input.create() + const parser = method.input().create() expect(parser.parse({ agentId: 'agent' })).toEqual({ agentId: 'agent' }) expect(parser.parse({ agentId: 'agent', label: undefined })).toEqual({ agentId: 'agent', label: undefined }) for (const invalid of [null, [], {}, { agentId: undefined }, { agentId: 'agent', label: null }, { agentId: 'agent', extra: true }]) { expect(() => parser.parse(invalid)).toThrow() } - expect(method.input.jsonSchema?.required).toEqual(['agentId']) - expect(method.input.jsonSchema).toMatchObject({ properties: { agentId: { description: 'Owning Agent.' } } }) + expect(method.input().jsonSchema?.required).toEqual(['agentId']) + expect(method.input().jsonSchema).toMatchObject({ properties: { agentId: { description: 'Owning Agent.' } } }) const signal = new AbortController().signal const promise = Promise.resolve({ ok: true as const, value: 'created' }) const create = vi.fn(function (this: object, ...args: unknown[]) { @@ -105,14 +137,14 @@ describe('lazy Remote info', () => { it('requires declared fields even when their parser accepts undefined and permits empty methods', () => { const unknown = { ...text, create: () => z.unknown(), jsonSchema: {} } const info = createTypertRemoteInfo({ package: '@fixture/goals', methods: { - 'goals/required': definition({ parameters: [{ name: 'value', source: 'json', codec: unknown }] }), + 'goals/required': definition({ parameters: [{ name: 'value', source: 'json', codec: unknown, strictCodec: unknown }] }), 'goals/empty': definition({ parameters: [] }), } }) - expect(() => info.methods['goals/required']!.input.create().parse({})).toThrow() + expect(() => info.methods['goals/required']!.input().create().parse({})).toThrow() const empty = info.methods['goals/empty']! - expect(empty.input.create().parse({})).toEqual({}) - expect(() => empty.input.create().parse({ extra: true })).toThrow() - expect(empty.input.jsonSchema?.required).toEqual([]) + expect(empty.input().create().parse({})).toEqual({}) + expect(() => empty.input().create().parse({ extra: true })).toThrow() + expect(empty.input().jsonSchema?.required).toEqual([]) const call = vi.fn(() => Promise.resolve({ ok: true as const, value: 'empty' })) const remote: object = { goals: { empty: call } } void empty.invoke(remote as TypertClientRemote, {}, new AbortController().signal) @@ -127,7 +159,9 @@ describe('lazy Remote info', () => { expect(isTypertRemoteMethod(base)).toBe(false) const strict = { ...base, result: text } expect(isTypertRemoteMethod(strict)).toBe(false) - const inputOnly = { ...strict, input: { create: () => z.strictObject({}) } } + const legacy = { ...strict, input: { create: () => z.strictObject({}) } } + expect(isTypertRemoteMethod(legacy)).toBe(false) + const inputOnly = { ...strict, input: () => ({ create: () => z.strictObject({}) }) } expect(isTypertRemoteMethod(inputOnly)).toBe(false) const notCallable = { ...inputOnly, invoke: undefined } expect(isTypertRemoteMethod(notCallable)).toBe(false) @@ -165,13 +199,13 @@ describe('lazy Remote info', () => { const encode = vi.fn((value: unknown) => value) const info = createTypertRemoteInfo({ package: '@fixture/goals', methods: { 'goals/create': definition({ - parameters: [{ name: 'request', source: 'json', description: 'Still documented.', codec: unsupported }], + parameters: [{ name: 'request', source: 'json', description: 'Still documented.', codec: unsupported, strictCodec: unsupported }], result: { ...unsupported, description: 'Still a result.', decode, encode }, }), } }) const method = info.methods['goals/create']! - expect(method.input.jsonSchema).toBeUndefined() - expect(method.input.jsonSchemaError).toBe('Unsupported tool type.') + expect(method.input().jsonSchema).toBeUndefined() + expect(method.input().jsonSchemaError).toBe('Unsupported tool type.') expect(method.result.jsonSchema).toBeUndefined() expect(method.result.jsonSchemaError).toBe('Unsupported tool type.') expect(method.result.description).toBe('Still a result.') @@ -179,12 +213,12 @@ describe('lazy Remote info', () => { expect(method.result.decode).toBe(decode) expect(method.result.encode).toBe(encode) expect(create).not.toHaveBeenCalled() - expect(() => method.input.create()).toThrow('invalid schema factory') - expect(() => method.input.create()).toThrow('invalid schema factory') + expect(() => method.input().create()).toThrow('invalid schema factory') + expect(() => method.input().create()).toThrow('invalid schema factory') const unprojected = createTypertRemoteInfo({ package: '@fixture/goals', methods: { - 'goals/create': definition({ parameters: [{ name: 'request', source: 'json', codec: { ...text, jsonSchema: undefined } }] }), + 'goals/create': definition({ parameters: [{ name: 'request', source: 'json', codec: { ...text, jsonSchema: undefined }, strictCodec: text }] }), } }).methods['goals/create']! - expect(unprojected.input.jsonSchemaError).toContain('request') - expect(unprojected.input.create().parse({ request: 'valid' })).toEqual({ request: 'valid' }) + expect(unprojected.input().jsonSchemaError).toContain('request') + expect(unprojected.input().create().parse({ request: 'valid' })).toEqual({ request: 'valid' }) }) }) diff --git a/packages/typert/registry/README.i18n.yaml b/packages/typert/registry/README.i18n.yaml index e827f933d0..76539a521a 100644 --- a/packages/typert/registry/README.i18n.yaml +++ b/packages/typert/registry/README.i18n.yaml @@ -21,8 +21,8 @@ en: 1ec4b304a024bb3c zh: f17e796c26faabab /deepseek-ai-dsh-typert-registry/use-this-package/querying-schemas-and-reflection: - en: 26261448d6a9afef - zh: 2718e7cfdc2b58ba + en: 254d265a3e5b1638 + zh: bfa325ec79afbc72 /deepseek-ai-dsh-typert-registry/use-this-package/registering-a-contribution: en: 9ca425515904d5c9 zh: b251226ecfa32002 diff --git a/packages/typert/registry/README.md b/packages/typert/registry/README.md index 805c49427d..613891b6b1 100644 --- a/packages/typert/registry/README.md +++ b/packages/typert/registry/README.md @@ -39,7 +39,7 @@ Load the registry plugin; the Client face is installed the same way by the Clien Consumers read schemas with `get(key)`, `resolve(key)`, or `list(filter?)` and package reflection with `getPackage(name, face?)` or `listPackages(filter?)`. The first schema read materializes one process-realm instance; later reads reuse it. `resolve()` distinguishes a malformed key, an absent package, and a registered package that contributes no schema under that name, each with its own error. `toJSONSchema(key)` projects the materialized Zod schema to JSON Schema without caching the projection. -Invocation information is available through existing `ctx.typert.local.get/list` and `ctx.typert.remotes.get/list` queries. They return the registered descriptor objects, including generated direct methods with `input`, `result`, and `invoke`; there is no separate metadata store or RPC API. Use `isTypertRemoteMethod(descriptor)` from `@deepseek-ai/dsh-typert-protocol/remote-info` to narrow the base descriptor. This guard exposes inputs as `object` and outputs as `unknown`, not endpoint-dependent generics. The [ordinary generated `TYPERT_REMOTE.methods` index](<../generator/README.md#ordinary-remote-metadata>) supplies precise per-method static types and aliases those same descriptors within its artifact instance. +Invocation information is available through existing `ctx.typert.local.get/list` and `ctx.typert.remotes.get/list` queries. They return the registered descriptor objects, including generated direct methods with `input(options?)`, `result`, and `invoke`; there is no separate metadata store or RPC API. Consumers select `method.input({ strict: true })` for matching strict parsing and JSON Schema without changing registration or Host dispatch. Use `isTypertRemoteMethod(descriptor)` from `@deepseek-ai/dsh-typert-protocol/remote-info` to narrow the base descriptor. This guard exposes inputs as `object` and outputs as `unknown`, not endpoint-dependent generics. The [ordinary generated `TYPERT_REMOTE.methods` index](<../generator/README.md#ordinary-remote-metadata>) supplies precise per-method static types and aliases those same descriptors within its artifact instance. ### Registering a contribution diff --git a/packages/typert/registry/README.zh.md b/packages/typert/registry/README.zh.md index d1f8756bb0..3bc30c0e49 100644 --- a/packages/typert/registry/README.zh.md +++ b/packages/typert/registry/README.zh.md @@ -39,7 +39,7 @@ kind: "package-reference" 消费方用 `get(key)`、`resolve(key)` 或 `list(filter?)` 读取 schema,用 `getPackage(name, face?)` 或 `listPackages(filter?)` 读取包反射。首次读取会物化一个进程 realm 内的 schema 实例,后续读取复用该实例。`resolve()` 能区分格式错误的键、未注册的包,以及已注册但未以该名称提供 schema 的包,各自给出不同的错误。`toJSONSchema(key)` 把已物化的 Zod schema 投影为 JSON Schema,但不缓存该投影。 -调用信息可通过现有的 `ctx.typert.local.get/list` 与 `ctx.typert.remotes.get/list` 查询读取。它们返回已注册的描述符对象,包括带有 `input`、`result` 和 `invoke` 的生成直接方法;不存在独立的元数据存储或 RPC API。使用 `@deepseek-ai/dsh-typert-protocol/remote-info` 提供的 `isTypertRemoteMethod(descriptor)` 收窄基础描述符。该守卫将输入暴露为 `object`、输出暴露为 `unknown`,不提供依赖端点的泛型。[普通生成产物的 `TYPERT_REMOTE.methods` 索引](<../generator/README.zh.md#ordinary-remote-metadata>)提供精确的各方法静态类型,并在该产物实例内引用同一批描述符。 +调用信息可通过现有的 `ctx.typert.local.get/list` 与 `ctx.typert.remotes.get/list` 查询读取。它们返回已注册的描述符对象,包括带有 `input(options?)`、`result` 和 `invoke` 的生成直接方法;不存在独立的元数据存储或 RPC API。消费方选择 `method.input({ strict: true })`,即可取得匹配的严格解析与 JSON Schema,而不改变注册或 Host 分发。使用 `@deepseek-ai/dsh-typert-protocol/remote-info` 提供的 `isTypertRemoteMethod(descriptor)` 收窄基础描述符。该守卫将输入暴露为 `object`、输出暴露为 `unknown`,不提供依赖端点的泛型。[普通生成产物的 `TYPERT_REMOTE.methods` 索引](<../generator/README.zh.md#ordinary-remote-metadata>)提供精确的各方法静态类型,并在该产物实例内引用同一批描述符。 ### 注册贡献 diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index b3562dbb91..e2d9cd7543 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -106,7 +106,7 @@ describe('TypertRegistry', () => { const info = createTypertRemoteInfo({ package: '@fixture/remote', methods: { 'goals/create': { service: 'goals', invocation: { kind: 'direct' }, description: 'Create a goal.', - parameters: [{ name: 'request', source: 'json', codec }], result: codec, + parameters: [{ name: 'request', source: 'json', codec, strictCodec: codec }], result: codec, }, } }) const descriptor = info.methods['goals/create']! @@ -125,7 +125,7 @@ describe('TypertRegistry', () => { expect(method).toBe(descriptor) expect(registry.list()[0]).toBe(descriptor) if (method === undefined || !isTypertRemoteMethod(method)) throw new Error('Generated method is missing') - expect(method.input.create().parse({ request: 'goal' })).toEqual({ request: 'goal' }) + expect(method.input().create().parse({ request: 'goal' })).toEqual({ request: 'goal' }) expect(method.result).toBe(codec) } await owner.dispose() From d5bf4b348aeca51b9043b94e058efa95cd484f36 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:19:42 +0800 Subject: [PATCH 05/17] refactor(remote): drop unrelated churn from the metadata change --- ...26-10-05-working-directory-attribution.i18n.yaml | 4 ++-- .../2026-10-05-working-directory-attribution.md | 2 +- .../2026-10-05-working-directory-attribution.zh.md | 2 +- .../api/session-controller/src/client/transport.ts | 3 +++ .../tests/transport.client.spec.ts | 2 +- .../api/workspace-controller/src/client/index.ts | 3 +++ .../tests/transport.client.spec.ts | 2 +- packages/typert/generator/src/analyzer.ts | 13 +++---------- 8 files changed, 15 insertions(+), 16 deletions(-) diff --git a/docs/persistence-changes/2026-10-05-working-directory-attribution.i18n.yaml b/docs/persistence-changes/2026-10-05-working-directory-attribution.i18n.yaml index 080248eabe..1ae5d09e4f 100644 --- a/docs/persistence-changes/2026-10-05-working-directory-attribution.i18n.yaml +++ b/docs/persistence-changes/2026-10-05-working-directory-attribution.i18n.yaml @@ -21,8 +21,8 @@ en: 39494aa3af994e6f zh: 07c2880503668176 /2026-10-05-working-directory-attribution/verification: - en: 9b79379292881d10 - zh: 54d670c09ce1bfc7 + en: 5963942c0d87e518 + zh: 5dbb6ac0fca1f279 /2026-10-05-working-directory-attribution/dev-note: en: b75c8458928b72b9 zh: 003ba4e17b4b5269 diff --git a/docs/persistence-changes/2026-10-05-working-directory-attribution.md b/docs/persistence-changes/2026-10-05-working-directory-attribution.md index 6496413c37..80996eee5c 100644 --- a/docs/persistence-changes/2026-10-05-working-directory-attribution.md +++ b/docs/persistence-changes/2026-10-05-working-directory-attribution.md @@ -52,7 +52,7 @@ Existing records remain valid. The additive working-directory source kind is att ## Verification -pnpm exec vitest run packages/session/working-directory/tests packages/shell/tool-bash/tests/tools.spec.ts packages/shell/tool-pwsh/tests/tools.spec.ts packages/subagent/subagent/tests/schedule-tools.spec.ts: 5 files and 207 tests passed. +pnpm exec vitest run packages/session/working-directory/tests packages/shell/tool-bash/tests/tools.spec.ts packages/shell/tool-pwsh/tests/tools.spec.ts packages/subagent/subagent-in-process-driver/tests/schedule-tools.spec.ts: 4 files and 203 tests passed. ## Dev Note diff --git a/docs/persistence-changes/2026-10-05-working-directory-attribution.zh.md b/docs/persistence-changes/2026-10-05-working-directory-attribution.zh.md index 56157cc7d2..e7ed00e08f 100644 --- a/docs/persistence-changes/2026-10-05-working-directory-attribution.zh.md +++ b/docs/persistence-changes/2026-10-05-working-directory-attribution.zh.md @@ -52,7 +52,7 @@ changes: ## 验证 -pnpm exec vitest run packages/session/working-directory/tests packages/shell/tool-bash/tests/tools.spec.ts packages/shell/tool-pwsh/tests/tools.spec.ts packages/subagent/subagent/tests/schedule-tools.spec.ts:5 个文件、207 个测试通过。 +pnpm exec vitest run packages/session/working-directory/tests packages/shell/tool-bash/tests/tools.spec.ts packages/shell/tool-pwsh/tests/tools.spec.ts packages/subagent/subagent-in-process-driver/tests/schedule-tools.spec.ts:4 个文件、203 个测试通过。 ## 开发备注 diff --git a/packages/api/session-controller/src/client/transport.ts b/packages/api/session-controller/src/client/transport.ts index 5cb39c1514..fc2a11a0bb 100644 --- a/packages/api/session-controller/src/client/transport.ts +++ b/packages/api/session-controller/src/client/transport.ts @@ -119,6 +119,9 @@ export function createSessionControlStream( const stream = remote.$stream({ name: 'session control stream', open: signal => remote.session.control(signal), + ended: accepted => accepted + ? new RemoteStreamCarrierError('session control stream ended without a terminal result') + : new Error('session control stream ended before its opening snapshot'), ...(options.carrierFailed === undefined ? {} : { carrierFailed: options.carrierFailed }), }) return new RemoteSnapshotStream(stream, { diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index 781fec4fa9..87d8fd7479 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -695,7 +695,7 @@ describe('Session Client stream adapters', () => { before.start() await vi.waitFor(() => { expect(beforeFailed).toHaveBeenCalledOnce() }) expect(beforeFailed.mock.calls[0]?.[0]).toMatchObject({ - message: 'session control stream ended before its opening item', + message: 'session control stream ended before its opening snapshot', }) await before.dispose() diff --git a/packages/api/workspace-controller/src/client/index.ts b/packages/api/workspace-controller/src/client/index.ts index 304ee7899d..c3182102a7 100644 --- a/packages/api/workspace-controller/src/client/index.ts +++ b/packages/api/workspace-controller/src/client/index.ts @@ -82,6 +82,9 @@ export function createWorkspaceStateStream( const stream = remote.$stream({ name: 'Workspace state stream', open: signal => remote.workspace.follow(signal), + ended: accepted => accepted + ? new RemoteStreamCarrierError('Workspace state stream ended without a terminal result') + : new Error('Workspace state stream ended before its opening snapshot'), ...(options.carrierFailed === undefined ? {} : { carrierFailed: options.carrierFailed }), }) return new RemoteSnapshotStream(stream, { diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index 5c38138c98..b74ddfb394 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -268,7 +268,7 @@ describe('Workspace state stream', () => { { name: 'a normal end before the baseline', items: [] as WorkspaceFollowFrame[], - message: 'ended before its opening item', + message: 'ended before its opening snapshot', }, ])('reports $name as a terminal failure', async ({ items, message }, { mock, start }) => { const { remote } = await gatewayClient(mock, start) diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 21b3cf58fc..90f901316b 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -673,13 +673,6 @@ interface FaceAnalyzerOptions { readonly crossFaceLinks: Map } -/** Direct `@Remote` choices read from one decorator. */ -interface DirectRemoteMarker { - readonly kind: 'direct' - readonly exportName?: string - readonly mode?: 'stream' -} - class FaceAnalyzer { private readonly root: string private readonly face: TypertFace @@ -1135,7 +1128,7 @@ class FaceAnalyzer { binding: GatewayBinding, method: ts.MethodDeclaration, invocation: - | DirectRemoteMarker + | { readonly kind: 'direct'; readonly exportName?: string; readonly mode?: 'stream' } | { readonly kind: 'context'; readonly context: string; readonly exportName?: string }, ): InvocationModel { if (visibilityOf(method) !== 'public' || hasModifier(method, ts.SyntaxKind.StaticKeyword)) { @@ -1392,11 +1385,11 @@ class FaceAnalyzer { private remoteMarker( member: ts.ClassElement, ): - | DirectRemoteMarker + | { readonly kind: 'direct'; readonly exportName?: string; readonly mode?: 'stream' } | { readonly kind: 'context'; readonly context: string; readonly exportName?: string } | undefined { let found: - | DirectRemoteMarker + | { readonly kind: 'direct'; readonly exportName?: string; readonly mode?: 'stream' } | { readonly kind: 'context'; readonly context: string; readonly exportName?: string } | undefined for (const decorator of ts.canHaveDecorators(member) ? ts.getDecorators(member) ?? [] : []) { From ae8d417dcf1ea3d8934a5605439301bf8af8b42a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:48:33 +0800 Subject: [PATCH 06/17] refactor(typert): name the tool-schema projection for its owning standard --- packages/typert/generator/README.i18n.yaml | 4 +-- packages/typert/generator/README.md | 2 +- packages/typert/generator/README.zh.md | 2 +- packages/typert/generator/src/emitter.ts | 8 ++--- .../{remote-json-schema.ts => tool-schema.ts} | 19 +++++++----- .../generator/tests/remote-metadata.spec.ts | 31 +++++++++++++++++++ .../generator/tests/remote-model.spec.ts | 2 +- 7 files changed, 51 insertions(+), 17 deletions(-) rename packages/typert/generator/src/{remote-json-schema.ts => tool-schema.ts} (88%) diff --git a/packages/typert/generator/README.i18n.yaml b/packages/typert/generator/README.i18n.yaml index 9a763d4ae6..b36fdd7f4f 100644 --- a/packages/typert/generator/README.i18n.yaml +++ b/packages/typert/generator/README.i18n.yaml @@ -21,8 +21,8 @@ en: c7078becc8f12609 zh: 4b8838b559ecb677 /deepseek-ai-dsh-typert-generator/use-this-package/ordinary-remote-metadata: - en: 306e8835ee10e704 - zh: f8ad8f08e1316b16 + en: 0baa110b030d9b80 + zh: 67ff18ef50c35002 /deepseek-ai-dsh-typert-generator/use-this-package/analyzing-a-workspace-statically: en: 8227097859169cdf zh: 249790ed62e8558a diff --git a/packages/typert/generator/README.md b/packages/typert/generator/README.md index 84af4f2e53..bb3dd232a9 100644 --- a/packages/typert/generator/README.md +++ b/packages/typert/generator/README.md @@ -54,7 +54,7 @@ Generated declarations use type-only imports for owner and schema types. Importi Missing JSDoc and unsupported tool JSON Schema do not block otherwise valid ordinary generation or its strict codecs. Metadata retains parsers and available documentation independently of projection: each unsupported input variant or output projection omits `jsonSchema` and records its own `jsonSchemaError`. A tool consumer explicitly selects methods and requires its documentation and supported input schema; an output parser is required, but output JSON Schema is optional unless that consumer requires it. Metadata alone does not make every Remote method a tool. -Every generated parameter spec contains both `codec` and `strictCodec`, derived from the same checker-resolved type model; the analyzer and model carry no per-method input policy. `method.input()` and `{ strict: false }` select the normal codec used by Host dispatch, whose declared objects accept and strip unknown fields. `method.input({ strict: true })` selects recursively closed declared objects for both parsing and supported JSON Schema. Both named-argument envelopes reject extra arguments; explicit JSON and dictionary keys remain open, while constrained dictionary values still validate. `result` keeps its single normal codec. Tool projection supports scalars, objects, arrays, and disjoint unions; canonical `JsonValue` and string-keyed JSON dictionaries need no recursive references. Numeric bounds and other semantic rules absent from TypeScript remain with the owning operation. +Every generated parameter spec contains both `codec` and `strictCodec`, derived from the same checker-resolved type model; the analyzer and model carry no per-method input policy. `method.input()` and `{ strict: false }` select the normal codec used by Host dispatch, whose declared objects accept and strip unknown fields. `method.input({ strict: true })` selects recursively closed declared objects for both parsing and supported JSON Schema. Both named-argument envelopes reject extra arguments; explicit JSON and dictionary keys remain open, while constrained dictionary values still validate. `result` keeps its single normal codec. Tool projection targets the tool-schema subset that [`@deepseek-ai/dsh-tools`](../../core/tools/src/json-schema.ts) enforces on tool parameters, tool output, and the generated PTC SDK; it supports scalars, objects, arrays, and disjoint unions, while canonical `JsonValue` and string-keyed JSON dictionaries need no recursive references. A type outside that subset is reported instead of projected more weakly. Numeric bounds and other semantic rules absent from TypeScript remain with the owning operation. `WorkspaceTypertGenerator` analyzes all contributors of each selected face before filtering emitted packages, so a package subset retains the same declaration merges and output bytes as a complete build. diff --git a/packages/typert/generator/README.zh.md b/packages/typert/generator/README.zh.md index 8fbbe1084b..0fff52d6a1 100644 --- a/packages/typert/generator/README.zh.md +++ b/packages/typert/generator/README.zh.md @@ -54,7 +54,7 @@ factory 返回类实例,并保留 spec,直到 `descriptors` 或 `methods` ge 缺少 JSDoc 或不支持工具 JSON Schema,不会阻止其他方面有效的普通生成及其严格 codec。元数据独立于投影保留解析器与可用文档:每个不受支持的输入变体或输出投影都省略 `jsonSchema`,并记录各自的 `jsonSchemaError`。工具消费方显式选择方法,并要求其所需文档和受支持的输入 schema;输出解析器是必需的,但输出 JSON Schema 是可选的,除非该消费方要求提供。仅有元数据不会让每个 Remote 方法自动成为工具。 -每个生成参数 spec 都包含从同一个 checker 解析后类型模型派生的 `codec` 和 `strictCodec`;分析器与模型不携带逐方法输入策略。`method.input()` 与 `{ strict: false }` 选择 Host 分发使用的普通 codec,其已声明对象接受并剥离未知字段。`method.input({ strict: true })` 为解析和受支持的 JSON Schema 选择递归关闭的已声明对象。两种具名参数封装都拒绝额外参数;显式 JSON 与字典键保持开放,有约束的字典值仍然校验。`result` 保留唯一的普通 codec。工具投影支持标量、对象、数组和互斥联合类型;规范的 `JsonValue` 与字符串键 JSON 字典无需递归引用。TypeScript 未表达的数值范围与其他语义规则仍由操作拥有者负责。 +每个生成参数 spec 都包含从同一个 checker 解析后类型模型派生的 `codec` 和 `strictCodec`;分析器与模型不携带逐方法输入策略。`method.input()` 与 `{ strict: false }` 选择 Host 分发使用的普通 codec,其已声明对象接受并剥离未知字段。`method.input({ strict: true })` 为解析和受支持的 JSON Schema 选择递归关闭的已声明对象。两种具名参数封装都拒绝额外参数;显式 JSON 与字典键保持开放,有约束的字典值仍然校验。`result` 保留唯一的普通 codec。工具投影的目标是 [`@deepseek-ai/dsh-tools`](../../core/tools/src/json-schema.ts) 对工具参数、工具输出和生成的 PTC SDK 强制执行的 tool-schema 子集;它支持标量、对象、数组和互斥联合类型,规范的 `JsonValue` 与字符串键 JSON 字典无需递归引用。子集之外的类型会被报告,而不是以更弱的 schema 投影。TypeScript 未表达的数值范围与其他语义规则仍由操作拥有者负责。 `WorkspaceTypertGenerator` 先分析每个选中 face 的所有贡献方,再筛选要生成产物的包,因此包子集保留与完整构建相同的声明合并结果和输出字节。 diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index e3b348d5d3..6b1684959b 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -23,7 +23,7 @@ import type { } from './model.ts' import { TypeGraphRenderer } from './renderer.ts' import { childTypeNodeIds } from './model.ts' -import { remoteJsonSchema, RemoteJsonSchemaError } from './remote-json-schema.ts' +import { projectToolSchema, ToolSchemaError } from './tool-schema.ts' /** Failure to project a modeled construct into an emitted artifact. */ export class TypertEmitError extends Error { @@ -337,7 +337,7 @@ export class FaceModelEmitter { parameter.boundary, schemas.boundary(parameterBoundaryKey(invocation, index, strict)), undefined, - toolProjection(() => remoteJsonSchema( + toolProjection(() => projectToolSchema( this.renderer, parameter.boundary.codecType, strict, parameter.boundary.acceptsUndefined, )), ), 6).trimStart()},`) @@ -361,7 +361,7 @@ export class FaceModelEmitter { invocation.result, schemas.boundary(resultBoundaryKey(invocation)), schemas.encoder(resultBoundaryKey(invocation)), - toolProjection(() => remoteJsonSchema(this.renderer, invocation.result.codecType)), + toolProjection(() => projectToolSchema(this.renderer, invocation.result.codecType)), returns, ), 2).trimStart()},`) lines.push(` sourceLocation: ${JSON.stringify(invocation.location)},`) @@ -570,7 +570,7 @@ function toolProjection(project: () => Record): string { try { return `jsonSchema: ${JSON.stringify(project())}` } catch (error) { - if (!(error instanceof RemoteJsonSchemaError)) throw error + if (!(error instanceof ToolSchemaError)) throw error return `jsonSchemaError: ${quote(error.message)}` } } diff --git a/packages/typert/generator/src/remote-json-schema.ts b/packages/typert/generator/src/tool-schema.ts similarity index 88% rename from packages/typert/generator/src/remote-json-schema.ts rename to packages/typert/generator/src/tool-schema.ts index 6ce8066f24..d955da7ed4 100644 --- a/packages/typert/generator/src/remote-json-schema.ts +++ b/packages/typert/generator/src/tool-schema.ts @@ -1,11 +1,11 @@ -/** Lossless JSON Schema projection for Remote methods selected by command and tool consumers. */ +/** Lossless projection into the harness tool-schema subset. */ import type { MemberModel, TypeNodeId } from './model.ts' import { TypeGraphRenderer } from './renderer.ts' -/** A valid Remote codec whose type cannot be represented by the tool-schema subset. */ -export class RemoteJsonSchemaError extends Error { - override name = 'RemoteJsonSchemaError' +/** A resolved codec whose type the tool-schema subset cannot represent without weakening constraints. */ +export class ToolSchemaError extends Error { + override name = 'ToolSchemaError' } interface Schema extends Record { @@ -21,18 +21,21 @@ interface Schema extends Record { } /** - * Project one resolved Remote type without weakening unsupported constraints. + * Project one resolved type into the tool-schema subset that `@deepseek-ai/dsh-tools` + * enforces on tool parameters, tool output, and the generated PTC SDK. A type the + * subset cannot express without weakening constraints raises {@link ToolSchemaError}, + * which callers report as an absent projection rather than a narrower schema. * @param renderer - complete analyzed type graph. * @param type - resolved wire type. * @param closedObjects - close declared request objects with `additionalProperties: false`. * @param optional - omit a top-level undefined alternative supplied by an optional argument. - * @returns an acyclic schema in the command/tool subset. + * @returns an acyclic schema in the tool-schema subset. */ -export function remoteJsonSchema( +export function projectToolSchema( renderer: TypeGraphRenderer, type: TypeNodeId, closedObjects = false, optional = false, ): Record { const active = new Set() - const fail = (subject: string): never => { throw new RemoteJsonSchemaError(`typert Remote JSON Schema: ${subject}`) } + const fail = (subject: string): never => { throw new ToolSchemaError(`typert tool schema: ${subject}`) } const project = (id: TypeNodeId, optional = false): Schema => { const node = renderer.node(id) if (node.json) return {} diff --git a/packages/typert/generator/tests/remote-metadata.spec.ts b/packages/typert/generator/tests/remote-metadata.spec.ts index 5c138313b5..275920225e 100644 --- a/packages/typert/generator/tests/remote-metadata.spec.ts +++ b/packages/typert/generator/tests/remote-metadata.spec.ts @@ -349,6 +349,37 @@ declare module '@fixture/remote/types' { expect(method.result.create().parse({ ref: 'created' })).toEqual({ ref: 'created' }) }) + it('projects every emitted schema into the enforced tool-schema subset', async () => { + const [artifact] = new WorkspaceTypertGenerator(fixture()).generate() + const remote = await executable(artifact!.remote!.js) + const host = await executableRemoteModule<{ readonly TYPERT: { readonly invocations: readonly RuntimeDescriptor[] } }>(artifact!.js) + let projected = 0 + let reported = 0 + const verify = (value: RuntimeValue): void => { + if (value.jsonSchema === undefined) { + // An unrepresentable type must say so instead of publishing a weaker schema. + expect(value.jsonSchemaError).toContain('typert tool schema:') + reported += 1 + return + } + assertSupportedJsonSchema(value.jsonSchema) + projected += 1 + } + for (const method of Object.values(remote.TYPERT_REMOTE.methods)) { + verify(method.input()) + verify(method.input({ strict: true })) + verify(method.result) + } + for (const descriptor of host.TYPERT.invocations) { + for (const parameter of descriptor.parameters) verify(parameter.codec) + verify(descriptor.result) + } + const expected = Object.keys(remote.TYPERT_REMOTE.methods).length * 3 + + host.TYPERT.invocations.reduce((total, descriptor) => total + descriptor.parameters.length + 1, 0) + expect(projected + reported).toBe(expected) + expect(projected).toBeGreaterThan(0) + }) + it('typechecks ordinary declarations for named invocation, Context exclusion, uplink, and decoded bytes', () => { const root = fixture() editFile(root, 'packages/remote/src/index.ts', source => `import type { RemoteStream } from '@deepseek-ai/dsh-typert-protocol'\n${source.replace(' constructor() {', ` @Remote diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 2ee980a746..f2e156e991 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -75,7 +75,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => { if (descriptor === undefined) throw new Error('binary descriptor missing') expect(generated.TYPERT_REMOTE.methods['goals/bytes']).toBe(descriptor) expect(descriptor.result.jsonSchema).toBeUndefined() - expect(descriptor.result.jsonSchemaError).toContain('Remote JSON Schema:') + expect(descriptor.result.jsonSchemaError).toContain('typert tool schema:') const schema = descriptor.result.create() for (const data of [new Uint8Array(), new Uint8Array([0, 128, 255]), new Uint8Array(new SharedArrayBuffer(3))]) { const parsed = schema.safeParse({ data, size: data.length }) From 902df58ded49ac400792a1a6f1e341b5a1c28883 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:48:55 +0800 Subject: [PATCH 07/17] refactor: scope this branch to generated Remote artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The caller-connection work — forwarded-event opt-in, physical socket ownership, and the Node/MCP artifact-only consumer fixture — moves to a stacked PR on top of this one. This branch now changes only how Remote definitions are generated (Typert artifacts, lazy method info, consumer-selected input strictness, tool-schema projection) and how the existing Registry consumes them. --- ...ompiler-independent-typert-model.i18n.yaml | 4 +- ...07-27-compiler-independent-typert-model.md | 2 +- ...27-compiler-independent-typert-model.zh.md | 2 +- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 2 +- ...026-08-02-typert-remote-method-calls.zh.md | 2 +- ...-definitions-and-passive-clients.i18n.yaml | 19 -- ...-remote-definitions-and-passive-clients.md | 41 --- ...mote-definitions-and-passive-clients.zh.md | 41 --- ...ote-metadata-and-passive-clients.i18n.yaml | 37 --- ...0-unified-typert-remote-metadata.i18n.yaml | 34 +++ ...6-09-20-unified-typert-remote-metadata.md} | 26 +- ...9-20-unified-typert-remote-metadata.zh.md} | 26 +- .oxlintrc.json | 1 - apps/web/tests/assembled-boot.ts | 3 - docs/config-catalog.md | 7 +- docs/config-catalog.zh.md | 7 +- .../passive-remote-clients/guide.i18n.yaml | 16 -- .../passive-remote-clients/guide.md | 21 -- .../passive-remote-clients/guide.zh.md | 21 -- packages/api/gateway/package.json | 5 +- packages/api/gateway/src/client/index.ts | 150 ++++------ .../api/gateway/src/client/remote-events.ts | 16 +- .../api/gateway/src/client/remote-stream.ts | 17 +- .../api/gateway/src/client/stream-client.ts | 51 ++-- packages/api/gateway/src/index.ts | 11 - packages/api/gateway/src/stream-protocol.ts | 3 - .../tests/control-retry.client.spec.ts | 19 -- .../gateway/tests/gateway-stream.host.spec.ts | 15 +- .../api/gateway/tests/gateway.client.spec.ts | 100 +++---- packages/api/gateway/tests/node-client.e2e.ts | 57 ---- .../api/gateway/tests/passive.client.spec.ts | 250 ---------------- .../tests/client-apply.client.spec.ts | 1 - packages/api/remotes/README.i18n.yaml | 8 +- packages/api/remotes/README.md | 6 +- packages/api/remotes/README.zh.md | 6 +- packages/api/remotes/package.json | 6 - packages/api/remotes/tests/built-lib.e2e.ts | 2 +- .../tests/fixtures/session-client/client.ts | 125 -------- .../tests/fixtures/session-client/mcp.ts | 82 ------ .../tests/fixtures/session-client/run.mjs | 269 ------------------ .../fixtures/session-client/tsconfig.json | 15 - .../tests/fixtures/session-client/typing.ts | 33 --- .../api/remotes/tests/session-client.e2e.ts | 56 ---- packages/bundle/web-app/cordis.patch.yml | 6 - packages/client/connection/README.i18n.yaml | 8 +- packages/client/connection/README.md | 6 +- packages/client/connection/README.zh.md | 6 +- packages/client/connection/package.json | 2 - .../client/connection/src/client/index.ts | 16 -- .../client/connection/src/client/socket.ts | 102 ------- .../connection/tests/socket.client.spec.ts | 134 --------- .../client/connection/tsconfig.client.json | 1 - .../product-analytics/src/client/index.ts | 1 + .../tests/collection.client.spec.ts | 3 +- .../tests/readiness.client.spec.ts | 2 +- .../src/client/api-catalog.ts | 12 +- .../client-runtime/README.i18n.yaml | 4 +- .../test-support/client-runtime/README.md | 2 - .../test-support/client-runtime/README.zh.md | 2 - .../src/assembly/test-client.ts | 12 +- pnpm-lock.yaml | 24 +- scripts/check-workspace-constraints.spec.ts | 9 - scripts/check-workspace-constraints.ts | 13 +- scripts/no-unknown-casts.baseline.json | 4 +- scripts/run-gates.ts | 1 - scripts/verify-package-dependencies.spec.ts | 27 -- scripts/verify-package-dependencies.ts | 9 +- tsconfig.base.json | 1 - tsconfig.host.json | 2 - 70 files changed, 230 insertions(+), 1798 deletions(-) delete mode 100644 .agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.i18n.yaml delete mode 100644 .agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.md delete mode 100644 .agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.zh.md delete mode 100644 .agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.i18n.yaml create mode 100644 .agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.i18n.yaml rename .agents/notes/proposed/architecture/{2026-09-20-unified-typert-remote-metadata-and-passive-clients.md => 2026-09-20-unified-typert-remote-metadata.md} (83%) rename .agents/notes/proposed/architecture/{2026-09-20-unified-typert-remote-metadata-and-passive-clients.zh.md => 2026-09-20-unified-typert-remote-metadata.zh.md} (83%) delete mode 100644 docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.i18n.yaml delete mode 100644 docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.md delete mode 100644 docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.zh.md delete mode 100644 packages/api/gateway/tests/node-client.e2e.ts delete mode 100644 packages/api/gateway/tests/passive.client.spec.ts delete mode 100644 packages/api/remotes/tests/fixtures/session-client/client.ts delete mode 100644 packages/api/remotes/tests/fixtures/session-client/mcp.ts delete mode 100644 packages/api/remotes/tests/fixtures/session-client/run.mjs delete mode 100644 packages/api/remotes/tests/fixtures/session-client/tsconfig.json delete mode 100644 packages/api/remotes/tests/fixtures/session-client/typing.ts delete mode 100644 packages/api/remotes/tests/session-client.e2e.ts delete mode 100644 packages/client/connection/src/client/socket.ts delete mode 100644 packages/client/connection/tests/socket.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml index d852fcea7c..9f174f4f54 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml @@ -18,5 +18,5 @@ en: fa503dcfaa6f438f zh: 26052212a124e133 /agent-note-compiler-independent-typert-type-model/consequences: - en: eb116721d7736818 - zh: cec38e1474ff8fce + en: e1022cf9523fb28e + zh: 01dfb764fb98de0d diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md index d2aa76a643..6ca9186f7f 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md @@ -52,4 +52,4 @@ New generation targets and static checks can reuse the same TypeGraph, and busin Explicit package opt-in keeps artifact publication and exports under package ownership. Repository orchestration may still run the whole-workspace Host contract pass for every opted-in package; that pass remains owned by the later Remote Gateway Agent Note. The static Cordis catalogs remain reproducible from the canonical model without coupling `tool-cordis` to runtime registry state. `ctx.typert` reflects only artifacts mounted in the current runtime, and unloading does not control Zod instances that consumers retain after materializing them. -Ordinary Host and Remote artifacts reuse this model to emit one static method spec per endpoint. Each parameter has normal `codec` and `strictCodec` variants derived from the same checker-resolved type model; neither analysis nor the model carries a per-method input policy. A pure factory returns a lazy info instance whose direct method index and descriptor list share objects; the existing Registry retains those descriptors, while `TYPERT_REMOTE.methods` supplies exact static types. The [unified Typert/Remote metadata note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.md) records this delivery mechanism and the incomplete downstream adaptation. Declaration and face-isolation rules remain unchanged; separate Programs and processes do not share object identity. +Ordinary Host and Remote artifacts reuse this model to emit one static method spec per endpoint. Each parameter has normal `codec` and `strictCodec` variants derived from the same checker-resolved type model; neither analysis nor the model carries a per-method input policy. A pure factory returns a lazy info instance whose direct method index and descriptor list share objects; the existing Registry retains those descriptors, while `TYPERT_REMOTE.methods` supplies exact static types. The [unified Typert/Remote metadata note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata.md) records this delivery mechanism and the incomplete downstream adaptation. Declaration and face-isolation rules remain unchanged; separate Programs and processes do not share object identity. diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md index ccbdd92cb4..1a4a870fab 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md @@ -52,4 +52,4 @@ Zod emitter 对支持的节点和各类 literal 逐类执行成功与失败 pars 包级显式 opt-in 使产物发布与 exports 由各包自行管理。仓库编排仍可为每个已 opt-in 的包运行全仓 Host 约定 pass;该 pass 仍由后续 Remote Gateway Agent Note 负责说明。静态 Cordis catalog 可从标准模型复现,同时不把 `tool-cordis` 与运行时注册表状态耦合。`ctx.typert` 只反映当前运行时中已挂载的产物;对于消费方物化后仍持有的 Zod 实例,卸载流程无法控制。 -普通 Host 与 Remote 产物复用此模型,为每个端点生成一份静态方法 spec。每个参数都有从同一 checker 解析后类型模型派生的普通 `codec` 和 `strictCodec` 变体;分析过程与模型均不携带逐方法输入策略。纯 factory 返回惰性 info 实例,其直接方法索引与描述符列表共享对象;既有 Registry 保留这些描述符,而 `TYPERT_REMOTE.methods` 提供精确静态类型。[统一 Typert/Remote 元数据 note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.zh.md)记录该交付机制及尚未完成的下游适配。声明与编译面隔离规则保持不变;独立 Program 和进程不共享对象身份。 +普通 Host 与 Remote 产物复用此模型,为每个端点生成一份静态方法 spec。每个参数都有从同一 checker 解析后类型模型派生的普通 `codec` 和 `strictCodec` 变体;分析过程与模型均不携带逐方法输入策略。纯 factory 返回惰性 info 实例,其直接方法索引与描述符列表共享对象;既有 Registry 保留这些描述符,而 `TYPERT_REMOTE.methods` 提供精确静态类型。[统一 Typert/Remote 元数据 note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md)记录该交付机制及尚未完成的下游适配。声明与编译面隔离规则保持不变;独立 Program 和进程不共享对象身份。 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 82599fc976..82629e613f 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -18,8 +18,8 @@ en: b9394d5ef98cc82a zh: 1fce07fe150722ee /agent-note-typert-gateway-targeted-method-calls/decorators-and-the-explicit-gateway-facet: - en: 668ac5fbbbfd928f - zh: f85915e316b6b434 + en: 161dccf2b4cf565d + zh: fa587fe0b46bf551 /agent-note-typert-gateway-targeted-method-calls/lookup-and-remote-scope-registration: en: 9f7448898cf5cadc zh: 419573c589ed6f7d diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 0857fce198..ba4e8eb4c9 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -91,7 +91,7 @@ A decorator states Remote participation, an optional export name, and stream del In SRC mode, the decorator records the method name and invocation mode in a versioned descriptor on the Service prototype. The descriptor uses a stable string property name, so `remoteMethods()` can read markers produced by another installed copy of `dsh-typert-protocol`; it writes nothing to the Service instance, constructor, or method function. -Ordinary artifacts construct a lazy Remote info instance from one static method spec per endpoint. Its direct `TypertRemoteMethod` objects retain descriptor fields and add named `input(options?)` and `invoke`. Consumers select matching strict parsing and projection with `input({ strict: true })`; `input()` preserves normal handling. Host parameter codecs and the single normal `result` codec are unchanged. Existing Registry queries retain those objects, while `TYPERT_REMOTE.methods` supplies exact static types. Strict source startup prebuilds and loads ordinary Typert artifacts; fallback cannot recover erased schemas. The [unified metadata note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.md) records this delivery mechanism and the incomplete consumer adaptation. Forwarded-event participation remains an explicit composition choice. +Ordinary artifacts construct a lazy Remote info instance from one static method spec per endpoint. Its direct `TypertRemoteMethod` objects retain descriptor fields and add named `input(options?)` and `invoke`. Consumers select matching strict parsing and projection with `input({ strict: true })`; `input()` preserves normal handling. Host parameter codecs and the single normal `result` codec are unchanged. Existing Registry queries retain those objects, while `TYPERT_REMOTE.methods` supplies exact static types. Strict source startup prebuilds and loads ordinary Typert artifacts; fallback cannot recover erased schemas. The [unified metadata note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata.md) records this delivery mechanism and the incomplete consumer adaptation. In LIB mode, the Typert compiler performs strict method discovery, type resolution, and descriptor generation. It accepts a literal service key in `TypertRemoteService`'s direct `super()` call or the explicit binding fallback; generation neither rewrites business source nor injects hidden registration metadata. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 21921350d2..a6eb7dae73 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -91,7 +91,7 @@ Decorator 表达 Remote 参与、可选导出名称和流式投递;`@Remote` SRC 模式下,decorator 把方法名和调用模式记录在 Service prototype 上的带版本描述符中。描述符使用稳定的字符串属性名,因此 `remoteMethods()` 可以读取 `dsh-typert-protocol` 另一个已安装副本生成的标记;它不会向 Service 实例、constructor 或方法函数写入任何内容。 -普通产物从每个端点唯一的静态方法 spec 构造惰性 Remote info 实例。其直接 `TypertRemoteMethod` 对象保留描述符字段,并增加具名 `input(options?)` 和 `invoke`。消费方通过 `input({ strict: true })` 选择配套的严格解析与投影;`input()` 保留普通处理。Host 参数 codec 与唯一的普通 `result` codec 保持不变。既有 Registry 查询保留这些对象,`TYPERT_REMOTE.methods` 则提供精确静态类型。严格源码启动预构建并加载普通 Typert 产物;fallback 无法恢复被擦除的 schema。[统一元数据 note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.zh.md)记录该交付机制及尚未完成的消费方适配。转发事件参与仍是显式的组合选择。 +普通产物从每个端点唯一的静态方法 spec 构造惰性 Remote info 实例。其直接 `TypertRemoteMethod` 对象保留描述符字段,并增加具名 `input(options?)` 和 `invoke`。消费方通过 `input({ strict: true })` 选择配套的严格解析与投影;`input()` 保留普通处理。Host 参数 codec 与唯一的普通 `result` codec 保持不变。既有 Registry 查询保留这些对象,`TYPERT_REMOTE.methods` 则提供精确静态类型。严格源码启动预构建并加载普通 Typert 产物;fallback 无法恢复被擦除的 schema。[统一元数据 note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md)记录该交付机制及尚未完成的消费方适配。 LIB 的严格方法发现、类型解析和 descriptor 生成由 Typert compiler 完成。它接受 `TypertRemoteService` 直接 `super()` 调用中的字面量 service key,或显式 binding 回退;生成过程不改写业务源码,也不注入隐藏注册元数据。 diff --git a/.agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.i18n.yaml deleted file mode 100644 index aadb6a5f08..0000000000 --- a/.agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.i18n.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# Bilingual-pair consistency record for 2026-09-20-committed-remote-definitions-and-passive-clients.md (docs/i18n/README.md): per heading -# section, a hash of its English and Chinese blocks outside code blocks and generated regions. -# After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.md -/agent-note-committed-remote-definitions-and-passive-clients: - en: 0452289f77050c14 - zh: e2ac3a0abd0589b1 -/agent-note-committed-remote-definitions-and-passive-clients/problem: - en: d945dbb724377079 - zh: 9a3a490e17571299 -/agent-note-committed-remote-definitions-and-passive-clients/decision: - en: 5c22c7c9b2895efd - zh: 3fc9345ec1e8bf72 -/agent-note-committed-remote-definitions-and-passive-clients/alternatives-considered: - en: c47d0cf858e28d83 - zh: 871dafca226bd544 -/agent-note-committed-remote-definitions-and-passive-clients/consequences: - en: 8072aa586c42dfab - zh: 3bae173fa86f5604 diff --git a/.agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.md b/.agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.md deleted file mode 100644 index 70ba370e60..0000000000 --- a/.agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note: Committed Remote definitions and passive clients - -Status: implemented - -English | [中文](2026-09-20-committed-remote-definitions-and-passive-clients.zh.md) - -## Problem - -A command or tool needs discoverable input documentation and validation before connecting to a Host. Recovering that information from running Services makes offline help depend on application boot, while a separate command schema or invocation dispatcher creates another authority for the same operation. Source launches also need the same selected request validation as published builds. - -A consumer that only invokes methods must not implicitly join forwarded user-interaction waterfalls. Joining those deliveries can delegate or settle interactions even when that consumer has no user interface. - -## Decision - -The [Typert method-call design](2026-08-02-typert-remote-method-calls.md) retains ownership of dispatch, generated signatures, lookup resolution, and carrier-independent invocation. Selected methods declare `@Remote({ emitSourceDef: true })`; the existing compiler-independent model emits committed source definitions, published definitions, and consumer metadata from their owning method and type declarations. The Host binding carries generated definitions explicitly, and a selected source method without its definition fails rather than using JSON-only validation. Closing request objects is a separate `rejectUnknownFields: true` choice, accepted only with `emitSourceDef`, so selecting a method for offline consumers does not change the request policy of its existing callers. - -The committed module contains parsers, method and field documentation, supported tool JSON Schemas, and typed calls to the ordinary Client Remote methods. Consumer selection and presentation do not redefine business schemas. Methods that also declare `rejectUnknownFields` reject unknown fields in declared request objects at the Host and in their tool schemas; explicit dictionaries and other methods keep their own policy. Unsupported input schemas fail generation; outputs retain their codec and omit optional JSON Schema when lossless projection is unavailable. Semantic constraints absent from TypeScript remain with their operation owner. - -Generation verification compares committed source against current owner declarations. Normal artifact publication also performs that check, so a build cannot silently publish a different definition from the source launch. Type-only owner imports keep metadata loading independent of Host Service activation. The [compiler-independent model](2026-07-27-compiler-independent-typert-model.md) remains the extraction and projection authority. - -Connection owns authenticated HTTP and physical WebSocket access. Gateway owns Remote framing and method invocation. A Gateway Client receives forwarded Host events only with `forwardedEvents: true`; without it, `$on()` and `$stream()` fail instead of waiting for deliveries or generations that never start. The Web bundle sets the Host Gateway row's `forwardedEvents`, which publishes the choice to served pages. Node compositions use the existing Client entries and generated contributions, with instance-owned carrier hooks and awaited disposal, and stay passive unless they configure the option. - -## Alternatives considered - -**Handwritten CLI/tool schemas and dispatch.** Every operation change would update two authorities. - -**Runtime TypeScript analysis.** Help and request validation would depend on compiler availability and application startup. - -**Weak source fallback for selected methods.** Source launches would accept requests that the corresponding published Host rejects. - -**Automatic event participation.** Subscribing every Remote client to forwarded events would couple passive observation to interactive answer ownership. - -## Consequences - -Owners regenerate committed metadata when selected signatures or documentation change. Generated files increase the reviewed diff, but an executed freshness check rejects drift. The tool projection intentionally supports a bounded JSON Schema language and refuses unsupported types without weakening them. - -Generation keeps the face's contributing packages in one stable TypeScript program: limiting program roots to emitted packages would omit externally declared members and make focused metadata disagree with the complete build. - -Generated schema tables and calls repeat emitter templates. Generator and owner integration suites verify their behavior, while repository per-file coverage and clone detection omit the emitted code. The coverage exclusion and source inventory share one exact path pattern; the inventory rejects unowned files, and byte-for-byte verification rejects handwritten additions to owned files. These checks retain the coverage requirements for authored sources and the typechecking and lint requirements for generated sources. - -Verification compares source and published parsers, rejects unknown and missing inputs, typechecks generated calls, rejects stale artifacts before publication, and observes passive and interactive clients over real HTTP and WebSocket carriers. diff --git a/.agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.zh.md b/.agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.zh.md deleted file mode 100644 index d2265bf6ab..0000000000 --- a/.agents/notes/implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.zh.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note: 提交到源码树的 Remote 定义与被动客户端 - -Status: implemented - -[English](2026-09-20-committed-remote-definitions-and-passive-clients.md) | 中文 - -## Problem - -命令或工具需要在连接 Host 前取得输入文档并验证输入。从运行中的 Service 恢复这些信息会使离线帮助依赖应用启动;单独的命令 schema 或调用分发器则会为同一操作建立第二个权威来源。源码启动也需要与发布构建相同的选中请求验证。 - -仅调用方法的消费方不得隐式加入转发的用户交互 waterfall。即使消费方没有用户界面,加入这些投递也可能委托或结束交互。 - -## Decision - -[Typert 方法调用设计](2026-08-02-typert-remote-method-calls.zh.md)继续拥有分发、生成签名、查找解析以及独立于载体的调用。选中的方法声明 `@Remote({ emitSourceDef: true })`;现有的编译器无关模型从其拥有者的方法和类型声明生成提交到源码树的定义、发布定义和消费方元数据。Host 绑定显式携带生成定义;选中的源码方法缺少定义时会失败,而不会退回仅验证 JSON。关闭请求对象是单独的 `rejectUnknownFields: true` 选择,且只能与 `emitSourceDef` 一起使用,因此为离线消费方选中某个方法不会改变其现有调用方的请求策略。 - -提交的模块包含解析器、方法与字段文档、支持的工具 JSON Schema,以及对普通 Client Remote 方法的类型化调用。消费方的选择与呈现不重新定义业务 schema。同时声明 `rejectUnknownFields` 的方法在 Host 与其工具 schema 中拒绝已声明请求对象的未知字段;显式字典与其他方法保留各自策略。不支持的输入 schema 会使生成失败;无法无损投影时,输出保留 codec 并省略可选 JSON Schema。TypeScript 未表达的语义约束仍由操作拥有者负责。 - -生成验证将已提交源码与当前拥有者声明比较。正常产物发布也执行此检查,因此构建不能静默发布与源码启动不同的定义。仅类型的拥有者导入使元数据加载独立于 Host Service 激活。[编译器无关模型](2026-07-27-compiler-independent-typert-model.zh.md)继续作为提取与投影的权威。 - -Connection 拥有经过认证的 HTTP 与物理 WebSocket 访问。Gateway 拥有 Remote 帧和方法调用。Gateway Client 仅在 `forwardedEvents: true` 时接收转发的 Host 事件;未开启时,`$on()` 与 `$stream()` 直接失败,而不会等待永不开始的投递或 generation。Web bundle 设置 Host Gateway 行的 `forwardedEvents`,由它把这一选择发布给已服务的页面。Node 组合使用现有 Client 入口与生成贡献,载体钩子由实例拥有,并等待释放完成;未配置该选项时保持被动。 - -## Alternatives considered - -**手写 CLI/工具 schema 与分发。** 每次操作变更都要更新两个权威来源。 - -**运行时 TypeScript 分析。** 帮助与请求验证会依赖编译器可用性及应用启动。 - -**为选中方法保留弱源码回退。** 源码启动会接受对应发布 Host 拒绝的请求。 - -**自动参与事件。** 让每个 Remote 客户端订阅转发事件会把被动观察与交互回答所有权耦合。 - -## Consequences - -选中签名或文档变化时,拥有者重新生成已提交元数据。生成文件增加了需要审阅的差异,但执行的新鲜度检查会拒绝漂移。工具投影刻意支持有界的 JSON Schema 语言,并拒绝不支持的类型而不弱化它们。 - -生成过程将该 face 的贡献包保留在同一个稳定的 TypeScript 程序中:若只把生成产物的包作为程序根,就会漏掉外部声明的成员,导致局部元数据与完整构建不一致。 - -生成的 schema 表和调用会重复发射器模板。生成器与拥有者的集成测试验证其行为,仓库的逐文件覆盖率和重复代码检测则省略生成代码。覆盖率排除项与源码清单共享同一个精确路径模式;清单拒绝无生成拥有者的文件,逐字节验证拒绝在有效生成文件中添加手写内容。这些检查保留了手写源码的覆盖率要求,以及生成源码的类型检查和 lint 要求。 - -验证比较源码与发布解析器,拒绝未知和缺失输入,对生成调用进行类型检查,在发布前拒绝过期产物,并通过真实 HTTP 和 WebSocket 载体观察被动与交互客户端。 diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.i18n.yaml deleted file mode 100644 index 980432ebbc..0000000000 --- a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.i18n.yaml +++ /dev/null @@ -1,37 +0,0 @@ -# Bilingual-pair consistency record for 2026-09-20-unified-typert-remote-metadata-and-passive-clients.md (docs/i18n/README.md): per heading -# section, a hash of its English and Chinese blocks outside code blocks and generated regions. -# After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.md -/agent-note-unified-typert-remote-metadata-and-passive-clients: - en: 4b9c7921ba23adf9 - zh: 24015b341a7befa3 -/agent-note-unified-typert-remote-metadata-and-passive-clients/problem: - en: 7d5096b832023e28 - zh: bd2b3f5b182d998b -/agent-note-unified-typert-remote-metadata-and-passive-clients/technical-architecture: - en: 23fa081ffc7d9489 - zh: 0128d572e821227f -/agent-note-unified-typert-remote-metadata-and-passive-clients/proposal: - en: 5e8a34a91fb50448 - zh: ec5a7a4f3b1fcc2e -/agent-note-unified-typert-remote-metadata-and-passive-clients/proposal/shared-generation-two-access-modes: - en: e2d2722046cd42cd - zh: b9ad3260ac636ccb -/agent-note-unified-typert-remote-metadata-and-passive-clients/proposal/downstream-migration-targets: - en: 1c5c713b8e8f380a - zh: 58c0318383ab69db -/agent-note-unified-typert-remote-metadata-and-passive-clients/proposal/paired-usage-examples: - en: 119995a2ef0fc875 - zh: 7af1e7c0d1105d28 -/agent-note-unified-typert-remote-metadata-and-passive-clients/proposal/bounded-reproduction-on-this-branch: - en: 9a1cb50b9576fe89 - zh: 11187b2db921ab8e -/agent-note-unified-typert-remote-metadata-and-passive-clients/alternatives-considered: - en: 7ec48c492b1e4859 - zh: a751968903a7f711 -/agent-note-unified-typert-remote-metadata-and-passive-clients/acceptance-criteria: - en: d2153a512cdb2fd6 - zh: 7c5e5e013b1345da -/agent-note-unified-typert-remote-metadata-and-passive-clients/risks: - en: 74f3a5b36d5609b2 - zh: 0ecc15d505511e8a diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.i18n.yaml new file mode 100644 index 0000000000..75110207f0 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.i18n.yaml @@ -0,0 +1,34 @@ +# Bilingual-pair consistency record for 2026-09-20-unified-typert-remote-metadata.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md +/agent-note-unified-typert-remote-metadata: + en: 463246d8c8732192 + zh: cdb9f8ca8cfe4635 +/agent-note-unified-typert-remote-metadata/problem: + en: 3aa575e4c0778df1 + zh: 5277fc49b040da50 +/agent-note-unified-typert-remote-metadata/technical-architecture: + en: 23fa081ffc7d9489 + zh: 0128d572e821227f +/agent-note-unified-typert-remote-metadata/proposal: + en: f5dcecbcae90a898 + zh: 923cf3a3074a3a2d +/agent-note-unified-typert-remote-metadata/proposal/shared-generation-two-access-modes: + en: 733398b2346d7438 + zh: d4e827b9dbd16d67 +/agent-note-unified-typert-remote-metadata/proposal/downstream-migration-targets: + en: 261dab3eb480a0c0 + zh: 13c3226d316d8d5e +/agent-note-unified-typert-remote-metadata/proposal/paired-usage-examples: + en: 7ef7dd38e2015801 + zh: b97cd179bf828406 +/agent-note-unified-typert-remote-metadata/alternatives-considered: + en: 151b1f66af030bc5 + zh: 44fea0898acfbdcb +/agent-note-unified-typert-remote-metadata/acceptance-criteria: + en: a42c93afee12b51c + zh: da8fdfb8c68ba88c +/agent-note-unified-typert-remote-metadata/risks: + en: a770478eac8f5fd1 + zh: f24aa378f875f47f diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.md b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md similarity index 83% rename from .agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.md rename to .agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md index c18834925e..a264640705 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.md +++ b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md @@ -1,14 +1,14 @@ -# Agent Note: Unified Typert/Remote metadata and passive clients +# Agent Note: Unified Typert/Remote metadata Status: proposed -English | [中文](2026-09-20-unified-typert-remote-metadata-and-passive-clients.zh.md) +English | [中文](2026-09-20-unified-typert-remote-metadata.zh.md) ## Problem CLI help, argument parsing, and MCP tool discovery need local method descriptions, named-input parsers, tool JSON Schemas, and result metadata before connecting to a controlled Host. Browser, Node, and independent consumers also need typed invocations. Tool consumers need stricter input-object validation without changing ordinary Host dispatch. These requirements do not imply runtime TypeScript analysis, Host Service activation, or another business schema maintained by each consumer. -The original #4644 [source-definition decision](../../implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.md) delivers this information through `emitSourceDef`, committed generated files, and `./remote-source`. Host bindings carry those definitions; source generation has its own inventory, freshness, publication, and exclusion rules. This note replaces that delivery mechanism and makes input strictness consumer-owned. The original note remains intact because its passive-client and carrier-ownership decisions still apply. +The original #4644 source-definition decision delivered this information through `emitSourceDef`, committed generated files, and `./remote-source`. Host bindings carried those definitions; source generation had its own inventory, freshness, publication, and exclusion rules. This note replaces that delivery mechanism and makes input strictness consumer-owned. Both delivery paths derive from the same [compiler-independent model](../../implemented/architecture/2026-07-27-compiler-independent-typert-model.md). Separate Host and Client Programs require generated cross-face declarations, not committed generated source. An explicit prebuild is acceptable when exact schemas are needed; ordinary ESM artifacts can supply offline metadata without a live Host. The [targeted-call decision](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) still owns receiver lookup, Gateway dispatch, and contribution lifetimes. @@ -36,7 +36,7 @@ Documentation and JSON Schema are optional. Each unsupported input variant or re ## Proposal -Complete the ordinary-artifact migration in the downstream Session-client, CLI, MCP, and native-tool packages. This note remains proposed because the bounded integration does not complete that migration. +Complete the ordinary-artifact migration in the downstream Session-client, CLI, MCP, and native-tool packages. This note remains proposed because that migration is not complete. ### Shared generation, two access modes @@ -46,8 +46,6 @@ Strict source startup explicitly prebuilds and loads ordinary `./typert` through Host metadata readiness remains composition-owned. A Host requiring strict validation must await local registration before serving calls; `hasSeen()` does not protect an endpoint before its first registration. Returning a lazy info instance neither completes asynchronous Loader discovery nor establishes readiness. Loader validates static codec specs and callable `input`/`invoke` without calling the selector, parser factories, or projections to register a contribution. -Connection owns authenticated HTTP and physical WebSockets; Gateway owns framing and dispatch. `forwardedEvents: true` explicitly opts into Host event forwarding and interaction waterfalls. Otherwise `$on()` and event-supervised `$stream()` fail promptly, while generated streaming methods remain available. Importing, registering, listing tools, or calling methods does not acquire interaction-answer ownership. Node hooks are instance-owned and disposal is awaited. - ### Downstream migration targets The inspected #4111 tree contains Session-client, CLI, MCP, and native-tool consumers that are not present as complete packages on this branch. Its Session client selects 13 Session methods plus Workspace listing from source exports. Migrating that tree requires updating selection, connection-time mounts, offline CLI initialization, MCP presentation, and native-tool registration together; deleting source exports alone is insufficient. @@ -60,11 +58,11 @@ The inspected #4111 tree contains Session-client, CLI, MCP, and native-tool cons | Native tools | Read Host local method info; require the tool registration's `output.schema`. | Parse at the tool boundary, then call same-process controllers directly. | | Independent adapters | Read typed `TYPERT_REMOTE.methods` directly. | Use their chosen Remote runtime only when invoking. | -Current-session selection and attachment preparation remain consumer-owned; CLI/MCP intake validates completed business arguments with the same strict input view used during selection. The downstream `follow` adapter's `activate: false` policy applies where that API exists; this branch's current `follow` has no such option. Aborting observation does not cancel accepted Session work. Native direct dispatch also does not remove unrelated business dependencies such as the downstream controller's pending-interaction access through Gateway. +Current-session selection and attachment preparation remain consumer-owned; CLI/MCP intake validates completed business arguments with the same strict input view used during selection. The downstream `follow` adapter's `activate: false` policy applies where that API exists; this branch's current `follow` has no such option. Aborting observation does not cancel accepted Session work. ### Paired usage examples -The old examples are historical comparisons of source-export consumers in the dependent changes. The new examples use ordinary artifacts. These fragments omit surrounding plugin declarations and include removed APIs; the linked consumer test owns compilation of the complete ordinary-artifact adapter. `raw`, `request`, `signal`, and an authenticated, mounted, unscoped `remote` are caller inputs, not new framework APIs. +The old examples are historical comparisons of source-export consumers in the dependent changes. The new examples use ordinary artifacts. These fragments omit surrounding plugin declarations and include removed APIs. `raw`, `request`, `signal`, and an authenticated, mounted, unscoped `remote` are caller inputs, not new framework APIs. **Operation owner and strict Host.** Preserve the business signature; leave generated registration to ordinary Typert Loader instead of the Service constructor. @@ -169,12 +167,6 @@ pnpm run build pnpm dsh --profile ``` -### Bounded reproduction on this branch - -The test-local [Session client](../../../../packages/api/remotes/tests/fixtures/session-client/client.ts) and [MCP adapter](../../../../packages/api/remotes/tests/fixtures/session-client/mcp.ts) extract the downstream access pattern for current `session/page` and `session/follow`. They use Registry metadata, `input({ strict: true })` for both selection and intake, current-session injection, explicit unsupported-content rejection, and ordinary invocation. They do not add future controller operations, a public package/bin, an alternate application launcher, or the complete downstream application. - -The [artifact test](../../../../packages/api/remotes/tests/session-client.e2e.ts) compiles only its local consumer fixture against ordinary declarations, then runs plain Node. Its [runtime fixture](../../../../packages/api/remotes/tests/fixtures/session-client/run.mjs) composes the real SessionController, live Session/query services, Gateway, and Connection over HTTP/WebSocket. This is a manually assembled bounded integration, not a Loader/profile composition or the complete CLI/MCP protocol server. The separate Loader test uses an authored artifact fixture; the strict-source Gateway test uses an authored ordinary contribution. Neither is represented as generated full-application evidence. - ## Alternatives considered - **Committed source:** meets offline needs but adds a redundant delivery and maintenance path; explicit ordinary prebuild is sufficient. @@ -182,19 +174,17 @@ The [artifact test](../../../../packages/api/remotes/tests/session-client.e2e.ts - **Mandatory Registry for every importer:** adds lifecycle requirements without information for standalone consumers. - **Fallback only:** cannot supply erased validation or tool documentation; retain it only for its supported subset. - **Handwritten adapter schemas or runtime TypeScript analysis:** duplicate ownership or introduce runtime compiler requirements already handled by build-time extraction. -- **Automatic forwarded events:** conflates method calls with willingness to answer user interactions. ## Acceptance criteria - Ordinary artifacts emit one static method spec per endpoint and return lazy class instances; direct method indexes alias descriptor objects, with full stream/binary typing, separate compiler faces, and full-face augmentation retention. - Descriptor indexes and each input-policy view, parser, and projection are independently lazy and cached; Loader validates static specs and callable helpers without calling selectors, parsers, or projections. - Every input parameter emits normal and strict codecs from the same model; consumers select policy without a method annotation. Both envelopes reject extra named arguments; strict input recursively closes declared objects without closing JSON or dictionary keys or changing Host dispatch and results. -- Registry identity, atomic registration, withdrawal, and disposal remain intact; metadata inspection and method calls stay passive. +- Registry identity, atomic registration, withdrawal, and disposal remain intact. - Missing documentation or unsupported optional projection does not disable generation; selected tools reject missing requirements and still parse outputs. - Strict source/artifact invocation and limited fallback have separate tests; a withdrawn strict descriptor never enables fallback. -- The bounded downstream fixture exercises offline selection, real unary/stream calls, input rejection, cancellation, and teardown with current artifacts. - Source static checks do not consume stale generated declarations; artifact checks declare and rebuild their inputs. No source-definition opt-in, source export, inventory, or freshness rule remains in production. ## Risks -Full downstream profile composition remains follow-up work in those packages; the bounded reproduction is not evidence that every #4111 operation or CLI/MCP application has migrated. The deliberate trade-off is an explicit prebuild for full source-runtime metadata. Missing or stale artifacts require regeneration, not a validation downgrade or a second handwritten schema. +Full downstream profile composition remains follow-up work in those packages; the generated metadata is not evidence that every #4111 operation or CLI/MCP application has migrated. The deliberate trade-off is an explicit prebuild for full source-runtime metadata. Missing or stale artifacts require regeneration, not a validation downgrade or a second handwritten schema. diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.zh.md b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md similarity index 83% rename from .agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.zh.md rename to .agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md index 38087a4374..2611475e6f 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata-and-passive-clients.zh.md +++ b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md @@ -1,14 +1,14 @@ -# Agent Note: 统一 Typert/Remote 元数据与被动客户端 +# Agent Note: 统一 Typert/Remote 元数据 Status: proposed -[English](2026-09-20-unified-typert-remote-metadata-and-passive-clients.md) | 中文 +[English](2026-09-20-unified-typert-remote-metadata.md) | 中文 ## Problem CLI 帮助、参数解析和 MCP 工具发现需要在连接受控 Host 前取得本地方法说明、具名输入解析器、工具 JSON Schema 和结果元数据。Browser、Node 与独立消费方还需要类型化调用。工具消费方需要更严格的输入对象校验,但不能改变普通 Host 分发。这些需求不意味着运行时 TypeScript 分析、Host Service 激活,或每个消费方另行维护业务 schema。 -#4644 原有的[源码定义决策](../../implemented/architecture/2026-09-20-committed-remote-definitions-and-passive-clients.zh.md)通过 `emitSourceDef`、提交的生成文件和 `./remote-source` 交付这些信息。Host 绑定携带定义;源码生成另有清单、新鲜度、发布和排除规则。本 note 替代该交付机制,并将输入严格性改为由消费方选择。原 note 保持不变,因为其中的被动客户端与载体所有权决策仍然适用。 +#4644 原有的源码定义决策通过 `emitSourceDef`、提交的生成文件和 `./remote-source` 交付这些信息;Host 绑定携带定义,源码生成另有清单、新鲜度、发布和排除规则。本 note 替代该交付机制,并将输入严格性改为由消费方选择。 两种交付路径均来自同一个[编译器无关模型](../../implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md)。独立的 Host 与 Client Program 要求生成跨编译面声明,而非提交生成源码。需要精确 schema 时,显式预构建是可接受的;普通 ESM 产物无需运行中的 Host 即可提供离线元数据。[定向调用决策](../../implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)仍负责接收者查找、Gateway 分发和贡献生命周期。 @@ -36,7 +36,7 @@ Registry 查询返回基础描述符。纯叶入口或协议根入口的 `isType ## Proposal -在下游 Session-client、CLI、MCP 和原生工具包中完成普通产物迁移。本 note 保持 proposed,因为有限集成并未完成该迁移。 +在下游 Session-client、CLI、MCP 和原生工具包中完成普通产物迁移。本 note 保持 proposed,因为该迁移尚未完成。 ### 共享生成,两种访问方式 @@ -46,8 +46,6 @@ Registry 查询返回基础描述符。纯叶入口或协议根入口的 `isType Host 元数据就绪仍由组合负责。要求严格校验的 Host 必须等待本地注册完成后再接受调用;`hasSeen()` 不会保护首次注册之前的端点。返回惰性 info 实例既不完成异步 Loader 发现,也不建立就绪保证。Loader 校验静态 codec spec 及可调用的 `input`/`invoke`,注册贡献时不调用选择器、解析器 factory 或投影。 -Connection 拥有认证 HTTP 和物理 WebSocket;Gateway 拥有帧和分发。`forwardedEvents: true` 显式选择 Host 事件转发和交互 waterfall(瀑布式事件)。否则 `$on()` 和由事件监督的 `$stream()` 立即失败,而生成的流式方法仍然可用。导入、注册、列举工具或调用方法不会取得交互回答所有权。Node 钩子由实例拥有,释放必须等待完成。 - ### 下游迁移目标 已检查的 #4111 树包含 Session-client、CLI、MCP 和原生工具消费方,当前分支并不包含这些完整 package。其 Session client 从源码导出选择 13 个 Session 方法及 Workspace 列举。迁移该树需要一并更新选择、连接时挂载、离线 CLI 初始化、MCP 呈现及原生工具注册;仅删除源码导出并不足够。 @@ -60,11 +58,11 @@ Connection 拥有认证 HTTP 和物理 WebSocket;Gateway 拥有帧和分发。 | 原生工具 | 读取 Host 本地方法信息;要求工具注册的 `output.schema`。 | 在工具边界解析,然后直接调用同进程 controller。 | | 独立适配器 | 直接读取类型化 `TYPERT_REMOTE.methods`。 | 仅调用时使用其选择的 Remote 运行时。 | -当前 Session 选择和附件准备仍由消费方拥有;CLI/MCP 输入接收使用选择时的同一严格输入视图验证补全后的业务参数。下游 `follow` 适配器的 `activate: false` 策略只适用于具有该 API 的版本;当前分支的 `follow` 没有此选项。中止观察不等于取消已接受的 Session 工作。原生直接分发也不会移除无关的业务依赖,例如下游 controller 通过 Gateway 访问待处理交互。 +当前 Session 选择和附件准备仍由消费方拥有;CLI/MCP 输入接收使用选择时的同一严格输入视图验证补全后的业务参数。下游 `follow` 适配器的 `activate: false` 策略只适用于具有该 API 的版本;当前分支的 `follow` 没有此选项。中止观察不等于取消已接受的 Session 工作。 ### 新旧接入示例 -旧示例是依赖改动中源码导出消费方的历史对照。新示例使用普通产物。这些片段省略外围插件声明,且包含已移除的 API;下方链接的消费方测试负责完整普通产物适配器的编译。`raw`、`request`、`signal` 和已认证、已挂载、无作用域的 `remote` 是调用方输入,不是新增框架 API。 +旧示例是依赖改动中源码导出消费方的历史对照。新示例使用普通产物。这些片段省略外围插件声明,且包含已移除的 API。`raw`、`request`、`signal` 和已认证、已挂载、无作用域的 `remote` 是调用方输入,不是新增框架 API。 **操作拥有者与严格 Host。** 保留业务签名;将生成定义注册交给普通 Typert Loader,而非 Service 构造函数。 @@ -169,12 +167,6 @@ pnpm run build pnpm dsh --profile ``` -### 当前分支上的有限复现 - -测试专用 [Session client](../../../../packages/api/remotes/tests/fixtures/session-client/client.ts) 和 [MCP 适配器](../../../../packages/api/remotes/tests/fixtures/session-client/mcp.ts)为当前 `session/page` 与 `session/follow` 提取下游访问方式。它们使用 Registry 元数据,以 `input({ strict: true })` 同时用于选择与输入接收,并保留当前 Session 注入、显式拒绝不支持的 content 和普通调用。不添加未来 controller 操作、公开 package/bin、另一套应用启动器或完整下游应用。 - -[产物测试](../../../../packages/api/remotes/tests/session-client.e2e.ts)仅将本地消费方 fixture 对普通声明编译,再使用普通 Node 运行。[运行时 fixture](../../../../packages/api/remotes/tests/fixtures/session-client/run.mjs)将真实 SessionController、活 Session/query 服务、Gateway 和 Connection 通过 HTTP/WebSocket 组合。这是手动组装的有限集成,不是 Loader/profile 组合或完整 CLI/MCP 协议服务器。独立的 Loader 测试使用手写产物 fixture;严格源码 Gateway 测试使用手写普通 contribution。两者都不被表述为生成的完整应用验证。 - ## Alternatives considered - **提交源码:** 满足离线需求,但增加冗余交付和维护路径;显式普通预构建已经足够。 @@ -182,19 +174,17 @@ pnpm dsh --profile - **所有导入方强制使用 Registry:** 为独立消费方增加生命周期要求,却不增加信息。 - **仅用 fallback:** 无法提供被擦除的验证或工具文档;仅为受支持子集保留。 - **手写适配器 schema 或运行时 TypeScript 分析:** 重复承担所有权,或引入构建期提取已解决的运行时编译器要求。 -- **自动转发事件:** 将方法调用与愿意回答用户交互混为一谈。 ## Acceptance criteria - 普通产物为每个端点生成一份静态方法 spec,并返回惰性类实例;直接方法索引引用描述符对象,保留完整流式/二进制类型、独立编译面及完整编译面的声明合并。 - 描述符索引与每种输入策略的视图、解析器和投影都独立惰性求值并缓存;Loader 校验静态 spec 及可调用辅助能力,不调用选择器、解析器或投影。 - 每个输入参数从同一模型生成普通与严格 codec;消费方无需方法注解即可选择策略。两种封装都拒绝额外具名参数;严格输入递归关闭已声明对象,但不关闭 JSON 或字典键,也不改变 Host 分发和结果。 -- Registry 身份、原子注册、撤销和释放保持不变;元数据检视和方法调用保持被动。 +- Registry 身份、原子注册、撤销和释放保持不变。 - 缺少文档或不支持可选投影不阻止生成;选中的工具拒绝缺少必需信息的条目,并仍然解析输出。 - 严格源码/产物调用与有限 fallback 分别测试;撤销严格 descriptor 绝不启用 fallback。 -- 有限下游 fixture 使用当前产物覆盖离线选择、真实一元/流式调用、输入拒绝、取消和释放。 - 源码静态检查不消费过期生成声明;产物检查声明并重建输入。生产代码不保留源码定义 opt-in、源码导出、清单或新鲜度规则。 ## Risks -完整下游 profile 组合仍需在相应 package 中继续完成;有限复现不代表每个 #4111 操作或 CLI/MCP 应用都已迁移。明确接受的取舍是完整源码运行时元数据需要显式预构建。缺失或过期产物要求重新生成,而不是降低验证或另建一套手写 schema。 +完整下游 profile 组合仍需在相应 package 中继续完成;生成的元数据不代表每个 #4111 操作或 CLI/MCP 应用都已迁移。明确接受的取舍是完整源码运行时元数据需要显式预构建。缺失或过期产物要求重新生成,而不是降低验证或另建一套手写 schema。 diff --git a/.oxlintrc.json b/.oxlintrc.json index 89c2805c1b..f52fdb1e84 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -21,7 +21,6 @@ "**/.oxlint-contract-*/**", // Scratch files created by the executable lint-contract tests. "**/oxlint-contract-*", // Flat probes use real TypeScript project include paths. "packages/typert/generator/tests/fixtures/type-model/**", // tsgolint rejects this fixture's preserved project shapes before rules run. - "packages/api/remotes/tests/fixtures/session-client/**", // Artifact-only consumer; compiled by the build-dependent smoke, not source static gates. "website/.generated/**", "vendor/**", // Vendored source keeps upstream style and idioms. "packages/experimental/claude-code-mods/examples/*/{hooks,tests,types}/**", // Claude Code's published example mods and their tests keep the upstream style; see the headers. diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 621169710c..e58ee67be0 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -16,7 +16,6 @@ import { afterEach, beforeEach, vi } from 'vitest' import { bootInjections, orderByModuleGraph } from '@deepseek-ai/dsh-client-modules' import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '@deepseek-ai/dsh-client-modules/client' import type { RemoteMock } from '@deepseek-ai/dsh-remote-mock' -import { GATEWAY_CLIENT_CONFIG_GLOBAL } from '@deepseek-ai/dsh-api-gateway/stream-protocol' import { AppWebEntry } from '@deepseek-ai/dsh-client-web' import { createAssembledRemote, type AssembledRemote, type AssembledRemoteOptions, @@ -282,8 +281,6 @@ export function mountAssembledApp(options: AssembledBootOptions = {}): Assembled const remote = createAssembledRemote(options.remote) mountedRemote = remote.mock win.__DSH_TRANSPORT__ = { rpc: remote.mock.rpc } - // The fixture supplies the Web Host's explicit forwarded-event opt-in. - vi.stubGlobal(GATEWAY_CLIENT_CONFIG_GLOBAL, { forwardedEvents: true }) history.replaceState(null, '', '/') const root = document.createElement('div') root.id = 'root' diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 358315fc64..b4dcc3c429 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -182,7 +182,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-gateway` - `inject`: `typert` -- `source`: [`packages/api/gateway/src/index.ts:147`](../packages/api/gateway/src/index.ts) +- `source`: [`packages/api/gateway/src/index.ts:146`](../packages/api/gateway/src/index.ts) ```ts config-catalog /** Gateway transport configuration. */ @@ -191,11 +191,6 @@ export interface Config { readonly websocketHeartbeatIntervalMs?: number /** Buffered uplink frame bytes one logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */ readonly streamInboxBytes?: number - /** - * Served pages receive forwarded Host events, including Agent-scoped approval and question - * requests; their Gateway Client then answers or delegates those requests. @default false - */ - readonly forwardedEvents?: boolean } ``` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index eb53e67501..a16e5d1f1d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -184,7 +184,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-gateway` - `inject`: `typert` -- `source`: [`packages/api/gateway/src/index.ts:147`](../packages/api/gateway/src/index.ts) +- `source`: [`packages/api/gateway/src/index.ts:146`](../packages/api/gateway/src/index.ts) ```ts config-catalog /** Gateway transport configuration. */ @@ -193,11 +193,6 @@ export interface Config { readonly websocketHeartbeatIntervalMs?: number /** Buffered uplink frame bytes one logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */ readonly streamInboxBytes?: number - /** - * Served pages receive forwarded Host events, including Agent-scoped approval and question - * requests; their Gateway Client then answers or delegates those requests. @default false - */ - readonly forwardedEvents?: boolean } ``` diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.i18n.yaml deleted file mode 100644 index a358e86095..0000000000 --- a/docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.i18n.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading -# section, a hash of its English and Chinese blocks outside code blocks and generated regions. -# After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.md -/: - en: ee01b7afc9465afa - zh: 7e83f087647e1d3b -/opt-into-forwarded-host-events: - en: 85c99343771f874d - zh: 9afc05d4d458d2a3 -/opt-into-forwarded-host-events/change: - en: 80a18e69025c04ad - zh: c306bdf66601f0dd -/opt-into-forwarded-host-events/migration: - en: e40890c6de2584e9 - zh: 9a16d86b2f4e1436 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.md deleted file mode 100644 index b617d7b2b7..0000000000 --- a/docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -kind: upgrade-guide -description: Custom Remote Client compositions must explicitly enable forwarded Host events for subscriptions and interaction handling. ---- - -# Opt into forwarded Host events - -English | [中文](guide.zh.md) - -## Change - -Gateway Clients are passive by default. Generated unary and streaming methods remain callable, but `$on()` subscriptions and the event-supervised `$stream()` API require `forwardedEvents: true`. Passive callers do not join approval/question waterfalls or start the forwarded-event Connection recovery loop. - -The shipped Web profile enables forwarding explicitly. Custom profiles and direct Client compositions that relied on automatic forwarding must choose whether to participate. - -## Migration - -1. For a custom Web profile that handles interactive Host events, set `forwardedEvents: true` in the Host `@deepseek-ai/dsh-api-gateway` entry's `config`. Gateway publishes this choice to served pages; see the [shipped Web patch](../../../../packages/bundle/web-app/cordis.patch.yml). -2. For a direct Client composition, pass `{ forwardedEvents: true }` to the `@deepseek-ai/dsh-api-gateway/client` plugin only when it owns subscriptions or interaction handling. CLI/MCP callers that only invoke generated methods should remain passive. An explicit Client choice overrides the serving page's choice. -3. Keep Connection transport hooks instance-owned and await Context disposal. Forwarding does not replace authentication, and generated method streams do not require forwarding. -4. Verify that interactive clients receive subscribed events and can answer interactions. Verify that passive clients can call generated methods while `$on()` and `$stream()` reject immediately; see the [Gateway runtime documentation](../../../../packages/api/gateway/README.md). diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.zh.md deleted file mode 100644 index a34c9a7c78..0000000000 --- a/docs/upgrade-guide/v0.2.1-alpha.1/passive-remote-clients/guide.zh.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -kind: upgrade-guide -description: 自定义 Remote Client 组合必须显式启用转发的 Host 事件,才能订阅事件和处理交互。 ---- - -# 显式选择转发 Host 事件 - -[English](guide.md) | 中文 - -## 变更 - -Gateway Client 默认被动运行。生成的一元和流式方法仍可调用,但 `$on()` 订阅和由事件监督的 `$stream()` API 要求 `forwardedEvents: true`。被动调用方不加入审批/提问 waterfall(瀑布式事件),也不启动转发事件的 Connection 恢复循环。 - -随附的 Web profile 显式启用转发。依赖自动转发的自定义 profile 和直接 Client 组合必须选择是否参与。 - -## 迁移 - -1. 对需要处理交互 Host 事件的自定义 Web profile,在 Host `@deepseek-ai/dsh-api-gateway` 条目的 `config` 中设置 `forwardedEvents: true`。Gateway 将该选择发布到所服务的页面;参见[随附的 Web patch](../../../../packages/bundle/web-app/cordis.patch.yml)。 -2. 对直接 Client 组合,仅在其拥有订阅或交互处理职责时向 `@deepseek-ai/dsh-api-gateway/client` 插件传入 `{ forwardedEvents: true }`。仅调用生成方法的 CLI/MCP 调用方应保持被动。显式 Client 选择覆盖所服务页面的选择。 -3. 保持 Connection 传输钩子由实例拥有,并等待 Context 释放。事件转发不替代认证,生成的方法流也不要求事件转发。 -4. 验证交互客户端能收到订阅事件并回答交互。验证被动客户端能调用生成方法,同时 `$on()` 和 `$stream()` 立即拒绝;参见 [Gateway 运行时文档](../../../../packages/api/gateway/README.zh.md)。 diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index f5d3b16a82..d4469cf8cd 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -20,7 +20,6 @@ }, "./client": { "types": "./lib/types/client/index.d.ts", - "node": "./lib/types/client/index.js", "default": "./lib/client.js" }, "./types": { @@ -62,8 +61,7 @@ "@deepseek-ai/dsh-timeout": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", "ws": "^8.21.3", - "@deepseek-ai/dsh-typert-protocol": "workspace:*", - "@deepseek-ai/dsh-util-crypto": "workspace:*" + "@deepseek-ai/dsh-typert-protocol": "workspace:*" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", @@ -76,6 +74,7 @@ "@deepseek-ai/dsh-host-webserver": "workspace:*", "@deepseek-ai/dsh-remote-mock": "workspace:*", "@deepseek-ai/dsh-typert-registry": "workspace:*", + "@deepseek-ai/dsh-util-crypto": "workspace:*", "@types/ws": "^8.18.1", "@deepseek-ai/cordis": "workspace:~", "zod": "4.4.3" diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 9b4b62d9f6..9a6cb07d16 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -4,7 +4,6 @@ * participates in method lookup, invocation, or type exposure. */ -import z from '@deepseek-ai/schemastery' import { Service } from '@deepseek-ai/cordis' import { RemoteError, isRemoteUplinkItem, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' export type { TypertGatewayFaultDetails } from '../remote-error-codes.ts' @@ -34,7 +33,6 @@ import { RemoteStream, type RemoteStreamOptions, } from './remote-stream.ts' -import { GATEWAY_CLIENT_CONFIG_GLOBAL, REMOTE_STREAM_MUX_PATH } from '../stream-protocol.ts' export { RemoteStreamCarrierError } from './stream-client.ts' export { RemoteJournalStream } from './journal-stream.ts' @@ -49,6 +47,11 @@ export type { RemoteStreamItem, RemoteStreamOptions } from './remote-stream.ts' export { RemoteSnapshotStream } from './snapshot-stream.ts' export type { RemoteSnapshotStreamOptions } from './snapshot-stream.ts' +interface MountToken { + active: boolean + readonly abort: AbortController +} + interface ScopedProjection { readonly context: string readonly wire: string @@ -57,7 +60,7 @@ interface ScopedProjection { interface DirectMethod { readonly descriptor: InvocationDescriptor - readonly token: AbortController + readonly token: MountToken } interface ScopedMethod extends DirectMethod { @@ -91,7 +94,7 @@ interface LoaderReadiness { /** One descriptor's mounted variants, for the group disposer to unwind. */ interface InstalledMethod { readonly descriptor: InvocationDescriptor - readonly token: AbortController + readonly token: MountToken direct: boolean scoped: boolean } @@ -99,11 +102,9 @@ interface InstalledMethod { /** Typed Remote service augmented by generated direct namespaces and Gateway stream supervision. */ export interface ClientRemote extends TypertClientRemote { /** - * Create one independently cancellable, reconnecting logical stream. Reconnection follows - * Connection generations, so the service must participate in forwarded events. + * Create one independently cancellable, reconnecting logical stream. * @param options - domain-owned opener and generation-end classification. * @returns a single-consumer stream annotated with physical generation ids. - * @throws when this service does not participate in forwarded events. */ $stream(options: RemoteStreamOptions): RemoteStream /** @@ -132,31 +133,12 @@ declare module '@deepseek-ai/cordis' { /** Required Client services: the Typert registry and the existing Connection carrier. */ export const inject = ['typert', 'connection'] -/** Client Remote runtime configuration. */ -export interface Config { - /** - * Receive application-forwarded Host events, including Agent-scoped approval and question - * requests, and run Connection generation recovery. Off by default: generated unary and - * stream methods work without it, while `$on()` and `$stream()` require it. - */ - forwardedEvents?: boolean -} - -/** Validated Client Remote runtime configuration. */ -export const Config: z = z.object({ - forwardedEvents: z.boolean(), -}) - /** * Install the typed Client Remote service. * @param ctx - Client Cordis root. - * @param config - explicit choices override the serving Host page configuration; omitted choices - * otherwise leave the client passive. */ -export function apply(ctx: Context, config?: Config): void { - const page = (globalThis as Partial>)[GATEWAY_CLIENT_CONFIG_GLOBAL] - const resolved = Config({ forwardedEvents: config?.forwardedEvents ?? page?.forwardedEvents ?? false }) - new ClientRemoteService(ctx, resolved) +export function apply(ctx: Context): void { + new ClientRemoteService(ctx) } class ClientRemoteService extends Service implements ClientRemote { @@ -164,56 +146,45 @@ class ClientRemoteService extends Service implements ClientRemote { private readonly connection: ConnectionHandle private readonly namespaces = new Map() private hostFacts: RemoteHostFacts | undefined - private readonly streams: RemoteStreamMuxClient - private readonly events: ClientRemoteEvents | undefined + private readonly streams = new RemoteStreamMuxClient() + private readonly events: ClientRemoteEvents private mutations = Promise.resolve() - private readonly invocations = new Map>, AbortController>() - private disposed = false - constructor(ctx: Context, config: Config) { + constructor(ctx: Context) { super(ctx, 'remote') this.ownerCtx = ctx const connection = ctx.get('connection') as ConnectionHandle this.connection = connection - this.streams = new RemoteStreamMuxClient(() => connection.openWebSocket(REMOTE_STREAM_MUX_PATH)) + this.events = new ClientRemoteEvents( + ctx, + connection, + (endpoint, payload, signal) => this.openRemoteStream(endpoint, payload, signal), + ) + if (connection.rpc.open === undefined) this.streams.start() + let disposed = false let loop: ReturnType | undefined - if (config.forwardedEvents === true) { - this.events = new ClientRemoteEvents( - ctx, - connection, - (endpoint, payload, signal) => this.openRemoteStream(endpoint, payload, signal), - ) - this.events.start() + const start = (): void => { + if (disposed) return if (connection.rpc.open === undefined) this.streams.start() - const start = (): void => { - if (this.disposed) return - if (connection.rpc.open === undefined) this.streams.start() - loop = connection.start({ - onConnected: () => { this.ownerCtx.emit('connection/reset') }, - onReconnectRequested: () => { - if (connection.rpc.open === undefined) this.streams.reconnect() - }, - }) - } - const loader = ctx.get('loader') as LoaderReadiness | undefined - if (loader === undefined) start() - else void loader.await().then(start, () => {}) + loop = connection.start({ + onConnected: () => { this.ownerCtx.emit('connection/reset') }, + onReconnectRequested: () => { + if (connection.rpc.open === undefined) this.streams.reconnect() + }, + }) } + const loader = ctx.get('loader') as LoaderReadiness | undefined + if (loader === undefined) start() + else void loader.await().then(start, () => {}) ctx.effect(() => async () => { - this.disposed = true + disposed = true loop?.stop() - const pending = [...this.invocations] - for (const [, token] of pending) token.abort() - await Promise.allSettled(pending.map(([invocation]) => invocation)) - await this.events?.dispose() + await this.events.dispose() await this.streams.close() }, 'api-gateway.client.transport') } $stream(options: RemoteStreamOptions): RemoteStream { - if (this.events === undefined) { - throw new Error(`client api: ${options.name} needs forwardedEvents; passive clients call generated stream methods directly`) - } return new RemoteStream(this.connection, options) } @@ -242,9 +213,6 @@ class ClientRemoteService extends Service implements ClientRemote { event: Event, listener: TypertClientEventListener, ): () => void { - if (this.events === undefined) { - throw new Error(`client api: $on(${JSON.stringify(event)}) needs forwardedEvents; this client does not receive forwarded Host events`) - } return this.events.subscribe(this.ctx, event, listener) } @@ -259,7 +227,6 @@ class ClientRemoteService extends Service implements ClientRemote { const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined if (connection === undefined) throw new Error(noConnection) const local = connection.rpc.open?.('/api', endpoint, payload, signal, uplink) - if (local === undefined && this.events?.started !== true) this.streams.start() return local === undefined ? this.streams.open(endpoint, payload, signal, uplink) : normalizeConnectionStream(local) @@ -367,15 +334,13 @@ class ClientRemoteService extends Service implements ClientRemote { const handle = namespace return async () => { for (const method of [...installed].reverse()) { - // Transport disposal aborts pending tokens before group cleanup. - if (method.token.signal.aborted) continue - method.token.abort() + /* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */ + if (!method.token.active) continue + method.token.active = false + method.token.abort.abort() if (method.scoped) handle.service.remove('scoped', method.descriptor.method, method.token) if (method.direct) handle.service.remove('direct', method.descriptor.method, method.token) } - const tokens = new Set(installed.map(method => method.token)) - await Promise.allSettled([...this.invocations] - .filter(([, token]) => tokens.has(token)).map(([invocation]) => invocation)) await this.disposeNamespace(name, handle) } } @@ -452,7 +417,7 @@ class ClientRemoteService extends Service implements ClientRemote { private invokeSelected( descriptor: InvocationDescriptor, projection: ScopedProjection | undefined, - token: AbortController, + token: MountToken, callerCtx: Context, values: readonly unknown[], boundIdentity?: BoundContextIdentity, @@ -460,23 +425,19 @@ class ClientRemoteService extends Service implements ClientRemote { if (descriptor.mode !== undefined) { return this.invokeStream(descriptor, projection, token, callerCtx, values, boundIdentity) } - const invocation = this.invoke(descriptor, projection, token, callerCtx, values, boundIdentity) - this.invocations.set(invocation, token) - const settled = (): void => { this.invocations.delete(invocation) } - void invocation.then(settled, settled) - return invocation + return this.invoke(descriptor, projection, token, callerCtx, values, boundIdentity) } private async invoke( descriptor: InvocationDescriptor, projection: ScopedProjection | undefined, - token: AbortController, + token: MountToken, callerCtx: Context, values: readonly unknown[], boundIdentity?: BoundContextIdentity, ): Promise> { const endpoint = endpointOf(descriptor) - if (this.disposed || token.signal.aborted) return withdrawn(endpoint) + if (!token.active) return withdrawn(endpoint) const prepared = this.prepareInvocation(descriptor, projection, token, callerCtx, values, boundIdentity) const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`) @@ -503,13 +464,13 @@ class ClientRemoteService extends Service implements ClientRemote { private invokeStream( descriptor: InvocationDescriptor, projection: ScopedProjection | undefined, - token: AbortController, + token: MountToken, callerCtx: Context, values: readonly unknown[], boundIdentity?: BoundContextIdentity, ): RemoteStreamHandle { const endpoint = endpointOf(descriptor) - if (this.disposed || token.signal.aborted) throw new Error(withdrawn(endpoint).error.message) + if (!token.active) throw new Error(withdrawn(endpoint).error.message) const prepared = this.prepareInvocation(descriptor, projection, token, callerCtx, values, boundIdentity) const generation = new AbortController() const uplink = new ClientUplinkQueue(endpoint) @@ -525,7 +486,7 @@ class ClientRemoteService extends Service implements ClientRemote { private prepareInvocation( descriptor: InvocationDescriptor, projection: ScopedProjection | undefined, - token: AbortController, + token: MountToken, callerCtx: Context, values: readonly unknown[], boundIdentity?: BoundContextIdentity, @@ -566,8 +527,8 @@ class ClientRemoteService extends Service implements ClientRemote { }) const callerSignal = hasCallerSignal ? values[expected] as AbortSignal | undefined : undefined const signal = callerSignal === undefined - ? token.signal - : AbortSignal.any([token.signal, callerSignal]) + ? token.abort.signal + : AbortSignal.any([token.abort.signal, callerSignal]) return { endpoint, args, signal } } } @@ -590,7 +551,7 @@ class ClientStreamHandle implements RemoteStreamHandle { downlink: AsyncIterable, private readonly uplink: ClientUplinkQueue, private readonly generation: AbortController, - private readonly token: AbortController, + private readonly token: MountToken, ) { this.downlink = downlink[Symbol.asyncIterator]() // The carrier opens the logical stream on the first pull; pulling now puts @@ -640,7 +601,7 @@ class ClientStreamHandle implements RemoteStreamHandle { const next = await (this.primed ?? this.downlink.next()) this.primed = undefined if (this.disposed || next.done === true) return - if (this.token.signal.aborted) throw new Error(withdrawn(this.endpoint).error.message) + if (!mountActive(this.token)) throw new Error(withdrawn(this.endpoint).error.message) yield next.value } } catch (error) { @@ -695,11 +656,11 @@ class RemoteNamespaceService extends Service { return this.methods.get(method)?.[kind] !== undefined } - installDirect(descriptor: InvocationDescriptor, token: AbortController): void { + installDirect(descriptor: InvocationDescriptor, token: MountToken): void { this.install(descriptor.method, 'direct', { descriptor, token }) } - installScoped(descriptor: InvocationDescriptor, projection: ScopedProjection, token: AbortController): void { + installScoped(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void { this.install(descriptor.method, 'scoped', { descriptor, projection, token }) } @@ -730,7 +691,7 @@ class RemoteNamespaceService extends Service { else record.scoped = value as ScopedMethod } - remove(kind: 'direct' | 'scoped', method: string, token: AbortController): void { + remove(kind: 'direct' | 'scoped', method: string, token: MountToken): void { const record = this.methods.get(method) const current = record?.[kind] /* v8 ignore next -- duplicate live variants are rejected before installation, so no newer token can replace this one. */ @@ -759,7 +720,7 @@ function installMethods( for (const descriptor of descriptors) { const method: InstalledMethod = { descriptor, - token: new AbortController(), + token: { active: true, abort: new AbortController() }, direct: false, scoped: false, } @@ -776,7 +737,8 @@ function installMethods( } } catch (error) { for (const method of [...installed].reverse()) { - method.token.abort() + method.token.active = false + method.token.abort.abort() if (method.scoped) service.remove('scoped', method.descriptor.method, method.token) if (method.direct) service.remove('direct', method.descriptor.method, method.token) } @@ -795,8 +757,8 @@ function endpointOf(descriptor: Pick void) | undefined + private readonly unregisterGeneration: () => void private activeGeneration: Promise | undefined /** @@ -74,16 +74,8 @@ export class ClientRemoteEvents { private readonly ownerCtx: Context, private readonly connection: ConnectionHandle, private readonly openStream: RemoteEventStreamOpener, - ) {} - - /** Whether this client participates in forwarded-event delivery. */ - get started(): boolean { - return this.unregisterGeneration !== undefined - } - - /** Register the generation source; the owning service calls this once when `forwardedEvents` is on. */ - start(): void { - this.unregisterGeneration = this.connection.registerGenerationSource(this.runGeneration) + ) { + this.unregisterGeneration = connection.registerGenerationSource(this.runGeneration) } /** @@ -107,7 +99,7 @@ export class ClientRemoteEvents { /** Withdraw the generation source and wait for active listener work to quiesce. */ async dispose(): Promise { - this.unregisterGeneration?.() + this.unregisterGeneration() await Promise.allSettled([this.activeGeneration]) } diff --git a/packages/api/gateway/src/client/remote-stream.ts b/packages/api/gateway/src/client/remote-stream.ts index cd5934a4ba..00b398afd7 100644 --- a/packages/api/gateway/src/client/remote-stream.ts +++ b/packages/api/gateway/src/client/remote-stream.ts @@ -22,12 +22,8 @@ export interface RemoteStreamOptions { readonly name: string /** Open one physical generation of the logical stream. */ readonly open: (signal: AbortSignal) => AsyncIterable - /** - * Classify a normal generation end after or before its opening item was accepted. Omitted: an - * end after acceptance is retryable like a carrier loss (a Host reload ends every stream), and an - * end before acceptance is a terminal failure. - */ - readonly ended?: (accepted: boolean) => Error + /** Classify a normal generation end after or before its opening item was accepted. */ + readonly ended: (accepted: boolean) => Error /** Observe a retryable carrier loss before the supervisor waits or reopens. */ readonly carrierFailed?: (error: RemoteStreamCarrierError) => void } @@ -47,7 +43,6 @@ export class RemoteStream implements AsyncIterable> private closing: Promise | undefined private revision = 0 private taken = false - private readonly ended: (accepted: boolean) => Error /** * @param connection - observable Host generation source used to pace retries. @@ -56,11 +51,7 @@ export class RemoteStream implements AsyncIterable> constructor( private readonly connection: Pick, private readonly options: RemoteStreamOptions, - ) { - this.ended = options.ended ?? (accepted => accepted - ? new RemoteStreamCarrierError(`${options.name} ended without a terminal result`) - : new Error(`${options.name} ended before its opening item`)) - } + ) {} /** Cancellation lifetime shared by the stream and sibling page requests. */ get signal(): AbortSignal { @@ -134,7 +125,7 @@ export class RemoteStream implements AsyncIterable> } if (isAborted(this.lifetime.signal)) return if (revision !== this.revision) continue - throw this.ended(accepted) + throw this.options.ended(accepted) } catch (error) { if (isAborted(this.lifetime.signal)) return if (revision !== this.revision) continue diff --git a/packages/api/gateway/src/client/stream-client.ts b/packages/api/gateway/src/client/stream-client.ts index 3b3a58576a..92255a10ee 100644 --- a/packages/api/gateway/src/client/stream-client.ts +++ b/packages/api/gateway/src/client/stream-client.ts @@ -1,16 +1,14 @@ import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -/** Gateway framing over a Connection-owned WebSocket. */ +/** Browser owner for the Gateway multiplexed Remote stream socket. */ import { parseRemoteStreamServerMessage, + REMOTE_STREAM_MUX_PATH, type RemoteStreamClientMessage, type RemoteStreamServerMessage, } from '../stream-protocol.ts' import { Deque } from '@deepseek-ai/dsh-deque' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' -import type { ConnectionWebSocket } from '@deepseek-ai/dsh-client-connection/client' - -const WEBSOCKET_OPEN = 1 /** Physical Remote stream socket failure that may be retried by a domain transport. */ export class RemoteStreamCarrierError extends Error { @@ -26,7 +24,7 @@ export class RemoteStreamCarrierError extends Error { interface SocketWaiter { readonly revision: number - resolve(socket: ConnectionWebSocket): void + resolve(socket: WebSocket): void reject(error: unknown): void } @@ -51,7 +49,7 @@ const UPLINK_DONE: IteratorReturnResult = { value: undefined, done: t * starts one. */ export class RemoteStreamMuxClient { - private socket: ConnectionWebSocket | undefined + private socket: WebSocket | undefined private cancelCandidate: ((error: Error) => void) | undefined private keepAlive: Promise | undefined private revision = 0 @@ -60,14 +58,11 @@ export class RemoteStreamMuxClient { private running = false private disposed = false - /** @param openSocket - physical socket factory owned by the active Connection. */ - constructor(private readonly openSocket: () => ConnectionWebSocket) {} - /** Ensure a physical attempt exists, following the current attempt once if needed. */ start(): void { if (this.disposed) return this.running = true - if (this.socket?.readyState === WEBSOCKET_OPEN) return + if (this.socket?.readyState === WebSocket.OPEN) return const pending = this.keepAlive if (pending === undefined) this.maintain() else void pending.then(() => { this.maintain() }) @@ -111,7 +106,7 @@ export class RemoteStreamMuxClient { const streamId = randomUUID() const inbox = new StreamInbox() const stream: LogicalStream = { inbox, pump: undefined } - let carrier: ConnectionWebSocket | undefined + let carrier: WebSocket | undefined let opened = false let terminal = false const abort = (): void => { inbox.fail(signal.reason) } @@ -141,7 +136,7 @@ export class RemoteStreamMuxClient { signal.removeEventListener('abort', abort) this.streams.delete(streamId) stream.pump?.stop() - if (opened && !terminal && carrier?.readyState === WEBSOCKET_OPEN) { + if (opened && !terminal && carrier?.readyState === WebSocket.OPEN) { this.send(carrier, { type: 'cancel', streamId }) } // The old generation's pump has stopped before a supervisor reopens the next one. @@ -157,7 +152,7 @@ export class RemoteStreamMuxClient { * generator blocked in `next()` only completes it once it yields. */ private pumpUplink( - socket: ConnectionWebSocket, + socket: WebSocket, streamId: string, uplink: AsyncIterable, signal: AbortSignal, @@ -228,9 +223,9 @@ export class RemoteStreamMuxClient { await this.keepAlive } - private async connect(): Promise { - const socket = this.openSocket() - const connecting = new Promise((resolve, reject) => { + private connect(): Promise { + const socket = new WebSocket(remoteStreamUrl()) + const connecting = new Promise((resolve, reject) => { let settled = false const rejectCandidate = (error: Error): void => { settled = true @@ -269,7 +264,7 @@ export class RemoteStreamMuxClient { } this.lost(socket) } - const received = (event: { readonly data: unknown }): void => { this.receive(socket, event.data) } + const received = (event: MessageEvent): void => { this.receive(socket, event.data) } this.cancelCandidate = rejectCandidate socket.addEventListener('open', opened, { once: true }) socket.addEventListener('error', failed, { once: true }) @@ -279,9 +274,9 @@ export class RemoteStreamMuxClient { return connecting } - private waitForSocket(signal: AbortSignal): Promise { + private waitForSocket(signal: AbortSignal): Promise { signal.throwIfAborted() - if (this.socket?.readyState === WEBSOCKET_OPEN) return Promise.resolve(this.socket) + if (this.socket?.readyState === WebSocket.OPEN) return Promise.resolve(this.socket) if (this.disposed) return Promise.reject(new Error('api gateway: Remote stream client disposed')) if (!this.running) return Promise.reject(new Error('api gateway: Remote stream client not started')) return new Promise((resolve, reject) => { @@ -308,7 +303,7 @@ export class RemoteStreamMuxClient { }) } - private receive(socket: ConnectionWebSocket, data: unknown): void { + private receive(socket: WebSocket, data: unknown): void { if (socket !== this.socket) return try { if (typeof data !== 'string') throw new Error('api gateway: Remote stream WebSocket requires text messages') @@ -327,7 +322,7 @@ export class RemoteStreamMuxClient { } private lost( - socket: ConnectionWebSocket, + socket: WebSocket, error: RemoteStreamCarrierError = new RemoteStreamCarrierError( 'api gateway: Remote stream WebSocket closed', ), @@ -339,7 +334,7 @@ export class RemoteStreamMuxClient { private maintain(): void { if (!this.running || this.disposed) return - if (this.socket?.readyState === WEBSOCKET_OPEN || this.keepAlive !== undefined) return + if (this.socket?.readyState === WebSocket.OPEN || this.keepAlive !== undefined) return const revision = this.revision const task = this.connect().then( () => undefined, @@ -363,7 +358,7 @@ export class RemoteStreamMuxClient { } } - private send(socket: ConnectionWebSocket, message: RemoteStreamClientMessage): void { + private send(socket: WebSocket, message: RemoteStreamClientMessage): void { socket.send(JSON.stringify(message)) } } @@ -473,3 +468,13 @@ export class ClientUplinkQueue implements AsyncIterable, AsyncIterator< wake?.() } } + +function remoteStreamUrl(): string { + // The mux route is registered absolute; a page resolves its document-relative + // form against its own document base. A shell-owned Host on another origin + // supplies that base through the transport. + const globals = globalThis as { __DSH_TRANSPORT__?: { streamBaseUrl?: string } } + const url = new URL(REMOTE_STREAM_MUX_PATH.slice(1), globals.__DSH_TRANSPORT__?.streamBaseUrl ?? document.baseURI) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + return url.href +} diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 4eb96330bc..877dd83194 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -46,7 +46,6 @@ import { rejectRemoteStreamUpgrade, } from './stream-server.ts' import { - GATEWAY_CLIENT_CONFIG_GLOBAL, REMOTE_EVENT_STREAM_ENDPOINT, REMOTE_EVENT_STREAM_READY, REMOTE_EVENT_RESULT_ENDPOINT, @@ -150,17 +149,11 @@ export interface Config { readonly websocketHeartbeatIntervalMs?: number /** Buffered uplink frame bytes one logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */ readonly streamInboxBytes?: number - /** - * Served pages receive forwarded Host events, including Agent-scoped approval and question - * requests; their Gateway Client then answers or delegates those requests. @default false - */ - readonly forwardedEvents?: boolean } interface ResolvedConfig extends Config { readonly websocketHeartbeatIntervalMs: number readonly streamInboxBytes: number - readonly forwardedEvents: boolean } /** @@ -210,7 +203,6 @@ export class TypertGatewayService extends Service implements TypertGateway { websocketHeartbeatIntervalMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS) .default(DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS), streamInboxBytes: z.number().step(1).min(1).default(DEFAULT_STREAM_INBOX_BYTES), - forwardedEvents: z.boolean().default(false), }) /** Carrier adapter shared by the WebSocket mux and local Host transports. */ @@ -247,9 +239,6 @@ export class TypertGatewayService extends Service implements TypertGateway { ) }) ctx.inject(['connection', 'webServer'], (webCtx) => { - webCtx.on('webserver/index-inject', (table) => { - table.push({ kind: 'global', name: GATEWAY_CLIENT_CONFIG_GLOBAL, value: { forwardedEvents: resolved.forwardedEvents } }) - }) const listen = (): void => { const mux = new RemoteStreamMuxServer( (endpoint, payload, uplink, peer, control, window) => diff --git a/packages/api/gateway/src/stream-protocol.ts b/packages/api/gateway/src/stream-protocol.ts index 65819083b8..610fc07780 100644 --- a/packages/api/gateway/src/stream-protocol.ts +++ b/packages/api/gateway/src/stream-protocol.ts @@ -6,9 +6,6 @@ import { isRemoteJsonValue } from '@deepseek-ai/dsh-typert-protocol' /** Exact WebSocket route carrying every Typert Remote stream. */ export const REMOTE_STREAM_MUX_PATH = '/api/remote.mux' -/** Page global through which the serving Host supplies the Gateway Client's Config to browser pages. */ -export const GATEWAY_CLIENT_CONFIG_GLOBAL = '__DSH_GATEWAY_CLIENT__' - /** Gateway-internal logical stream carrying application-selected Cordis events. */ export const REMOTE_EVENT_STREAM_ENDPOINT = '$events' diff --git a/packages/api/gateway/tests/control-retry.client.spec.ts b/packages/api/gateway/tests/control-retry.client.spec.ts index b3c7317a10..c818d507e7 100644 --- a/packages/api/gateway/tests/control-retry.client.spec.ts +++ b/packages/api/gateway/tests/control-retry.client.spec.ts @@ -97,25 +97,6 @@ describe('RemoteStream', () => { await stream.dispose() }) - it('reopens after an accepted generation ends normally and fails one that ends before its opening item', async () => { - const source = hostSource(true) - const reopened = new RemoteStream(source.connection, { name: 'default stream', open: scripted([ - { values: ['first'] }, - { values: ['second'], hold: true }, - ]) }) - const iterator = reopened[Symbol.asyncIterator]() - const first = await iterator.next() - if (first.done) throw new Error('fixture generation ended early') - first.value.accept() - expect(await iterator.next()).toMatchObject({ done: false, value: { generation: 2, value: 'second' } }) - await reopened.dispose() - - const bare = new RemoteStream(source.connection, { name: 'default stream', open: scripted([{ values: [] }]) }) - await expect(bare[Symbol.asyncIterator]().next()).rejects.toMatchObject({ - isDSHRemoteError: true, code: 'gateway/internal', message: 'default stream ended before its opening item', - }) - }) - it('permits one isolated retry while the Host remains available', async () => { const source = hostSource(true) const first = new RemoteStreamCarrierError('first carrier failure') diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index e7932ecbe3..2efcb950e0 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -415,11 +415,9 @@ describe('Typert Remote streams', () => { }) it('validates the WebSocket heartbeat timer range and the stream inbox bound', () => { - expect(TypertGatewayService.Config({})).toEqual({ - websocketHeartbeatIntervalMs: 2_000, streamInboxBytes: 262_144, forwardedEvents: false, - }) - expect(TypertGatewayService.Config({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS, streamInboxBytes: 1, forwardedEvents: true })) - .toEqual({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS, streamInboxBytes: 1, forwardedEvents: true }) + expect(TypertGatewayService.Config({})).toEqual({ websocketHeartbeatIntervalMs: 2_000, streamInboxBytes: 262_144 }) + expect(TypertGatewayService.Config({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS, streamInboxBytes: 1 })) + .toEqual({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS, streamInboxBytes: 1 }) for (const websocketHeartbeatIntervalMs of [0, 1.5, MAX_TIMER_DELAY_MS + 1]) { expect(() => TypertGatewayService.Config({ websocketHeartbeatIntervalMs })).toThrow() } @@ -428,13 +426,6 @@ describe('Typert Remote streams', () => { } }) - it.each([false, true])('publishes the served-page forwarded-event choice %s', async (forwardedEvents) => { - const { ctx } = await setup(true, TypertGatewayService.Config({ forwardedEvents })) - expect(ctx.webServer.collectIndexInjections()).toContainEqual({ - kind: 'global', name: '__DSH_GATEWAY_CLIENT__', value: { forwardedEvents }, - }) - }) - it('opens decoded carrier payloads through the in-process wire adapter', async () => { const { ctx } = await setup(false) const source = await ctx.typertGateway.wireStream.open( diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index ce40decf81..b0c7d4f390 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -1,6 +1,5 @@ import { queryObjects } from 'node:v8' import { RemoteError, typertOwnedValue } from '@deepseek-ai/dsh-typert-protocol' -import type { ConnectionWebSocketEventMap } from '@deepseek-ai/dsh-client-connection/client' import { Context, Service } from '@deepseek-ai/cordis' import type { Fiber } from '@deepseek-ai/cordis' import { describe, expect, expectTypeOf, it, vi } from 'vitest' @@ -26,7 +25,7 @@ import type { } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { ClientRemote } from '../src/client/index.ts' -import { apply, Config, inject, isRemoteFailure, RemoteStream } from '../src/client/index.ts' +import { apply, inject, isRemoteFailure, RemoteStream } from '../src/client/index.ts' import { ClientUplinkQueue, RemoteStreamCarrierError, @@ -256,27 +255,6 @@ function attachDescriptor(): InvocationDescriptor { type WebSocketGlobal = { WebSocket?: typeof WebSocket } class FakeWebSocket extends EventTarget { - override addEventListener( - type: E, listener: (event: ConnectionWebSocketEventMap[E]) => void, options?: { readonly once?: boolean }, - ): void - override addEventListener( - type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions, - ): void - override addEventListener(type: string, listener: unknown, options?: boolean | AddEventListenerOptions): void { - // This fixture dispatches MessageEvent for messages and Event for other socket events. - super.addEventListener(type, listener as EventListenerOrEventListenerObject | null, options) - } - - override removeEventListener( - type: E, listener: (event: ConnectionWebSocketEventMap[E]) => void, - ): void - override removeEventListener( - type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions, - ): void - override removeEventListener(type: string, listener: unknown, options?: boolean | EventListenerOptions): void { - super.removeEventListener(type, listener as EventListenerOrEventListenerObject | null, options) - } - static readonly CONNECTING = 0 static readonly OPEN = 1 static readonly CLOSING = 2 @@ -370,16 +348,11 @@ async function benchFiber( const generation = new GenerationHarness() const start = vi.fn(() => ({ stop: () => {} })) ctx.provide('connection', { - isLoopback: false, - generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, - state: { getSnapshot: () => undefined, subscribe: () => () => {} }, - reconnect: () => { throw new Error('fixture reconnect is not driven here') }, rpc, registerGenerationSource: generation.register, - openWebSocket: (path: string) => new FakeWebSocket(`wss://harness.example${path}`), start, - }) - const client = ctx.plugin({ inject, apply, Config }, { forwardedEvents: true }) + } as unknown as ConnectionHandle) + const client = ctx.plugin({ inject, apply }) await client return { ctx, client, generation, start } } @@ -478,17 +451,12 @@ async function loaderReadinessBench( const start = vi.fn(() => ({ stop })) const call = vi.fn() ctx.provide('connection', { - isLoopback: false, - generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, - state: { getSnapshot: () => undefined, subscribe: () => () => {} }, - reconnect: () => { throw new Error('fixture reconnect is not driven here') }, rpc: carrier === 'web' ? { call } : { call, open: () => unexpectedInProcessStream() }, registerGenerationSource: generation.register, - openWebSocket: (path: string) => new FakeWebSocket(`wss://harness.example${path}`), start, - }) + } as unknown as ConnectionHandle) ctx.provide('loader', { await: () => readiness }) - const client = ctx.plugin({ inject, apply, Config }, { forwardedEvents: true }) + const client = ctx.plugin({ inject, apply }) await client return { client, start, stop } } @@ -630,7 +598,7 @@ describe('Client Remote transport readiness', () => { start: () => ({ stop: () => {} }), } as unknown as ConnectionHandle const withdraw = ctx.provide('connection', handle) - const client = ctx.plugin({ inject, apply, Config }, { forwardedEvents: true }) + const client = ctx.plugin({ inject, apply }) await client const remote = ctx.remote @@ -681,7 +649,7 @@ describe('Client Remote transport readiness', () => { try { await ctx.plugin(TypertRegistry) await ctx.plugin({ inject: [], apply: applyConnection }) - await ctx.plugin({ inject, apply, Config }, { forwardedEvents: true }) + await ctx.plugin({ inject, apply }) await vi.advanceTimersByTimeAsync(0) const connection = ctx.get('connection') as ConnectionHandle expect(FakeWebSocket.sockets).toHaveLength(1) @@ -1271,10 +1239,8 @@ describe('Client Typert API', () => { const dispose = await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [first, second] }) const invocation = ctx.remote.probe.create('agent-1', { objective: 'ship' }) await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) - const disposal = dispose() - await vi.waitFor(() => { expect(call.mock.calls[0]?.[3]?.aborted).toBe(true) }) + await dispose() resolveCall({ ok: true, value: { ref: 'goal-1' } }) - await disposal await expect(invocation).resolves.toMatchObject({ ok: false, @@ -2133,7 +2099,7 @@ describe('Client Typert API', () => { try { await ctx.plugin(TypertRegistry) await ctx.plugin({ inject: [], apply: applyConnection }) - await ctx.plugin({ inject, apply, Config }, { forwardedEvents: true }) + await ctx.plugin({ inject, apply }) const connection = ctx.get('connection') as ConnectionHandle | undefined if (connection === undefined) throw new Error('injected Connection service is unavailable') @@ -2841,7 +2807,7 @@ describe('Client Typert API', () => { describe('Remote stream client carrier lifecycle', () => { it('does not pull another uplink item when sending ends the stream', async () => { await withFakeWebSocket('https://harness.example', async () => { - const client = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) + const client = new RemoteStreamMuxClient() client.start() const socket = FakeWebSocket.sockets[0]! const send = socket.send.bind(socket) @@ -2888,7 +2854,7 @@ describe('Remote stream client carrier lifecycle', () => { }, }), } - const client = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) + const client = new RemoteStreamMuxClient() client.start() const iterator = client.open('probe/attach', {}, new AbortController().signal, uplink)[Symbol.asyncIterator]() const first = iterator.next() @@ -2914,9 +2880,10 @@ describe('Remote stream client carrier lifecycle', () => { }) }) - it('uses the selected physical carrier independently of the document origin', async () => { + it('connects to the shell-owned Host while the document uses a local asset origin', async () => { await withFakeWebSocket('dsh-app://app', async () => { - const client = new RemoteStreamMuxClient(() => new FakeWebSocket('ws://127.0.0.1:43210/api/remote.mux')) + vi.stubGlobal('__DSH_TRANSPORT__', { streamBaseUrl: 'http://127.0.0.1:43210' }) + const client = new RemoteStreamMuxClient() try { client.start() expect(FakeWebSocket.sockets[0]!.url).toBe('ws://127.0.0.1:43210/api/remote.mux') @@ -2929,7 +2896,7 @@ describe('Remote stream client carrier lifecycle', () => { it('fails the downlink and cancels when an uplink iterable throws, and swallows a rejecting uplink return()', async () => { await withFakeWebSocket('https://harness.example', async () => { - const client = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) + const client = new RemoteStreamMuxClient() client.start() const socket = FakeWebSocket.sockets[0]! const exploding = (async function* (): AsyncGenerator { @@ -2962,7 +2929,7 @@ describe('Remote stream client carrier lifecycle', () => { }) it('requires the transport owner to start the physical carrier', async () => { - const client = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) + const client = new RemoteStreamMuxClient() await expect(client.open('feed/follow', {}, new AbortController().signal) [Symbol.asyncIterator]().next()).rejects.toThrow('Remote stream client not started') await client.close() @@ -2971,7 +2938,7 @@ describe('Remote stream client carrier lifecycle', () => { it('connects without a logical stream, waits for owner-driven retries, and stops permanently', async () => { await withFakeWebSocket('https://harness.example', async () => { FakeWebSocket.autoOpen = false - const client = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) + const client = new RemoteStreamMuxClient() client.start() client.start() expect(FakeWebSocket.sockets).toHaveLength(1) @@ -3006,7 +2973,7 @@ describe('Remote stream client carrier lifecycle', () => { expect(FakeWebSocket.sockets).toHaveLength(3) expect(final.closedWith).toContainEqual({ code: 1000, reason: 'disposed' }) - const stopping = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) + const stopping = new RemoteStreamMuxClient() stopping.start() const racing = FakeWebSocket.sockets[3]! racing.open() @@ -3018,7 +2985,7 @@ describe('Remote stream client carrier lifecycle', () => { it('mints a new wire stream id when the same endpoint opens on a replacement socket', async () => { await withFakeWebSocket('https://harness.example', async () => { - const client = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) + const client = new RemoteStreamMuxClient() client.start() const first = client.open('feed/follow', { label: 'same' }, new AbortController().signal) [Symbol.asyncIterator]() @@ -3047,7 +3014,7 @@ describe('Remote stream client carrier lifecycle', () => { it('replaces an in-flight candidate and an open socket on reconnect', async () => { await withFakeWebSocket('https://harness.example', async () => { FakeWebSocket.autoOpen = false - const client = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) + const client = new RemoteStreamMuxClient() client.start() const candidate = FakeWebSocket.sockets[0]! @@ -3080,7 +3047,7 @@ describe('Remote stream client carrier lifecycle', () => { it('coalesces repeated candidate replacements and drops one queued after close', async () => { await withFakeWebSocket('https://harness.example', async () => { FakeWebSocket.autoOpen = false - const client = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) + const client = new RemoteStreamMuxClient() client.start() const first = FakeWebSocket.sockets[0]! @@ -3096,10 +3063,10 @@ describe('Remote stream client carrier lifecycle', () => { }) }) - it('shares an in-flight physical connection', async () => { + it('shares an in-flight connection and resolves the mux route against the document base', async () => { await withFakeWebSocket('https://harness.example/mounted/app/', async () => { FakeWebSocket.autoOpen = false - const client = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) + const client = new RemoteStreamMuxClient() client.start() const first = client.open('feed/follow', { label: 'first' }, new AbortController().signal) [Symbol.asyncIterator]() @@ -3109,6 +3076,7 @@ describe('Remote stream client carrier lifecycle', () => { const secondPending = second.next() expect(FakeWebSocket.sockets).toHaveLength(1) const socket = FakeWebSocket.sockets[0]! + expect(socket.url).toBe('wss://harness.example/mounted/app/api/remote.mux') socket.open() await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) }) @@ -3124,7 +3092,7 @@ describe('Remote stream client carrier lifecycle', () => { it('fails waiters with one socket attempt and lets the owner start the next attempt', async () => { await withFakeWebSocket('http://harness.example/', async () => { FakeWebSocket.autoOpen = false - const closedClient = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) + const closedClient = new RemoteStreamMuxClient() closedClient.start() const closed = closedClient.open('feed/follow', {}, new AbortController().signal) [Symbol.asyncIterator]().next() @@ -3143,26 +3111,27 @@ describe('Remote stream client carrier lifecycle', () => { await expect(replacementStream).resolves.toEqual({ done: true, value: undefined }) await closedClient.close() - const disposedClient = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) + const disposedClient = new RemoteStreamMuxClient() disposedClient.start() const disposed = disposedClient.open('feed/follow', {}, new AbortController().signal) [Symbol.asyncIterator]().next() await disposedClient.close() await expect(disposed).rejects.toThrow('Remote stream client disposed') - const abortedClient = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) + const abortedClient = new RemoteStreamMuxClient() abortedClient.start() const abort = new AbortController() const aborted = abortedClient.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next() abort.abort('cancelled while connecting') await expect(aborted).rejects.toBe('cancelled while connecting') await abortedClient.close() + expect(FakeWebSocket.sockets[3]?.url).toBe('ws://harness.example/api/remote.mux') }) }) it('fails active streams on an invalid frame and ignores later frames', async () => { await withFakeWebSocket('https://harness.example', async () => { - const client = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) + const client = new RemoteStreamMuxClient() client.start() const stream = client.open('feed/follow', {}, new AbortController().signal)[Symbol.asyncIterator]() const pending = stream.next() @@ -3184,7 +3153,7 @@ describe('Remote stream client carrier lifecycle', () => { it('completes a stream and drops a frame racing with cancellation', async () => { await withFakeWebSocket('https://harness.example', async () => { - const client = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) + const client = new RemoteStreamMuxClient() client.start() const completed = client.open('feed/follow', {}, new AbortController().signal) [Symbol.asyncIterator]() @@ -3209,7 +3178,7 @@ describe('Remote stream client carrier lifecycle', () => { it('contains non-Error cancellation reasons and late socket close events', async () => { await withFakeWebSocket('http://harness.example', async () => { - const cancelledClient = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) + const cancelledClient = new RemoteStreamMuxClient() cancelledClient.start() const abort = new AbortController() const cancelled = cancelledClient.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next() @@ -3219,7 +3188,7 @@ describe('Remote stream client carrier lifecycle', () => { await cancelledClient.close() FakeWebSocket.dispatchClose = false - const disposedClient = new RemoteStreamMuxClient(() => new FakeWebSocket('wss://harness.example/api/remote.mux')) + const disposedClient = new RemoteStreamMuxClient() disposedClient.start() const disposed = disposedClient.open('feed/follow', {}, new AbortController().signal) [Symbol.asyncIterator]().next() @@ -3342,15 +3311,12 @@ describe('Remote result decoders', () => { const abort = new AbortController() const pending = ctx.remote.probe.bytes(abort.signal) expect(call).toHaveBeenCalledOnce() - // Unmounting waits for the in-flight call, so the late result settles it before disposal completes. - const disposal = action === 'cancel' ? undefined : dispose() if (action === 'cancel') abort.abort() - else await vi.waitFor(() => { expect(call.mock.calls[0]?.[3]?.aborted).toBe(true) }) + else await dispose() response.resolve({ ok: true, value: { data: new Uint8Array([7]), offset: 0 } }) expect(await pending).toMatchObject({ ok: false, error: { code: action === 'cancel' ? 'gateway/cancelled' : 'gateway/internal' }, }) - await disposal } finally { await client.dispose() } diff --git a/packages/api/gateway/tests/node-client.e2e.ts b/packages/api/gateway/tests/node-client.e2e.ts deleted file mode 100644 index 1f671b732d..0000000000 --- a/packages/api/gateway/tests/node-client.e2e.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** Published Client entries expose ordinary ESM for passive Node compositions. */ - -import { execFile } from 'node:child_process' -import { existsSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { promisify } from 'node:util' -import { describe, expect, it } from 'vitest' - -const packageDirectory = fileURLToPath(new URL('..', import.meta.url)) -const built = ['../../client/connection', '../../typert/registry', '.'] - .every(path => existsSync(fileURLToPath(new URL(`${path}/lib/types/client/index.js`, new URL('../', import.meta.url))))) - -describe.skipIf(!built)('Node Client package entries', () => { - it('mounts typed Remotes without a browser or forwarded-event subscription', async () => { - const { stdout } = await promisify(execFile)(process.execPath, ['--input-type=module', '-e', ` - import { Context } from '@deepseek-ai/cordis' - import { installConnection } from '@deepseek-ai/dsh-client-connection/client' - import * as Registry from '@deepseek-ai/dsh-typert-registry/client' - import * as Gateway from '@deepseek-ai/dsh-api-gateway/client' - import { z } from 'zod' - - if ('window' in globalThis || 'document' in globalThis) throw new Error('smoke requires plain Node') - const calls = [] - const ctx = new Context() - try { - await ctx.plugin(Registry) - installConnection(ctx, { transport: { rpc: { - async call(channel, endpoint) { - calls.push(endpoint) - return { ok: true, value: 7 } - }, - async *open(channel, endpoint) { - calls.push(endpoint) - yield 8 - }, - } } }) - await ctx.plugin(Gateway) - const result = { mode: 'strict', typeSymbol: 'nodeProbe#number', create: () => z.number() } - const common = { service: 'nodeProbe', namespace: 'nodeProbe', invocation: { kind: 'direct' }, parameters: [], result } - await ctx.remote.$mount({ package: '@fixture/node-probe', descriptors: [ - { ...common, id: 'nodeProbe#read', method: 'read' }, - { ...common, id: 'nodeProbe#follow', method: 'follow', mode: 'stream' }, - ] }) - const read = await ctx.remote.nodeProbe.read() - const items = [] - for await (const item of ctx.remote.nodeProbe.follow()) items.push(item) - console.log(JSON.stringify({ read, items, calls, generation: ctx.connection.generation.getSnapshot() ?? null })) - } finally { - await ctx.fiber.dispose() - } - `], { cwd: packageDirectory, encoding: 'utf8', timeout: 30_000 }) - expect(JSON.parse(stdout.trim())).toEqual({ - read: { ok: true, value: 7 }, items: [8], - calls: ['nodeProbe/read', 'nodeProbe/follow'], generation: null, - }) - }) -}) diff --git a/packages/api/gateway/tests/passive.client.spec.ts b/packages/api/gateway/tests/passive.client.spec.ts deleted file mode 100644 index f4ad4e91b4..0000000000 --- a/packages/api/gateway/tests/passive.client.spec.ts +++ /dev/null @@ -1,250 +0,0 @@ -/** Passive generated Remotes and explicit forwarded-event participation over Node carriers. */ - -import { Context } from '@deepseek-ai/cordis' -import { createServer } from 'node:http' -import { setImmediate } from 'node:timers/promises' -import { afterEach, describe, expect, it, vi } from 'vitest' -import WebSocket, { WebSocketServer } from 'ws' -import { z } from 'zod' -import { - installConnection, type ConnectionHandle, type ConnectionWebSocketFactory, type RpcFetch, -} from '@deepseek-ai/dsh-client-connection/client' -import * as TypertRegistry from '@deepseek-ai/dsh-typert-registry/client' -import type { RemoteResult, TypertRemoteContribution, TypertRemoteNamespace } from '@deepseek-ai/dsh-typert-protocol' -import { apply, Config, inject, isRemoteFailure } from '../src/client/index.ts' -import { GATEWAY_CLIENT_CONFIG_GLOBAL, parseRemoteStreamClientMessage, type RemoteStreamClientMessage } from '../src/stream-protocol.ts' - -declare module '@deepseek-ai/cordis' { - interface Events { - /** - * Test-only forwarded Host event used to probe passive subscription refusal. - * @param value - marker payload never delivered. - */ - 'passive-probe/changed'(value: string): void - } -} - -declare module '@deepseek-ai/dsh-typert-protocol' { - interface TypertRemoteEventSelection extends Record<'passive-probe/changed', true> {} - interface TypertRemoteMap { - 'passiveProbe/echo': (value: string) => Promise> - 'passiveProbe/follow': (signal?: AbortSignal) => AsyncIterable - } - interface TypertRemoteNamespaceMap { - passiveProbe: TypertRemoteNamespace<'passiveProbe'> - } -} - -const contexts: Context[] = [] -const cleanup: (() => Promise)[] = [] - -afterEach(async () => { - await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) - await Promise.all(cleanup.splice(0).map(dispose => dispose())) - vi.unstubAllGlobals() -}) - -function contribution(): TypertRemoteContribution { - const codec = { mode: 'strict' as const, typeSymbol: 'passiveProbe#text', create: () => z.string() } - return { - package: '@fixture/passive-probe', - descriptors: [ - { - id: 'passiveProbe#echo', namespace: 'passiveProbe', service: 'passiveProbe', method: 'echo', - invocation: { kind: 'direct' }, - parameters: [{ name: 'value', wire: 'value', source: 'json', codec }], - result: codec, - }, - { - id: 'passiveProbe#follow', namespace: 'passiveProbe', service: 'passiveProbe', method: 'follow', - mode: 'stream', invocation: { kind: 'direct' }, parameters: [], - cancellation: { parameter: 'signal' }, result: codec, - }, - ], - } -} - -async function host() { - const requests: { readonly url: string | undefined; readonly cookie: string | undefined }[] = [] - const frames: RemoteStreamClientMessage[] = [] - const upgrades: (string | undefined)[] = [] - const server = createServer((request, response) => { - const chunks: Buffer[] = [] - request.on('data', (chunk: Buffer) => { chunks.push(chunk) }) - request.on('end', () => { - requests.push({ url: request.url, cookie: request.headers.cookie }) - const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as { - readonly rpcId: string - readonly payload: { readonly args: { readonly value: string } } - } - response.writeHead(200, { 'content-type': 'application/json' }) - response.end(JSON.stringify({ - type: 'server-response', rpcId: body.rpcId, - result: { ok: true, value: body.payload.args.value }, - })) - }) - }) - const sockets = new WebSocketServer({ noServer: true }) - server.on('upgrade', (request, socket, head) => { - upgrades.push(request.headers.cookie) - sockets.handleUpgrade(request, socket, head, (client) => { - client.on('message', (data) => { - const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data) - const frame = parseRemoteStreamClientMessage(bytes.toString('utf8')) - frames.push(frame) - if (frame.type !== 'open') return - client.send(JSON.stringify({ - type: 'item', streamId: frame.streamId, - value: frame.endpoint === '$events' - ? { type: 'ready', clientId: 'fixture-events', host: { home: '/fixture/home' } } - : 'opening', - })) - }) - }) - }) - cleanup.push(async () => { - for (const socket of sockets.clients) socket.terminate() - await new Promise((resolve, reject) => { - sockets.close((error) => { - if (error === undefined) resolve() - else reject(error) - }) - }) - if (server.listening) await new Promise((resolve, reject) => { - server.close((error) => { - if (error === undefined) resolve() - else reject(error) - }) - }) - }) - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(0, '127.0.0.1', resolve) - }) - const address = server.address() - if (address === null || typeof address === 'string') throw new Error('fixture server has no TCP address') - return { origin: `http://127.0.0.1:${address.port}`, requests, frames, upgrades, sockets } -} - -async function client(origin: string, doFetch?: RpcFetch, forwardedEvents: boolean | 'unconfigured' | 'page' = 'unconfigured') { - const ctx = new Context() - contexts.push(ctx) - const cookie = 'session=fixture' - const webSocket: ConnectionWebSocketFactory = url => new WebSocket(url, { headers: { cookie } }) - await ctx.plugin(TypertRegistry) - installConnection(ctx, { - transport: { - streamBaseUrl: origin, - webSocket, - fetch: doFetch ?? ((input, init) => fetch(new URL(input, origin), { - ...init, headers: { ...Object.fromEntries(new Headers(init.headers)), cookie }, - })), - }, - }) - if (forwardedEvents === 'page') vi.stubGlobal(GATEWAY_CLIENT_CONFIG_GLOBAL, { forwardedEvents: true }) - const gateway = typeof forwardedEvents === 'boolean' - ? ctx.plugin({ inject, apply, Config }, { forwardedEvents }) - : ctx.plugin({ inject, apply, Config }) - await gateway - const unmount = await ctx.remote.$mount(contribution()) - return { ctx, gateway, unmount, connection: ctx.get('connection') as ConnectionHandle } -} - -describe('passive Remote composition', () => { - it.each(['contribution', 'gateway', 'context'] as const)('aborts and joins a pending response body when its %s closes', async (owner) => { - const reading = Promise.withResolvers() - const aborted = Promise.withResolvers() - const release = Promise.withResolvers() - const { ctx, gateway, unmount } = await client('http://127.0.0.1:1', async (_input, init) => { - const signal = init.signal - if (signal === undefined || signal === null) throw new Error('Remote request has no cancellation signal') - signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true }) - return new Response(new ReadableStream({ - async pull(controller) { - reading.resolve(undefined) - await release.promise - controller.error(new Error('response body stopped')) - }, - }, { highWaterMark: 0 })) - }) - const retained = ctx.remote.passiveProbe.echo - const invocation = retained('pending body') - await reading.promise - let disposed = false - const disposal = (owner === 'contribution' ? unmount() : owner === 'gateway' ? gateway.dispose() : ctx.fiber.dispose()) - .then(() => { disposed = true }) - try { - expect(await Promise.race([aborted.promise.then(() => true), disposal.then(() => false)])).toBe(true) - // The next event-loop turn observes disposal after its ready microtasks, while the body owner remains held. - await setImmediate() - expect(disposed).toBe(false) - } finally { - release.resolve(undefined) - await disposal - } - const outcome = await invocation - expect(outcome).toMatchObject({ ok: false, error: { code: 'gateway/cancelled' } }) - if (!outcome.ok) expect(isRemoteFailure(outcome.error)).toBe(true) - expect(await retained('after disposal')).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) - }) - - it('calls generated methods and cancels a stream without joining forwarded events', async () => { - const server = await host() - const { ctx, unmount, connection } = await client(server.origin) - expect(server.requests).toEqual([]) - expect(server.upgrades).toEqual([]) - expect(connection.generation.getSnapshot()).toBeUndefined() - - await expect(ctx.remote.passiveProbe.echo('typed value')).resolves.toEqual({ ok: true, value: 'typed value' }) - expect(server.requests).toEqual([{ url: '/api/passiveProbe/echo', cookie: 'session=fixture' }]) - expect(server.upgrades).toEqual([]) - - const stream = ctx.remote.passiveProbe.follow()[Symbol.asyncIterator]() - await expect(stream.next()).resolves.toEqual({ done: false, value: 'opening' }) - const waiting = stream.next() - const aborted = expect(waiting).rejects.toThrow() - await unmount() - await aborted - await expect.poll(() => server.frames.filter(frame => frame.type === 'cancel')).toHaveLength(1) - expect(server.frames.filter(frame => frame.type === 'open').map(frame => frame.endpoint)) - .toEqual(['passiveProbe/follow']) - expect(server.upgrades).toEqual(['session=fixture']) - expect(connection.generation.getSnapshot()).toBeUndefined() - - await ctx.fiber.dispose() - await expect.poll(() => server.sockets.clients.size).toBe(0) - }) - - it('stays passive unless forwarded events are explicitly configured', async () => { - const server = await host() - const opened = () => server.frames.filter(frame => frame.type === 'open').map(frame => frame.endpoint) - const passive = [await client(server.origin), await client(server.origin, undefined, false)] - for (const { ctx, connection } of passive) { - await expect(ctx.remote.passiveProbe.echo('passive')).resolves.toEqual({ ok: true, value: 'passive' }) - expect(connection.generation.getSnapshot()).toBeUndefined() - expect(() => ctx.remote.$on('passive-probe/changed', () => {})).toThrow('needs forwardedEvents') - expect(() => ctx.remote.$stream({ name: 'fixture stream', open: () => ctx.remote.passiveProbe.follow() })) - .toThrow('needs forwardedEvents') - } - expect(opened()).toEqual([]) - - const configured = await client(server.origin, undefined, true) - await expect.poll(() => configured.connection.generation.getSnapshot()?.host.home).toBe('/fixture/home') - expect(opened()).toEqual(['$events']) - await expect(configured.ctx.remote.passiveProbe.echo('joined')).resolves.toEqual({ ok: true, value: 'joined' }) - expect(() => configured.ctx.remote.$on('passive-probe/changed', () => {})).not.toThrow() - - // A browser page reads the choice its Host Gateway row published. - const page = await client(server.origin, undefined, 'page') - await expect.poll(() => page.connection.generation.getSnapshot()?.host.home).toBe('/fixture/home') - expect(opened()).toEqual(['$events', '$events']) - - const overridden = await client(server.origin, undefined, false) - await expect(overridden.ctx.remote.passiveProbe.echo('passive page')).resolves.toEqual({ ok: true, value: 'passive page' }) - expect(overridden.connection.generation.getSnapshot()).toBeUndefined() - expect(opened()).toEqual(['$events', '$events']) - - for (const { ctx } of [overridden, page, configured, ...passive]) await ctx.fiber.dispose() - await expect.poll(() => server.sockets.clients.size).toBe(0) - }) -}) diff --git a/packages/api/job-controller/tests/client-apply.client.spec.ts b/packages/api/job-controller/tests/client-apply.client.spec.ts index 4a46e36619..39db59f0dc 100644 --- a/packages/api/job-controller/tests/client-apply.client.spec.ts +++ b/packages/api/job-controller/tests/client-apply.client.spec.ts @@ -25,7 +25,6 @@ async function mount(): Promise<{ ctx: Context; observeCalls: unknown[]; rowsCal generation: { getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), subscribe: () => () => {} }, state: { getSnapshot: () => 'connected' as const, subscribe: () => () => {} }, rpc: { call: () => Promise.reject(new Error('unexpected generic RPC call')) }, - openWebSocket: () => { throw new Error('unexpected WebSocket open') }, reconnect: () => {}, registerGenerationSource: () => () => {}, start: () => ({ stop: () => {} }), diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index 2051c31982..c8686dd4e2 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/README.i18n.yaml @@ -15,14 +15,14 @@ en: a7a78c6d1aeebeb3 zh: 4bb69f32d8b05ba3 /deepseek-ai-dsh-api-remotes/use-this-package: - en: 5bd571345016ec7e - zh: df43440e9c8b3aa9 + en: a80d44e0683768a5 + zh: 379833ac180e5418 /deepseek-ai-dsh-api-remotes/forwarded-host-events: en: d6d82bf0e55ae5e9 zh: c3a0c80f8b115039 /deepseek-ai-dsh-api-remotes/build-boundary: - en: 17f0dfe5163dba43 - zh: 1f33dcb4a3b1df76 + en: 7a9934a3ba5b185f + zh: 40457fd5707f57f0 /deepseek-ai-dsh-api-remotes/model-experience: en: 6a60ae036c71e76f zh: bf5e86a5ac5b4be4 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index 5b8639feea..f479777030 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -29,11 +29,11 @@ Two-sided BFF for Host Remote capabilities selected by this application. The Hos [`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.md) owns Agent and Session identity policy, including the Typert lookup resolvers used by other namespaces. This package selects and mounts that generated Session contribution without a direct runtime dependency on `@deepseek-ai/dsh-session`; activation policy stays with Session Controller. -The Client assembly mounts Office conversion, Commands, credentials, settings, Goal, Schedule, dynamic Cordis, file and Session references, read-only Host plugin inventory, plugin-installation registry response comparison, message feedback, permission presets, Session Controller, subagents, and Workspace Controller contributions. The `permissionPresets` namespace returns the complete process-level catalog used by current-session controls. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. Each ordinary contribution remains a mountable `TYPERT_REMOTE`; consumers needing static method information use its typed `methods` index rather than another metadata export (see the [generator reference](<../../typert/generator/README.md#ordinary-remote-metadata>)). The Client entry calls the typed Gateway service through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. +The Client assembly mounts Office conversion, Commands, credentials, settings, Goal, Schedule, dynamic Cordis, file and Session references, read-only Host plugin inventory, plugin-installation registry response comparison, message feedback, permission presets, Session Controller, subagents, and Workspace Controller contributions. The `permissionPresets` namespace returns the complete process-level catalog used by current-session controls. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. This facade is also the front door for the wire type vocabulary a Client package names. It re-exports, type-only, the Remote failure vocabulary (`RemoteResult`, `RemoteFailure`, `RemoteErrorCode`, `RemoteErrorDetailsMap`), the Host facts (`RemoteHostFacts`), and each selected domain's client-safe payload types, so a Client feature package imports one specifier instead of reaching into `dsh-typert-protocol`, the Gateway, or an owner's Host entry. Two kinds of package deliberately skip this door: the API-layer packages this assembly itself selects — importing it back would close a dependency cycle — and their tests, which take the failure vocabulary from `dsh-typert-protocol` directly. A UI package's tests instead take the `RemoteError` constructor from [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md). -This package owns no physical transport or Host service discovery. It projects the application selection into generated Remote contributions and an independent Host event source per Client; API Gateway owns endpoints, carriers, cancellation, and reconnection. The Web bundle sets the Host Gateway row's `forwardedEvents: true`, so its served pages join forwarded events. A passive Node composition instead mounts the Gateway Client without that option and with its selected generated contributions, without this Web assembly; it shares the same React-free Remote methods. +This package owns no physical transport or Host service discovery. It projects the application selection into generated Remote contributions and an independent Host event source per Client; API Gateway owns endpoints, carriers, cancellation, and reconnection. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract. ----- @@ -59,8 +59,6 @@ That exception is not just a `files` entry. The root `tsconfig.base.json` maps ` The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; split only when the two source sets require different compiler faces. -The [Session consumer artifact test](tests/session-client.e2e.ts) independently compiles a test-local Registry/CLI/MCP adaptation against ordinary declarations, then runs plain Node with real SessionController HTTP/WebSocket calls. Its CLI/MCP selection and intake both use `method.input({ strict: true })`, pairing JSON Schema with the same view's parser without tightening Host dispatch. It covers current `session/page` and `session/follow`, offline tool selection, input rejection, current-session injection, cancellation, and disposal. Its manual Context composition is not a supported application entry or a full downstream Loader/profile integration; unavailable attachment preparation rejects explicitly. - ## Model Experience diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index c88f1fc378..b130e28b76 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -29,11 +29,11 @@ kind: "package-reference" [`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.zh.md) 拥有 agent(智能体)与会话身份策略,包括供其他 namespace 使用的 Typert lookup 解析器。本包选择并挂载生成的会话 contribution,无需直接依赖 `@deepseek-ai/dsh-session` 的运行时;激活策略由会话控制器负责。 -Client 组合挂载 Office 转换、Commands、凭据、settings、Goal、Schedule、动态 Cordis、文件与会话引用、只读 Host 插件清单、插件安装源响应比较、消息反馈、权限预设、会话控制器、subagents 和 Workspace 控制器 contribution。`permissionPresets` namespace 返回 current-session 控件使用的完整进程级目录。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪的 namespace 服务、直接与作用域方法、调用、流与取消。每项普通贡献仍是可挂载的 `TYPERT_REMOTE`;需要静态方法信息的消费方使用其类型化 `methods` 索引,而非另一份元数据导出(参见[生成器参考](<../../typert/generator/README.zh.md#ordinary-remote-metadata>))。Client 入口通过 Cordis 调用带类型的 Gateway 服务,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 +Client 组合挂载 Office 转换、Commands、凭据、settings、Goal、Schedule、动态 Cordis、文件与会话引用、只读 Host 插件清单、插件安装源响应比较、消息反馈、权限预设、会话控制器、subagents 和 Workspace 控制器 contribution。`permissionPresets` namespace 返回 current-session 控件使用的完整进程级目录。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪的 namespace 服务、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 本 facade 同时是 Client 包指称 wire 类型词汇的正门。它以 type-only 方式转出 Remote 失败词汇(`RemoteResult`、`RemoteFailure`、`RemoteErrorCode`、`RemoteErrorDetailsMap`)、Host 事实(`RemoteHostFacts`),以及各已选领域对 Client 安全的载荷类型,因此 Client 功能包只 import 一个 specifier,不必伸手进 `dsh-typert-protocol`、Gateway 或某个拥有方的 Host 入口。有两类包刻意不走这道门:本装配自己选中的 API 层包——反向 import 会形成依赖环——以及它们的测试,后者直接从 `dsh-typert-protocol` 取失败词汇。UI 包的测试则从 [`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 取 `RemoteError` 构造器。 -本包不拥有物理传输或 Host 服务发现。它只把应用选择投影为生成的 Remote contribution,以及每个 Client 各自独立的 Host 事件源;API Gateway 负责 endpoint、carrier、取消与重连。Web bundle 设置 Host Gateway 行的 `forwardedEvents: true`,因此其已服务的页面会加入转发事件。被动 Node 组合改为不带该选项挂载 Gateway Client 及所需的生成贡献,不挂载这一 Web 装配;它使用同一组不依赖 React 的 Remote 方法。 +本包不拥有物理传输或 Host 服务发现。它只把应用选择投影为生成的 Remote contribution,以及每个 Client 各自独立的 Host 事件源;API Gateway 负责 endpoint、carrier、取消与重连。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。 ----- @@ -59,8 +59,6 @@ Host entry 为每条 Client 流独立注册一组 allowlist listener 和一个 包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project,并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle;只有两组源码需要不同 compiler face 时才拆分。 -[Session 消费方产物测试](tests/session-client.e2e.ts)将测试专用 Registry/CLI/MCP 适配对普通声明独立编译,再使用普通 Node 发起真实 SessionController HTTP/WebSocket 调用。其 CLI/MCP 选择与输入接收均使用 `method.input({ strict: true })`,将 JSON Schema 与同一视图的解析器配套使用,而不收紧 Host 分发。它覆盖当前 `session/page` 和 `session/follow`、离线工具选择、输入拒绝、当前 Session 注入、取消及释放。其手动 Context 组合不是受支持的应用入口,也不是完整下游 Loader/profile 集成;不可用的附件准备会显式拒绝。 - ## 模型体验 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index bb2f119008..1ac78ce12f 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -81,19 +81,13 @@ "@deepseek-ai/dsh-permission-presets": "workspace:*", "@deepseek-ai/dsh-schedule": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", - "@deepseek-ai/dsh-session": "workspace:*", - "@deepseek-ai/dsh-session-projection": "workspace:*", - "@deepseek-ai/dsh-session-query": "workspace:*", "@deepseek-ai/dsh-session-reference": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", - "@deepseek-ai/dsh-typert-registry": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/dsh-user-questions": "workspace:*", "@deepseek-ai/cordis": "workspace:~", - "typescript": "^6.0.3", - "ws": "^8.21.3", "zod": "4.4.3", "@deepseek-ai/dsh-command-feedback": "workspace:*", "@deepseek-ai/dsh-api-terminal-controller": "workspace:*", diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index e725262bbe..991c1ac46e 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -175,7 +175,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { '@deepseek-ai/dsh-api-remotes', ]) { const plugin = instantiate(id) - await client.plugin({ inject: plugin.inject, apply: plugin.apply, Config: plugin.Config }) + await client.plugin({ inject: plugin.inject, apply: plugin.apply }) } client.typert.contexts.registerClient('agent', { identity: candidate => candidate.builtAgentId, diff --git a/packages/api/remotes/tests/fixtures/session-client/client.ts b/packages/api/remotes/tests/fixtures/session-client/client.ts deleted file mode 100644 index 3f40de67bc..0000000000 --- a/packages/api/remotes/tests/fixtures/session-client/client.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** Test-local extraction of Session Client selection, intake projection, and invocation. */ - -import { z } from 'zod' -import type { TypertClientRemote, TypertRemoteRegistry } from '@deepseek-ai/dsh-typert-protocol' -import { isTypertRemoteMethod } from '@deepseek-ai/dsh-typert-protocol/remote-info' -import type { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' -import type { SessionId } from '@deepseek-ai/dsh-session/types' - -/** Only explicitly selected endpoints become local operations. */ -export const selectedEndpoints = ['session/page', 'session/follow'] as const -/** Statically known endpoint names; Registry lookup itself remains runtime-typed. */ -export type Endpoint = typeof selectedEndpoints[number] -/** The ordinary generated artifact retains exact named arguments for known callers. */ -export type PageArguments = Parameters[1] -/** Successful output comes from the generated parser rather than a duplicate declaration. */ -export type PageOutput = ReturnType['parse']> - -/** Local wrapper retained from Session Client; neither selected operation accepts content. */ -export interface LocalRemoteInput { - readonly arguments: Arguments - readonly current?: true - readonly content?: readonly unknown[] -} - -const intake = z.strictObject({ - arguments: z.record(z.string(), z.unknown()), - current: z.literal(true).optional(), -}) - -function object(value: unknown): Record { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new TypeError('Selected Session operation requires an object schema') - } - return value as Record -} - -/** - * Select documented operations from the caller's offline Registry without mounting a transport. - * @param registry - Registry whose contribution lifetime belongs to the caller. - * @returns local operation lookup, schema projection, and validated preparation. - */ -export function sessionOperations(registry: TypertRemoteRegistry) { - function method(endpoint: Endpoint) { - if (!selectedEndpoints.some(selected => selected === endpoint)) throw new Error(`Unselected Session endpoint: ${endpoint}`) - const descriptor = registry.get(endpoint) - if (descriptor === undefined || !isTypertRemoteMethod(descriptor)) throw new Error(`Missing generated method for ${endpoint}`) - if (!descriptor.description || !descriptor.parameters.find(parameter => parameter.wire === 'request')?.description) { - throw new Error(`Selected tool ${endpoint} requires method and request documentation`) - } - const input = descriptor.input({ strict: true }) - if (input.jsonSchema === undefined) { - throw new Error(`Selected tool ${endpoint} requires an input JSON Schema: ${input.jsonSchemaError ?? 'unavailable'}`) - } - return descriptor - } - - function inputSchema(endpoint: Endpoint): Readonly> { - const schema = method(endpoint).input({ strict: true }).jsonSchema - const branches = [false, true].map((current) => { - const argumentsSchema = structuredClone(object(schema)) - if (current) { - const request = object(object(argumentsSchema.properties).request) - const properties = object(request.properties) - request.properties = Object.fromEntries(Object.entries(properties).filter(([key]) => key !== 'address')) - request.additionalProperties = false - request.required = z.array(z.string()).parse(request.required).filter(key => key !== 'address') - } - return { - type: 'object', - properties: { - arguments: argumentsSchema, - ...(current ? { current: { type: 'boolean', const: true } } : {}), - }, - required: ['arguments', ...(current ? ['current'] : [])], - additionalProperties: false, - } - }) - return { oneOf: branches } - } - - function prepare(endpoint: Endpoint, input: unknown, callerSessionId?: SessionId): object { - if (typeof input === 'object' && input !== null && 'content' in input) { - throw new TypeError(`${endpoint} does not accept local content`) - } - const parsed = intake.parse(input) - z.fromJSONSchema(inputSchema(endpoint)).parse(parsed) - const args = structuredClone(parsed.arguments) - if (parsed.current === true) { - if (!callerSessionId) throw new Error('current requires caller Session identity') - args.request = { ...object(args.request), address: { kind: 'session', sessionId: callerSessionId } } - } - return method(endpoint).input({ strict: true }).create().parse(args) - } - - return { method, inputSchema, prepare, list: () => selectedEndpoints.map(endpoint => ({ endpoint, method: method(endpoint) })) } -} - -/** - * Invoke through an already-mounted ordinary Remote; own neither its Context nor discovery. - * @param operations - offline selection and intake projection. - * @param remote - caller-owned mounted Gateway Client. - * @param callerSessionId - optional identity used only by the current convenience. - * @returns unary calls and complete, cancellable stream handles. - */ -export function sessionClient( - operations: ReturnType, - remote: TypertClientRemote, - callerSessionId?: SessionId, -) { - return { - async call(endpoint: Endpoint, input: unknown, signal?: AbortSignal): Promise { - const method = operations.method(endpoint) - if (method.mode === 'stream') throw new Error(`${endpoint} requires stream()`) - const result = await method.invoke(remote, operations.prepare(endpoint, input, callerSessionId), signal) - if (!result.ok) throw result.error - return method.result.create().parse(result.value) - }, - stream(endpoint: Endpoint, input: unknown, signal?: AbortSignal) { - const method = operations.method(endpoint) - if (method.mode !== 'stream') throw new Error(`${endpoint} requires call()`) - // Gateway validates each item; preserve the full handle, including send/end/dispose. - return method.invoke(remote, operations.prepare(endpoint, input, callerSessionId), signal) - }, - } -} diff --git a/packages/api/remotes/tests/fixtures/session-client/mcp.ts b/packages/api/remotes/tests/fixtures/session-client/mcp.ts deleted file mode 100644 index 18299e62c9..0000000000 --- a/packages/api/remotes/tests/fixtures/session-client/mcp.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** Test-local MCP presentation and result envelopes; no server, transport, or automatic exposure. */ - -import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' -import type { sessionClient, sessionOperations } from './client.ts' - -const failureSchema = { - type: 'object', properties: { - ok: { const: false }, - error: { - type: 'object', properties: { code: { type: 'string' }, message: { type: 'string' }, details: { type: 'object' } }, - required: ['code', 'message', 'details'], additionalProperties: false, - }, - }, - required: ['ok', 'error'], additionalProperties: false, -} - -function withoutDescriptions(schema: Readonly>): Record { - const result = structuredClone(schema) - function strip(value: unknown): void { - if (typeof value !== 'object' || value === null) return - if (Array.isArray(value)) { value.forEach(strip); return } - const node = value as Record - delete node.description - if (typeof node.properties === 'object' && node.properties !== null) Object.values(node.properties).forEach(strip) - strip(node.items) - strip(node.oneOf) - } - strip(result) - return result -} - -/** - * Project generated schemas into MCP envelopes without requiring an output projection. - * @param input - required local intake schema. - * @param output - optional generated output JSON Schema, independent of the output parser. - * @returns detached input fields and an output envelope only when projectable. - */ -export function mcpSchemas(input: Readonly>, output?: Readonly>) { - return { - inputSchema: { ...withoutDescriptions(input), type: 'object' }, - ...(output === undefined ? {} : { outputSchema: { - type: 'object', oneOf: [ - { type: 'object', properties: { ok: { const: true }, value: withoutDescriptions(output) }, required: ['ok', 'value'], additionalProperties: false }, - failureSchema, - ], - } }), - } -} - -/** - * Present the explicitly selected finite operation; stream endpoints are not MCP tools. - * @param operations - offline Registry-backed operation selection. - * @returns an MCP tools/list entry with detached validation fields. - */ -export function pageTool(operations: ReturnType) { - return { - name: 'remote_session_page', - description: 'Read Session or subagent history without starting it. Records are oldest first. Use the opening follow cursor as throughSeq and the first record\'s sequence as beforeSeq to read older history when hasMore is true. maxMessages limits complete messages; turnWindow requests minimum messages and turns within that limit. To target the calling Session, set current: true and omit its target from arguments.request. This requires caller identity from the selected server.', - ...mcpSchemas(operations.inputSchema('session/page'), operations.method('session/page').result.jsonSchema), - } -} - -/** - * Run the selected unary operation with CLI-compatible success/failure envelopes. - * @param client - mounted Session Client whose executor validates intake and output. - * @param input - untrusted local wrapper supplied by an MCP caller. - * @param signal - caller-owned cancellation. - * @returns identical text and structured results, with isError on failures. - */ -export async function callPageTool(client: ReturnType, input: unknown, signal?: AbortSignal) { - try { - const success = { ok: true, value: await client.call('session/page', input, signal) } - return { content: [{ type: 'text', text: JSON.stringify(success) }], structuredContent: success } - } catch (error) { - const remote = remoteErrorOf(error) - const failure = { ok: false, error: remote !== undefined - ? { code: remote.code, message: remote.message, details: remote.details } - : { code: 'session-client/invalid', message: error instanceof Error ? error.message : String(error), details: {} }, - } - return { content: [{ type: 'text', text: JSON.stringify(failure) }], structuredContent: failure, isError: true } - } -} diff --git a/packages/api/remotes/tests/fixtures/session-client/run.mjs b/packages/api/remotes/tests/fixtures/session-client/run.mjs deleted file mode 100644 index b60b99dd13..0000000000 --- a/packages/api/remotes/tests/fixtures/session-client/run.mjs +++ /dev/null @@ -1,269 +0,0 @@ -/** Plain-Node artifact integration: offline consumer, real Session history, HTTP, and WebSocket. */ - -import assert from 'node:assert/strict' -import { createServer } from 'node:http' -import { once } from 'node:events' -import { Context } from '@deepseek-ai/cordis' -import { z } from 'zod' -import WebSocket from 'ws' -import Registry from '@deepseek-ai/dsh-typert-registry' -import * as connectionHost from '@deepseek-ai/dsh-client-connection' -import Gateway from '@deepseek-ai/dsh-api-gateway' -import { AgentRegistry } from '@deepseek-ai/dsh-agent' -import { SessionStore } from '@deepseek-ai/dsh-session' -import { createUserMessage } from '@deepseek-ai/dsh-llm' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import SessionQueryEngine from '@deepseek-ai/dsh-session-query' -import SessionController from '@deepseek-ai/dsh-api-session-controller' -import { TYPERT } from '@deepseek-ai/dsh-api-session-controller/typert' -import { sessionOperations, sessionClient } from './client.js' -import { pageTool, callPageTool, mcpSchemas } from './mcp.js' - -// Search is outside this fixture; point observations and history windows use the real engine. -class PointQuery extends SessionQueryEngine { - searchSessions() { throw new Error('Search is not selected') } - searchEvents() { throw new Error('Search is not selected') } -} - -const offline = new Context() -const host = new Context() -const client = new Context() -const routes = [] -const upgrades = [] -const records = new Map() -const counters = { http: 0, sockets: 0, uploads: 0 } -const nativeFetch = globalThis.fetch -const nativeWebSocket = globalThis.WebSocket -let server -try { - for (const name of ['window', 'document', 'location']) assert.equal(name in globalThis, false) - globalThis.fetch = () => { throw new Error('Offline metadata attempted network access') } - globalThis.WebSocket = class { constructor() { throw new Error('Offline metadata opened a socket') } } - const [registryClient, { installConnection }, gatewayClient, { TYPERT_REMOTE }] = await Promise.all([ - import('@deepseek-ai/dsh-typert-registry/client'), - import('@deepseek-ai/dsh-client-connection/client'), - import('@deepseek-ai/dsh-api-gateway/client'), - import('@deepseek-ai/dsh-api-session-controller/remote'), - ]) - await offline.plugin(registryClient) - const offlineOwner = offline.plugin({ - inject: ['typert'], - apply(ctx) { ctx.effect(() => ctx.typert.remotes.register(TYPERT_REMOTE)) }, - }) - await offlineOwner - const operations = sessionOperations(offline.typert.remotes) - assert.deepEqual(operations.list().map(item => item.endpoint), ['session/page', 'session/follow']) - assert.throws(() => operations.method('workspace/list'), /Unselected/) - for (const { endpoint, method } of operations.list()) { - assert.equal(method, TYPERT_REMOTE.methods[endpoint]) - assert.equal(method, TYPERT_REMOTE.descriptors.find(item => `${item.namespace}/${item.method}` === endpoint)) - assert.equal('metadata' in method, false) - assert.ok(method.description) - assert.ok(method.parameters.find(parameter => parameter.wire === 'request').description) - assert.equal(typeof method.input({ strict: true }).create().parse, 'function') - assert.equal(typeof method.result.create().parse, 'function') - } - const normalSchema = TYPERT_REMOTE.methods['session/page'].input().jsonSchema - assert.equal(normalSchema.properties.request.additionalProperties, true) - const originalSchema = structuredClone(TYPERT_REMOTE.methods['session/page'].input({ strict: true }).jsonSchema) - assert.equal(originalSchema.properties.request.additionalProperties, false) - const tool = pageTool(operations) - assert.equal(tool.name, 'remote_session_page') - assert.equal(tool.description, 'Read Session or subagent history without starting it. Records are oldest first. Use the opening follow cursor as throughSeq and the first record\'s sequence as beforeSeq to read older history when hasMore is true. maxMessages limits complete messages; turnWindow requests minimum messages and turns within that limit. To target the calling Session, set current: true and omit its target from arguments.request. This requires caller identity from the selected server.') - assert.equal(JSON.stringify(tool.inputSchema).includes('"description"'), false) - // Exercise optional presentation without manufacturing replacement controller metadata. - const inputOnly = mcpSchemas(operations.inputSchema('session/page')) - assert.equal('outputSchema' in inputOnly, false) - assert.deepEqual(inputOnly.inputSchema, tool.inputSchema) - assert.deepEqual(TYPERT_REMOTE.methods['session/page'].input({ strict: true }).jsonSchema, originalSchema) - assert.equal(counters.http + counters.sockets, 0) - assert.throws(() => operations.prepare('session/page', { arguments: { request: { throughSeq: -1 } }, current: true }), /caller Session identity/) - assert.throws(() => operations.prepare('session/page', { arguments: {}, content: [] }), /does not accept local content/) - assert.throws(() => operations.prepare('session/page', { arguments: { request: { throughSeq: 'bad' } }, current: true }, 'session-fixture')) - globalThis.fetch = nativeFetch - globalThis.WebSocket = nativeWebSocket - - host.provide('webServer', { - register(route) { routes.push(route); return () => routes.splice(routes.indexOf(route), 1) }, - registerUpgrade(route) { upgrades.push(route); return () => upgrades.splice(upgrades.indexOf(route), 1) }, - tapIndex() { return () => {} }, port: 0, - }) - host.provide('credentials', { - readRecord(key) { return Promise.resolve(records.get(key)) }, - async modifyRecord(key, mutate) { - const current = records.get(key) - const next = await mutate(current) - if (next !== undefined) records.set(key, next) - return next ?? current - }, - }) - // Unselected upload operations are never called; only the constructor's registration is supplied. - host.provide('fileUploads', { - registerAgentResolver() { counters.uploads++; return () => { counters.uploads-- } }, - }) - await host.plugin({ inject: connectionHost.inject, apply: connectionHost.apply }) - await host.plugin(Registry) - await host.plugin(AgentRegistry) - await host.plugin(SessionStore) - await host.plugin(SessionProjectionRegistry) - await host.plugin(PointQuery) - await host.plugin(Gateway) - const session = host.sessions.create(undefined, { meta: { cwd: process.cwd() } }) - session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'offline history' }], source: { kind: 'user' } }), { surfaceOp: 'append' }) - const hostOwner = host.plugin({ - inject: ['typert', 'agents', 'sessions', 'sessionQuery', 'sessionProjections', 'fileUploads'], - apply(ctx) { - new SessionController(ctx, {}) - ctx.effect(() => ctx.typert.register(TYPERT)) - }, - }) - await hostOwner - assert.equal(counters.uploads, 1) - assert.equal(host.typert.local.get('session/page'), TYPERT.invocations.find(item => item.method === 'page')) - assert.equal(routes.length, 1) - assert.equal(upgrades.length, 1) - server = createServer((request, response) => { - if ((request.url ?? '/').startsWith('/?')) { - if (host.connection.authorizeIndex(request, response)) response.end('Session test') - return - } - void routes[0].handler(request, response).catch(error => { response.destroy(error) }) - }) - server.on('upgrade', (request, socket, head) => { - const route = upgrades.find(item => item.path === new URL(request.url, 'http://localhost').pathname) - if (route === undefined) socket.destroy() - else route.handler(request, socket, head) - }) - server.listen(0, '127.0.0.1') - await once(server, 'listening') - const origin = `http://127.0.0.1:${server.address().port}/` - const login = await fetch(host.connection.authenticatedUrl(origin), { redirect: 'manual' }) - assert.equal(login.status, 303) - const cookie = login.headers.get('set-cookie').split(';', 1)[0] - await login.body?.cancel() - - await client.plugin(registryClient) - await client.plugin({ - apply(ctx) { - installConnection(ctx, { transport: { - fetch(input, init) { - counters.http++ - const headers = new Headers(init.headers) - headers.set('cookie', cookie) - return fetch(new URL(input, origin), { ...init, headers }) - }, - streamBaseUrl: origin, - webSocket(url) { counters.sockets++; return new WebSocket(url, { headers: { cookie } }) }, - } }) - }, - }) - await client.plugin(gatewayClient) - const mounted = client.plugin({ - inject: ['remote'], - async apply(ctx) { await ctx.remote.$mount(TYPERT_REMOTE) }, - }) - await mounted - assert.equal(counters.http + counters.sockets, 0, 'passive install/mount must not subscribe') - assert.equal(client.connection.generation.getSnapshot(), undefined) - const calls = sessionClient(operations, client.remote, session.id) - const address = { kind: 'session', sessionId: session.id } - const args = { request: { address, throughSeq: 0 } } - const valid = { arguments: args } - const local = { arguments: { request: { throughSeq: 0 } }, current: true } - assert.deepEqual(operations.prepare('session/page', local, session.id), args) - assert.throws(() => operations.prepare('session/page', { arguments: args, current: true }, session.id)) - const direct = await TYPERT_REMOTE.methods['session/page'].invoke(client.remote, args) - assert.equal(direct.ok, true) - assert.equal(direct.value.records.length, 1) - assert.equal(direct.value.hasMore, false) - assert.deepEqual(await calls.call('session/page', local), direct.value) - assert.equal(counters.http, 2) - assert.equal(counters.sockets, 0) - const extraArguments = { request: { ...args.request, maxMesssages: 1 } } - const ordinary = await TYPERT_REMOTE.methods['session/page'].invoke(client.remote, extraArguments) - assert.equal(ordinary.ok, true, 'consumer strictness must not tighten ordinary Host dispatch') - assert.deepEqual(ordinary.value, direct.value) - const malformed = await TYPERT_REMOTE.methods['session/page'].invoke(client.remote, { request: { address, throughSeq: 'bad' } }) - assert.equal(malformed.ok, false) - const before = counters.http - const misspelled = await callPageTool(calls, { arguments: extraArguments }) - assert.equal(misspelled.isError, true) - assert.equal(counters.http, before, 'strict CLI/MCP intake rejects unknown fields before HTTP') - const invalid = await callPageTool(calls, { arguments: { request: { throughSeq: 'bad' } }, current: true }) - assert.equal(invalid.isError, true) - assert.equal(invalid.structuredContent.ok, false) - assert.equal(counters.http, before, 'invalid local inputs never reach HTTP') - assert.deepEqual(JSON.parse(invalid.content[0].text), invalid.structuredContent) - const success = await callPageTool(calls, valid) - assert.deepEqual(success.structuredContent, { ok: true, value: direct.value }) - assert.deepEqual(JSON.parse(success.content[0].text), success.structuredContent) - const failure = await callPageTool(calls, { arguments: { request: { address, throughSeq: 999 } } }) - assert.equal(failure.isError, true) - assert.equal(failure.structuredContent.ok, false) - assert.deepEqual(JSON.parse(failure.content[0].text), failure.structuredContent) - if (tool.outputSchema !== undefined) { - const envelope = z.fromJSONSchema(tool.outputSchema) - envelope.parse(success.structuredContent) - envelope.parse(failure.structuredContent) - assert.throws(() => envelope.parse({ ok: true, value: { records: 'bad', hasMore: false } })) - } - assert.throws(() => TYPERT_REMOTE.methods['session/page'].result.create().parse({ records: 'bad', hasMore: false })) - // A corrupt response exercises the consumer's output parser without inventing descriptors. - const corrupt = sessionClient(operations, { session: { page: async () => ({ ok: true, value: { records: 'bad' } }) } }) - await assert.rejects(corrupt.call('session/page', valid)) - await assert.rejects(calls.call('session/follow', { arguments: { request: { address } } }), /requires stream/) - assert.throws(() => calls.stream('session/page', valid), /requires call/) - - const control = new AbortController() - const handle = calls.stream('session/follow', { arguments: { request: {} }, current: true }, control.signal) - for (const key of ['send', 'end', 'dispose']) assert.equal(typeof handle[key], 'function') - const iterator = handle[Symbol.asyncIterator]() - const opening = await iterator.next() - assert.equal(opening.done, false) - assert.equal(opening.value.type, 'snapshot') - assert.equal(opening.value.cursor, 0) - assert.equal(opening.value.records.length, 1) - handle.end() - const next = iterator.next() - session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'live history' }], source: { kind: 'user' } }), { surfaceOp: 'append' }) - const update = await next - assert.equal(update.value.type, 'event') - assert.equal(update.value.event.seq, 1) - const pending = iterator.next() - control.abort() - const [aborted] = await Promise.allSettled([pending]) - if (aborted.status === 'fulfilled') assert.equal(aborted.value.done, true) - handle.dispose() - await iterator.return?.() - assert.equal(counters.sockets, 1) - assert.equal(host.agents.list().length, 0, 'following a live Session needs no activation') - - // Unmount while a second stream is pending, then await all consumer/socket teardown. - const owned = calls.stream('session/follow', { arguments: { request: { address } } }) - const ownedIterator = owned[Symbol.asyncIterator]() - await ownedIterator.next() - const waiting = Promise.allSettled([ownedIterator.next()]) - await mounted.dispose() - assert.equal(client.typert.remotes.get('session/page'), undefined) - assert.deepEqual(client.typert.remotes.list(), []) - await client.fiber.dispose() - await waiting - await hostOwner.dispose() - assert.equal(host.typert.local.get('session/page'), undefined) - assert.deepEqual(host.typert.local.list(), []) - assert.equal(counters.uploads, 0) - await offlineOwner.dispose() - assert.deepEqual(offline.typert.remotes.list(), []) - assert.throws(() => operations.list(), /Missing generated method/) - console.log(JSON.stringify({ endpoints: ['session/page', 'session/follow'], http: counters.http, sockets: counters.sockets, realController: true })) -} finally { - globalThis.fetch = nativeFetch - globalThis.WebSocket = nativeWebSocket - await client.fiber.dispose() - await host.fiber.dispose() - if (server !== undefined) { - server.closeAllConnections() - await new Promise((resolve, reject) => server.close(error => error && error.code !== 'ERR_SERVER_NOT_RUNNING' ? reject(error) : resolve())) - } - await offline.fiber.dispose() -} diff --git a/packages/api/remotes/tests/fixtures/session-client/tsconfig.json b/packages/api/remotes/tests/fixtures/session-client/tsconfig.json deleted file mode 100644 index 425ecb9956..0000000000 --- a/packages/api/remotes/tests/fixtures/session-client/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2023", - "lib": ["ES2023", "ESNext.Disposable", "DOM"], - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "skipLibCheck": false, - "noEmitOnError": true, - "rewriteRelativeImportExtensions": true, - "rootDir": ".", - "types": [] - }, - "files": ["client.ts", "mcp.ts", "typing.ts"] -} diff --git a/packages/api/remotes/tests/fixtures/session-client/typing.ts b/packages/api/remotes/tests/fixtures/session-client/typing.ts deleted file mode 100644 index 867b8a45c6..0000000000 --- a/packages/api/remotes/tests/fixtures/session-client/typing.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** Compile-only consumer checks against ordinary generated declarations, without source aliases. */ - -import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' -import type { RemoteResult, RemoteStreamHandle, TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' -import type { PageArguments, PageOutput } from './client.ts' - -declare const remote: TypertClientRemote -declare const input: PageArguments -declare const output: PageOutput -const unary: Promise> = TYPERT_REMOTE.methods['session/page'].invoke(remote, input) -const more: boolean = output.hasMore -const records: readonly object[] = output.records -const strictInput: PageArguments = TYPERT_REMOTE.methods['session/page'].input({ strict: true }).create().parse({}) -const normalInput: PageArguments = TYPERT_REMOTE.methods['session/page'].input().create().parse({}) -// @ts-expect-error Schema strictness is a consumer boolean, not an arbitrary policy string. -TYPERT_REMOTE.methods['session/page'].input({ strict: 'yes' }) -void [strictInput, normalInput] -// @ts-expect-error Page arguments retain the generated request fields. -void TYPERT_REMOTE.methods['session/page'].invoke(remote, { request: { throughSeq: 'bad' } }) -// @ts-expect-error Page output has no cursor; the follow snapshot supplies it. -void output.cursor -// @ts-expect-error Method info is the descriptor, not a metadata wrapper. -void TYPERT_REMOTE.methods['session/page'].metadata - -type Follow = typeof TYPERT_REMOTE.methods['session/follow'] -declare const followInput: Parameters[1] -type FollowOutput = ReturnType['parse']> -const stream: RemoteStreamHandle = TYPERT_REMOTE.methods['session/follow'].invoke(remote, followInput) -stream.end() -stream.dispose() -// @ts-expect-error Follow has no uplink messages. -stream.send({}) -void [unary, more, records] diff --git a/packages/api/remotes/tests/session-client.e2e.ts b/packages/api/remotes/tests/session-client.e2e.ts deleted file mode 100644 index ee49534c36..0000000000 --- a/packages/api/remotes/tests/session-client.e2e.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** Built-artifact Session Client regression; generated schemas, declarations, and runtime share one plane. */ - -import { execFile } from 'node:child_process' -import { existsSync } from 'node:fs' -import { copyFile, mkdtemp, rm } from 'node:fs/promises' -import { dirname, join, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { promisify } from 'node:util' -import ts from 'typescript' -import { expect, it } from 'vitest' - -const directory = dirname(fileURLToPath(import.meta.url)) -const root = resolve(directory, '../../../..') -const fixture = join(directory, 'fixtures/session-client') -const required = [ - 'packages/api/session-controller/lib/typert.host.js', - 'packages/api/session-controller/lib/typert.remote-client.js', - 'packages/api/session-controller/lib/typert.remote-client.d.ts', - 'packages/api/session-controller/lib/index.js', - 'packages/api/gateway/lib/types/client/index.js', - 'packages/client/connection/lib/types/client/index.js', - 'packages/typert/registry/lib/types/client/index.js', - 'packages/typert/protocol/lib/types/remote-info.js', -] - -it.skipIf(required.some(path => !existsSync(join(root, path))))('runs the extracted Session Client against real controller artifacts in plain Node', async () => { - // Stay under the package for ordinary Node package resolution, with one private output per test. - const output = await mkdtemp(join(fixture, '.compiled-')) - try { - const config = ts.readConfigFile(join(fixture, 'tsconfig.json'), path => ts.sys.readFile(path)) - expect(config.error).toBeUndefined() - const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, fixture, { outDir: output }) - const program = ts.createProgram(parsed.fileNames, parsed.options) - const diagnostics = [...parsed.errors, ...ts.getPreEmitDiagnostics(program)] - const formatted = ts.formatDiagnosticsWithColorAndContext(diagnostics, { - getCurrentDirectory: () => root, - getCanonicalFileName: path => path, - getNewLine: () => '\n', - }) - expect(formatted).toBe('') - expect(program.emit().emitSkipped).toBe(false) - await copyFile(join(fixture, 'run.mjs'), join(output, 'run.mjs')) - const result = await promisify(execFile)(process.execPath, [join(output, 'run.mjs')], { - cwd: root, - env: { ...process.env, NODE_OPTIONS: '' }, - timeout: 25_000, - maxBuffer: 2 * 1024 * 1024, - }) - const summary: unknown = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') - expect(summary).toMatchObject({ endpoints: ['session/page', 'session/follow'], realController: true, sockets: 1 }) - } finally { - // mkdtemp returns this test's absolute directory; never remove a caller-provided path. - if (dirname(output) !== fixture || !output.startsWith(join(fixture, '.compiled-'))) throw new Error('Unexpected fixture output path') - await rm(output, { recursive: true, force: true }) - } -}, 60_000) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index d636e32692..a1ca0b5483 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -19,12 +19,6 @@ personaPrefix: >- You are a coding agent powered by the {{model}} model. -# Served pages answer Agent-scoped approval and question requests, so their -# Gateway Client receives forwarded Host events. Other compositions stay passive. -- id: typert-gateway - config: - forwardedEvents: true - # Full-text session search is opt-in (the base row's `openAt: never`). This # restatement keeps the Web values on one ephemeral in-memory index; a # deployment enabling content search overrides `openAt` to `first-search` in a diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index f975701c7b..9efedba955 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -15,14 +15,14 @@ en: 18792bf2fb4f8030 zh: 84c63acc7e0099ae /deepseek-ai-dsh-client-connection/use-this-package: - en: d36fdf3a6cc76ec3 - zh: 96609d956c191a4b + en: 13b54969ff1d00a4 + zh: 4d2ffff0445472ed /deepseek-ai-dsh-client-connection/browser-authentication-and-request-trust: en: 44956be3cc11908a zh: d518f765d58f3558 /deepseek-ai-dsh-client-connection/connection-generation: - en: e5d588463967e80b - zh: de2e2c4b7d5815f5 + en: 116f94e44c0d87e7 + zh: 59ef48984154d858 /deepseek-ai-dsh-client-connection/model-experience: en: 88bbc33fe9ae9df8 zh: bfbab6d51b4abd40 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 12ca49a12d..d6cb58d42c 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -25,11 +25,11 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, and con ## Use this package -`installConnection(ctx, { transport })` accepts instance-local `fetch`, `streamBaseUrl`, and `webSocket` hooks. Fetch receives document-relative routes; the composition resolves them against its selected Host and supplies authentication. The WebSocket factory receives an absolute URL and supplies the same authentication. Connection owns each created socket until its close event and joins physical closure on disposal. Without hooks it uses browser Fetch, WebSocket, and the document base. A static desktop page can supply these hooks through `__DSH_TRANSPORT__`; setting an origin alone grants no access. Node resolves `/client` to ordinary ESM, while browsers retain the Client bundle. +A static desktop page can provide `__DSH_TRANSPORT__.streamBaseUrl` for the HTTP origin of its owned Host. The Gateway uses that origin for its WebSocket while HTTP transport remains independently selected. The desktop carrier owns authentication; setting the origin alone grants no access. Unary RPC requests use JSON. A Host handler may return byte attachments that it has already separated from the JSON-compatible result value, with each attachment naming its result-relative path. Connection writes these values as multipart parts. The JSON `metadata` part contains the RPC response envelope with `null` placeholders and an attachment table recording each path, codec, and part identifier. Paths use string keys and numeric array indices; no business field name is reserved. The Client validates the envelope, `rpcId`, attachment table, and parts, then restores each byte value as an `ArrayBuffer`-backed view. Results without attachments, including base64 strings, and failures remain JSON. Logical RPC carriers return decoded native values directly. Connection does not discover binary fields or depend on Typert; the handler that owns a result protocol performs any type-directed or runtime projection before returning. Binary parameters, events, and streamed binary results are unsupported. -The browser uses HTTP POST for Remote unary calls. API Gateway owns `/api/remote.mux` framing and logical streams over Connection-owned WebSockets; shell-owned compositions provide equivalent Remote streams, including a stream's uplink, through `connection.rpc.open` without opening a WebSocket. The browser plugin reads the page transport, recovery settings, and location, then delegates to `installConnection(ctx, options)`. A composition that owns its carrier may call the same installer directly; the whole-client test tier does so. Each invocation creates one Context-owned service, so several Client trees can use different carriers in one realm. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). +The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams, including a stream's uplink, through `connection.rpc.open` without opening a WebSocket. The browser plugin reads the page transport, recovery settings, and location, then delegates to `installConnection(ctx, options)`. A composition that owns its carrier may call the same installer directly; the whole-client test tier does so. Each invocation creates one Context-owned service, so several Client trees can use different carriers in one realm. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). ----- @@ -51,7 +51,7 @@ Authenticated shared HTTP requests pass through the `connection/request` waterfa ## Connection generation -The Gateway Client's `forwardedEvents` Config, set for Web pages by the Host Gateway row, registers the internal `$events` logical stream as the sole generation source. Passive compositions omit it; mounting Connection or invoking a generated Remote method does not start the event pump. The Host attaches all incremental listeners in the API Remotes source factory, then sends one `{ type: 'ready', clientId, host: { home } }` item before events. `ConnectionController` publishes that generation and calls `onConnected` only after the ready item arrives, so baseline acquisition cannot race ahead of incremental observation. +API Gateway Client registers the internal `$events` logical stream as the sole generation source, independently of whether any `$on` listener exists. The Host attaches all incremental listeners in the API Remotes source factory, then sends one `{ type: 'ready', clientId, host: { home } }` item before events. `ConnectionController` publishes that generation and calls `onConnected` only after the ready item arrives, so baseline acquisition cannot race ahead of incremental observation. An ended `$events` stream, a Remote stream error, a non-ready opening item, or a malformed event item invalidates the current generation. A pending handshake logs a slow-Host warning after 3 seconds and logs the readiness timeout and aborts after 15 seconds by default, including time spent waiting for the physical socket. The source must stop delivery, release resources, and settle after cancellation before a replacement starts; late readiness from a cancelled source cannot publish a generation. While the browser reports network availability, the controller publishes `connecting` and retries with 50%–100% jitter under caps of 500ms, 1s, 2s, 4s, 8s, and 10s, continuing at the final cap until recovery. Every retry asks Gateway to replace the physical WebSocket once and reopens `$events`. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index b76e187440..6ec954f84d 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -25,11 +25,11 @@ kind: "package-reference" ## 使用本包 -`installConnection(ctx, { transport })` 接受实例独立的 `fetch`、`streamBaseUrl` 与 `webSocket` hook。Fetch 收到文档相对路由,由组合按选定的 Host 解析并提供认证;WebSocket factory 收到绝对 URL,并提供相同认证。Connection 持有创建的每条 socket,直到其 close 事件,并在销毁时等待物理连接关闭。未提供 hook 时使用浏览器 Fetch、WebSocket 与文档 base。静态桌面页面可通过 `__DSH_TRANSPORT__` 提供这些 hook;仅设置 origin 不会授予访问权限。Node 将 `/client` 解析为普通 ESM,浏览器仍使用 Client bundle。 +静态桌面页面可以通过 `__DSH_TRANSPORT__.streamBaseUrl` 提供其所拥有 Host 的 HTTP origin。Gateway 将该 origin 用于 WebSocket,HTTP 传输仍独立选择。桌面载体负责认证;仅设置 origin 不会授予访问权限。 一元 RPC 请求使用 JSON。Host handler 可以返回已经从 JSON 兼容结果值中分离的字节附件,每个附件标明其相对于结果的路径。Connection 将这些值写为 multipart 部分。JSON `metadata` 部分包含带 `null` 占位值的 RPC 响应信封,以及记录各路径、codec 和部分标识符的附件表。路径使用字符串键与数字数组下标,不保留任何业务字段名。Client 校验信封、`rpcId`、附件表和各部分,再将每个字节值恢复为以 `ArrayBuffer` 为底层缓冲区的视图。没有附件的结果(包括 base64 字符串)与失败仍使用 JSON。逻辑 RPC 载体直接返回解码后的原生值。Connection 不识别二进制字段,也不依赖 Typert;拥有结果协议的 handler 在返回前执行按类型或按运行时值的投影。不支持二进制参数、事件和二进制流式结果。 -浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 在 Connection 持有的 WebSocket 上负责 `/api/remote.mux` 帧与逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流(包括流的上行),不打开 WebSocket。浏览器插件读取页面 transport、恢复设置与 location,再委托 `installConnection(ctx, options)`。持有自身载体的组合可以直接调用同一个安装函数;整机客户端测试档就是这一消费者。每次调用都会创建一个归所属 Context 的服务,因此同一 realm 中的多棵 Client 树可以使用不同载体。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 +浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流(包括流的上行),不打开 WebSocket。浏览器插件读取页面 transport、恢复设置与 location,再委托 `installConnection(ctx, options)`。持有自身载体的组合可以直接调用同一个安装函数;整机客户端测试档就是这一消费者。每次调用都会创建一个归所属 Context 的服务,因此同一 realm 中的多棵 Client 树可以使用不同载体。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 ----- @@ -51,7 +51,7 @@ HTTPS cookie 名称与签名 audience 包含 `https:` scheme,因此即使 host ## Connection generation -Gateway Client 的 `forwardedEvents` Config(Web 页面由 Host Gateway 行设置)将内部 `$events` 逻辑流注册为唯一 generation source。被动组合不设置它;挂载 Connection 或调用生成的 Remote 方法都不会启动事件 pump。Host 在 API Remotes source factory 同步挂好所有增量 listener 后,先发送唯一 `{ type: 'ready', clientId, host: { home } }` 项,再发送事件。`ConnectionController` 仅在收到该 ready 项后发布 generation 并调用 `onConnected`,因此 baseline 不会跑在增量 listener 前面。 +API Gateway Client 把内部 `$events` 逻辑流注册为唯一 generation source,与有无 `$on` 订阅无关。Host 在 API Remotes source factory 同步挂好所有增量 listener 后,先发送唯一 `{ type: 'ready', clientId, host: { home } }` 项,再发送事件。`ConnectionController` 仅在收到该 ready 项后发布 generation 并调用 `onConnected`,因此 baseline 不会跑在增量 listener 前面。 `$events` 结束、Remote 流报错、收到非 ready 首项或畸形事件项,都会使当前 generation 失效。默认情况下,挂起的握手在 3 秒后记录 Host 响应缓慢告警,在 15 秒后记录就绪超时并中止,包含等待物理 socket 的时间。取消后,source 必须停止投递、释放资源并结束,替换 source 才能启动;已取消 source 迟到的 ready 不能发布 generation。浏览器报告网络可用时,Controller 发布 `connecting`,并在 500ms、1s、2s、4s、8s 与 10s 上限内采用 50%–100% 抖动重试,达到终档后继续尝试直到恢复。每次重试都要求 Gateway 替换一次物理 WebSocket,再重开 `$events`。 diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 0691d4daf1..21e395fb6f 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -20,7 +20,6 @@ }, "./client": { "types": "./lib/types/client/index.d.ts", - "node": "./lib/types/client/index.js", "default": "./lib/client.js" }, "./src/*": "./src/*", @@ -44,7 +43,6 @@ "locale/*.json", "lib/index.js", "lib/client.js", - "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "peerDependencies": { diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 035024640b..e9d80a64f4 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -12,7 +12,6 @@ import { createWebConnectionRpc, type RpcFetch, type RpcStreamOpen } from './rpc import { isLoopbackHostname } from '../loopback-hostname.ts' import type { ClientConnectionRpc } from '../rpc.ts' import { resolveConnectionConfig } from '../recovery-config.ts' -import { ConnectionSockets, type ConnectionWebSocket, type ConnectionWebSocketFactory } from './socket.ts' declare module '@deepseek-ai/cordis' { interface Events { @@ -51,7 +50,6 @@ export type { ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult, } from '../rpc.ts' export type { RpcFetch } from './rpc.ts' -export type { ConnectionWebSocket, ConnectionWebSocketEventMap, ConnectionWebSocketFactory } from './socket.ts' /** Observable identity and Host facts for the active connection generation. */ export interface ConnectionGenerationState { @@ -106,8 +104,6 @@ export interface ClientTransportHooks { ownsHost?: boolean /** HTTP origin of a shell-owned Host when its WebSocket uses a different page origin. */ streamBaseUrl?: string - /** Physical socket factory, including shell-owned authentication; Connection owns socket disposal. */ - webSocket?: ConnectionWebSocketFactory } /** Page global carrying {@link ClientTransportHooks}; absent in the served web app. */ @@ -148,12 +144,6 @@ export interface ConnectionHandle { readonly state: ConnectionStateSource /** Generic logical RPC channels over the same Connection transport. */ readonly rpc: ClientConnectionRpc - /** - * Open a physical WebSocket on this Connection's selected Host or document mount. - * @param path - absolute logical channel path. - * @returns a socket owned until its close event; the consumer owns framing and retries. - */ - openWebSocket(path: string): ConnectionWebSocket /** Reset retry progression and replace the current attempt immediately. */ reconnect(): void /** @@ -217,7 +207,6 @@ export function installConnection(ctx: Context, options: ConnectionInstallOption const transport = options.transport const recovery = options.recovery ?? {} const rpc = transport?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream) - const sockets = new ConnectionSockets(transport?.webSocket, transport?.streamBaseUrl) let generationSource: ConnectionGenerationSource | undefined let owner: ConnectionOwner | undefined let generationId = 0 @@ -272,7 +261,6 @@ export function installConnection(ctx: Context, options: ConnectionInstallOption }, }, rpc, - openWebSocket: path => sockets.open(path), reconnect() { owner?.controller.reconnect() }, @@ -319,10 +307,6 @@ export function installConnection(ctx: Context, options: ConnectionInstallOption } }, } - ctx.effect(() => async () => { - if (owner !== undefined) releaseOwner(owner) - await sockets.dispose() - }, 'client-connection: physical carriers') ctx.provide('connection', handle) } diff --git a/packages/client/connection/src/client/socket.ts b/packages/client/connection/src/client/socket.ts deleted file mode 100644 index c76d6e8e23..0000000000 --- a/packages/client/connection/src/client/socket.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** Physical WebSocket selection and ownership for one Client Connection. */ - -/** Events consumed by Connection and Gateway on either browser or Node sockets. */ -export interface ConnectionWebSocketEventMap { - readonly open: object - readonly error: object - readonly close: object - readonly message: { readonly data: unknown } -} - -/** WebSocket operations shared by the browser API and Node carriers. */ -export interface ConnectionWebSocket { - /** Standard WebSocket connection state, from CONNECTING (0) through CLOSED (3). */ - readonly readyState: number - /** @param data - complete text frame to send. */ - send(data: string): void - /** - * @param code - protocol close code. - * @param reason - peer-facing close explanation. - */ - close(code?: number, reason?: string): void - /** - * @param event - physical socket event. - * @param listener - callback for that event. - * @param options - whether the listener is removed after its first delivery. - */ - addEventListener( - event: Event, - listener: (event: ConnectionWebSocketEventMap[Event]) => void, - options?: { readonly once?: boolean }, - ): void - /** - * @param event - physical socket event. - * @param listener - previously registered callback. - */ - removeEventListener( - event: Event, - listener: (event: ConnectionWebSocketEventMap[Event]) => void, - ): void -} - -/** - * Open an authenticated physical socket; framing remains with its consumer. - * @param url - absolute WebSocket URL below the selected Host or document mount. - * @returns the newly created socket, including its pending connection lifetime. - */ -export type ConnectionWebSocketFactory = (url: string) => ConnectionWebSocket - -const WEBSOCKET_CLOSED = 3 - -/** Own sockets until their close event, including failed connection attempts. */ -export class ConnectionSockets { - private readonly sockets = new Map>() - private disposed = false - - /** - * @param factory - physical carrier; omission uses the browser WebSocket API. - * @param baseUrl - shell-owned Host base; omission uses the current document base. - */ - constructor( - private readonly factory: ConnectionWebSocketFactory = url => new WebSocket(url), - private readonly baseUrl?: string, - ) {} - - /** - * Open a channel beneath the selected Host or document mount. - * @param path - absolute logical channel path. - * @returns a physical socket whose lifetime belongs to this Connection. - */ - open(path: string): ConnectionWebSocket { - if (this.disposed) throw new Error('connection: WebSocket carrier is disposed') - const base = this.baseUrl ?? (typeof document === 'undefined' ? undefined : document.baseURI) - if (base === undefined) throw new Error('connection: WebSocket carrier requires streamBaseUrl outside a document') - const url = new URL(path.slice(1), base) - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - const socket = this.factory(url.href) - if (socket.readyState === WEBSOCKET_CLOSED) return socket - // Node sockets can emit an error after a cancelled opener removes its listeners. - const error = (): void => {} - socket.addEventListener('error', error) - const closed = new Promise((resolve) => { - socket.addEventListener('close', () => { - socket.removeEventListener('error', error) - this.sockets.delete(socket) - resolve() - }, { once: true }) - }) - this.sockets.set(socket, closed) - return socket - } - - /** - * Close every owned socket. - * @returns after every physical close event has arrived. - */ - async dispose(): Promise { - this.disposed = true - const sockets = [...this.sockets] - for (const [socket] of sockets) socket.close(1000, 'Connection disposed') - await Promise.all(sockets.map(([, closed]) => closed)) - } -} diff --git a/packages/client/connection/tests/socket.client.spec.ts b/packages/client/connection/tests/socket.client.spec.ts deleted file mode 100644 index f61c356bc3..0000000000 --- a/packages/client/connection/tests/socket.client.spec.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** Connection chooses WebSocket URLs and retains physical close ownership. */ - -import { EventEmitter } from 'node:events' -import type { ConnectionWebSocket, ConnectionWebSocketEventMap } from '../src/client/socket.ts' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { ConnectionSockets } from '../src/client/socket.ts' - -afterEach(() => { vi.unstubAllGlobals() }) - -class Socket extends EventTarget { - override addEventListener( - type: E, listener: (event: ConnectionWebSocketEventMap[E]) => void, options?: { readonly once?: boolean }, - ): void - override addEventListener( - type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions, - ): void - override addEventListener(type: string, listener: unknown, options?: boolean | AddEventListenerOptions): void { - // This fixture dispatches MessageEvent for messages and Event for other socket events. - super.addEventListener(type, listener as EventListenerOrEventListenerObject | null, options) - } - - override removeEventListener( - type: E, listener: (event: ConnectionWebSocketEventMap[E]) => void, - ): void - override removeEventListener( - type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions, - ): void - override removeEventListener(type: string, listener: unknown, options?: boolean | EventListenerOptions): void { - super.removeEventListener(type, listener as EventListenerOrEventListenerObject | null, options) - } - - readyState = 0 - readonly closedWith: { readonly code?: number; readonly reason?: string }[] = [] - - constructor(readonly url: string) { super() } - - send(_data: string): void {} - - close(code?: number, reason?: string): void { - this.closedWith.push({ - ...code === undefined ? {} : { code }, - ...reason === undefined ? {} : { reason }, - }) - this.readyState = 2 - } - - finishClose(): void { - this.readyState = 3 - this.dispatchEvent(new Event('close')) - } -} - -describe('Connection WebSocket carrier', () => { - it('does not retain a socket that is already closed when its factory returns', async () => { - const socket = new Socket('ws://fixture/api/remote.mux') - socket.finishClose() - const sockets = new ConnectionSockets(() => socket, 'http://fixture/') - expect(sockets.open('/api/remote.mux')).toBe(socket) - await sockets.dispose() - expect(socket.closedWith).toEqual([]) - }) - - it('contains Node socket errors until physical close, then removes its handler', async () => { - const events = new EventEmitter() - const socket: ConnectionWebSocket = { - readyState: 0, - send() {}, - close() {}, - addEventListener(event, listener, options) { - if (options?.once === true) events.once(event, listener) - else events.on(event, listener) - }, - removeEventListener(event, listener) { events.off(event, listener) }, - } - const sockets = new ConnectionSockets(() => socket, 'http://fixture/') - sockets.open('/api/remote.mux') - expect(() => events.emit('error', new Error('opening failed'))).not.toThrow() - const closed = sockets.dispose() - expect(() => events.emit('error', new Error('cancelled while opening'))).not.toThrow() - events.emit('close', {}) - await closed - expect(events.listenerCount('error')).toBe(0) - }) - - it.each([ - ['https://harness.example/mounted/app/', 'wss://harness.example/mounted/app/api/remote.mux'], - ['http://127.0.0.1:43210', 'ws://127.0.0.1:43210/api/remote.mux'], - ])('resolves channel paths below the selected base %s', async (base, expected) => { - vi.stubGlobal('document', { baseURI: 'dsh-app://app/' }) - const created: Socket[] = [] - const sockets = new ConnectionSockets((url) => { - const socket = new Socket(url) - created.push(socket) - return socket - }, base) - const socket = sockets.open('/api/remote.mux') - expect(created[0]?.url).toBe(expected) - expect(socket).toBe(created[0]) - created[0]!.finishClose() - await sockets.dispose() - }) - - it('uses the browser document base and native socket when no carrier is supplied', async () => { - vi.stubGlobal('document', { baseURI: 'https://harness.example/application/' }) - vi.stubGlobal('WebSocket', Socket) - const sockets = new ConnectionSockets() - const socket = sockets.open('/api/remote.mux') - expect(socket).toBeInstanceOf(Socket) - expect((socket as Socket).url).toBe('wss://harness.example/application/api/remote.mux') - ;(socket as Socket).finishClose() - await sockets.dispose() - }) - - it('waits for a connecting socket to close and rejects later opens', async () => { - const socket = new Socket('ws://fixture/api/remote.mux') - const sockets = new ConnectionSockets(() => socket, 'http://fixture/') - sockets.open('/api/remote.mux') - let disposed = false - const closing = sockets.dispose().then(() => { disposed = true }) - await Promise.resolve() - expect(disposed).toBe(false) - expect(socket.closedWith).toEqual([{ code: 1000, reason: 'Connection disposed' }]) - expect(() => sockets.open('/api/remote.mux')).toThrow('carrier is disposed') - socket.finishClose() - await closing - expect(disposed).toBe(true) - }) - - it('requires an explicit stream base when no document exists', () => { - vi.stubGlobal('document', undefined) - const sockets = new ConnectionSockets(url => new Socket(url)) - expect(() => sockets.open('/api/remote.mux')).toThrow('requires streamBaseUrl') - }) -}) diff --git a/packages/client/connection/tsconfig.client.json b/packages/client/connection/tsconfig.client.json index 9b1d3ba3dd..a9c558d29b 100644 --- a/packages/client/connection/tsconfig.client.json +++ b/packages/client/connection/tsconfig.client.json @@ -12,7 +12,6 @@ "src/client/index.ts", "src/client/random-uuid.ts", "src/client/rpc.ts", - "src/client/socket.ts", "src/loopback-hostname.ts", "src/recovery-config.ts", "src/rpc.ts" diff --git a/packages/client/product-analytics/src/client/index.ts b/packages/client/product-analytics/src/client/index.ts index ffbaee364e..dfa990bfe6 100644 --- a/packages/client/product-analytics/src/client/index.ts +++ b/packages/client/product-analytics/src/client/index.ts @@ -26,6 +26,7 @@ class DesktopAnalytics extends Service { if (!('dshDesktop' in globalThis)) return const stream = ctx.remote.$stream({ name: 'product analytics policy', open: signal => ctx.remote.productAnalytics.watchPolicy(signal), + ended: () => new Error('product analytics policy stream ended'), carrierFailed: () => { this.collecting = false }, }) ctx.effect(() => () => { this.collecting = false; return stream.dispose() }) diff --git a/packages/client/product-analytics/tests/collection.client.spec.ts b/packages/client/product-analytics/tests/collection.client.spec.ts index ab39faf24f..bdf5e7e5c5 100644 --- a/packages/client/product-analytics/tests/collection.client.spec.ts +++ b/packages/client/product-analytics/tests/collection.client.spec.ts @@ -94,8 +94,7 @@ it('fails closed on stream loss and accepts a fresh policy after reconnection', vi.stubGlobal('dshDesktop', {}) const b = await setup() b.options().open(new AbortController().signal) - // A Host reload ends the policy stream after its baseline; the supervisor default reopens it. - expect(b.options().ended).toBeUndefined() + expect(b.options().ended(true).message).toContain('policy stream ended') b.options().carrierFailed?.(new Error('offline')) expect(b.ctx.productAnalytics.enabled).toBe(false) await b.policy(true) diff --git a/packages/experimental/client-ui-voice-input/tests/readiness.client.spec.ts b/packages/experimental/client-ui-voice-input/tests/readiness.client.spec.ts index 0fe98d2657..8e7787bcc4 100644 --- a/packages/experimental/client-ui-voice-input/tests/readiness.client.spec.ts +++ b/packages/experimental/client-ui-voice-input/tests/readiness.client.spec.ts @@ -40,7 +40,7 @@ it('accepts a Host snapshot and keeps preparation ownership outside observation const signal = new AbortController().signal b.options.open(signal) expect(b.follow).toHaveBeenCalledWith(signal) - expect(b.options.ended?.(true)).toBeInstanceOf(RemoteStreamCarrierError) + expect(b.options.ended(true)).toBeInstanceOf(RemoteStreamCarrierError) b.options.carrierFailed?.(new RemoteStreamCarrierError('connection lost')) expect(b.mirror.state.getSnapshot()).toMatchObject({ connected: false, error: 'connection lost' }) await b.mirror.dispose() diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 60fb06141f..469db60766 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -545,7 +545,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ConnectionHandle', - declaration: 'export interface ConnectionHandle {\n readonly isLoopback: boolean;\n readonly generation: ConnectionGenerationState;\n readonly state: ConnectionStateSource;\n readonly rpc: ClientConnectionRpc;\n openWebSocket(path: string): ConnectionWebSocket;\n reconnect(): void;\n registerGenerationSource(source: ConnectionGenerationSource): () => void;\n start(sinks: ConnectionSinks, config?: ConnectionRecoveryConfig): ConnectionLoop;\n}', + declaration: 'export interface ConnectionHandle {\n readonly isLoopback: boolean;\n readonly generation: ConnectionGenerationState;\n readonly state: ConnectionStateSource;\n readonly rpc: ClientConnectionRpc;\n reconnect(): void;\n registerGenerationSource(source: ConnectionGenerationSource): () => void;\n start(sinks: ConnectionSinks, config?: ConnectionRecoveryConfig): ConnectionLoop;\n}', }, { name: 'ConnectionHostInfo', @@ -579,14 +579,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ConnectionStateSource', declaration: 'export interface ConnectionStateSource {\n getSnapshot(): ConnectionState | undefined;\n subscribe(listener: () => void): () => void;\n}', }, - { - name: 'ConnectionWebSocket', - declaration: 'export interface ConnectionWebSocket {\n readonly readyState: number;\n send(data: string): void;\n close(code?: number, reason?: string): void;\n addEventListener(event: Event, listener: (event: ConnectionWebSocketEventMap[Event]) => void, options?: {\n readonly once?: boolean;\n }): void;\n removeEventListener(event: Event, listener: (event: ConnectionWebSocketEventMap[Event]) => void): void;\n}', - }, - { - name: 'ConnectionWebSocketEventMap', - declaration: 'export interface ConnectionWebSocketEventMap {\n readonly open: object;\n readonly error: object;\n readonly close: object;\n readonly message: {\n readonly data: unknown;\n };\n}', - }, { name: 'DraftInitializationOptions', declaration: 'export interface DraftInitializationOptions {\n readonly prompt?: string;\n readonly clearPreviousDraft?: boolean;\n}', @@ -845,7 +837,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RemoteStreamOptions', - declaration: 'export interface RemoteStreamOptions {\n readonly name: string;\n readonly open: (signal: AbortSignal) => AsyncIterable;\n readonly ended?: (accepted: boolean) => Error;\n readonly carrierFailed?: (error: RemoteStreamCarrierError) => void;\n}', + declaration: 'export interface RemoteStreamOptions {\n readonly name: string;\n readonly open: (signal: AbortSignal) => AsyncIterable;\n readonly ended: (accepted: boolean) => Error;\n readonly carrierFailed?: (error: RemoteStreamCarrierError) => void;\n}', }, { name: 'RenderFactorySlot', diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index 513bf14548..f4989ed8a8 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-runtime/README.i18n.yaml @@ -27,8 +27,8 @@ en: cbcfc9a331783c98 zh: 1cc2f8e21a3d0dd4 /deepseek-ai-dsh-client-test-runtime/use-this-package/whole-client-tier: - en: 2c2e221adff5164e - zh: 86349d4718c852e6 + en: 1442fc215c8128e8 + zh: 8439861b08e46ea2 /deepseek-ai-dsh-client-test-runtime/use-this-package/roster-and-startup-behavior: en: 964236f295b61c0c zh: 863a9342b1169ad8 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index 1818973fae..2a7690f4ab 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -72,8 +72,6 @@ expect(view.getByRole('alert')).toHaveTextContent('goal/not-found') The slot tier above mounts one feature against doubles. The whole-client tier boots the real assembly: `TestClient.start(plan, mock, options)` imports every roster row's `/client` module in-process (or takes the plan's `provide` replacement), binds `{ rpc: mock.rpc }` to that client's Connection module, synthesizes the boot graph with `graphFromRoster` and hands the loaded modules to the production module system, boots through the production `bootClient`, optionally mounts `uiRenderer`, and waits for `ctx.connection.state === 'connected'`. It lives behind a deep import so slot-tier specs never load it: -The assembly mounts the Gateway Client row with `forwardedEvents: true`, the choice a served Web page receives from its Host's Gateway row, so roster plugins can call `$on()` and `$stream()` before the test waits for Connection readiness. - ```text // @vitest-environment jsdom import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index 16b3021d0f..a603d918c6 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -72,8 +72,6 @@ expect(view.getByRole('alert')).toHaveTextContent('goal/not-found') 上面的 slot 档把一个功能挂在替身上。整体档起真实装配:`TestClient.start(plan, mock, options)` 进程内 import 每个 roster 行的 `/client` 模块(或取计划里的 `provide` 替换),把 `{ rpc: mock.rpc }` 绑定到该客户端的 Connection 模块,用 `graphFromRoster` 合成启动图并把已加载模块交给生产模块系统,经生产 `bootClient` 启动,按需挂载 `uiRenderer`,再等 `ctx.connection.state === 'connected'`。它藏在深 import 后面,slot 档测试永不加载它: -装配以 `forwardedEvents: true` 挂载 Gateway Client 行,即已服务的 Web 页面从其 Host 的 Gateway 行获得的选择,因此 roster 插件可以在测试等待 Connection 就绪之前调用 `$on()` 与 `$stream()`。 - ```text // @vitest-environment jsdom import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' diff --git a/packages/test-support/client-runtime/src/assembly/test-client.ts b/packages/test-support/client-runtime/src/assembly/test-client.ts index 618b058507..2a54d235bc 100644 --- a/packages/test-support/client-runtime/src/assembly/test-client.ts +++ b/packages/test-support/client-runtime/src/assembly/test-client.ts @@ -61,7 +61,6 @@ class SharedJsdomShims { const sharedJsdomShims = new SharedJsdomShims() const CONNECTION_PACKAGE = '@deepseek-ai/dsh-client-connection' -const GATEWAY_PACKAGE = '@deepseek-ai/dsh-api-gateway' /** Default readiness budget; the mock answers `$events` immediately, so a miss means a boot-time fixture is absent. */ const DEFAULT_CONNECT_TIMEOUT_MS = 5_000 @@ -178,8 +177,7 @@ export class TestClient { * `installConnection`, while this path supplies the mock carrier, uses * default recovery timings, and captures the current page hostname once for * later reloads. A caller-provided Connection row remains unchanged and owns - * its readiness behavior. The Gateway Client row receives forwarded events, - * the choice a served Web page reads from its Host. The `@deepseek-ai/dsh-api-remotes` row is + * its readiness behavior. The `@deepseek-ai/dsh-api-remotes` row is * dropped from the roster: its generated Remote clients exist only in built * `lib/`, and the `remote.` services the roster injects (plus the * namespaces the mock has rules for at this point) are provided as @@ -228,14 +226,6 @@ export class TestClient { }, }) } - const gateway = modules.get(GATEWAY_PACKAGE) - if (gateway !== undefined && plan.provide?.[GATEWAY_PACKAGE] === undefined) { - // The served Web page receives this choice from the Host Gateway row; the in-process tier has no served page. - modules.set(GATEWAY_PACKAGE, { - ...gateway, - apply: gatewayCtx => gateway.apply(gatewayCtx, { forwardedEvents: true }), - }) - } mountPoint = resolveMountPoint(options.mount) release = sharedJsdomShims.acquire() const system = createInProcessModules(graphFromRoster(roster.rows), modules) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6d45bb1e5..6359e04f41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1191,9 +1191,6 @@ importers: '@deepseek-ai/dsh-typert-protocol': specifier: workspace:* version: link:../../typert/protocol - '@deepseek-ai/dsh-util-crypto': - specifier: workspace:* - version: link:../../util/crypto '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -1222,6 +1219,9 @@ importers: '@deepseek-ai/dsh-typert-registry': specifier: workspace:* version: link:../../typert/registry + '@deepseek-ai/dsh-util-crypto': + specifier: workspace:* + version: link:../../util/crypto '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -1368,15 +1368,6 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:* version: link:../../core/scope - '@deepseek-ai/dsh-session': - specifier: workspace:* - version: link:../../core/session - '@deepseek-ai/dsh-session-projection': - specifier: workspace:* - version: link:../../session/session-projection - '@deepseek-ai/dsh-session-query': - specifier: workspace:* - version: link:../../session-query/session-query '@deepseek-ai/dsh-session-reference': specifier: workspace:* version: link:../../context/session-reference @@ -1389,21 +1380,12 @@ importers: '@deepseek-ai/dsh-typert-protocol': specifier: workspace:* version: link:../../typert/protocol - '@deepseek-ai/dsh-typert-registry': - specifier: workspace:* - version: link:../../typert/registry '@deepseek-ai/dsh-user-approval': specifier: workspace:* version: link:../../interaction/user-approval '@deepseek-ai/dsh-user-questions': specifier: workspace:* version: link:../../interaction/user-questions - typescript: - specifier: ^6.0.3 - version: 6.0.3 - ws: - specifier: ^8.21.3 - version: 8.21.3 zod: specifier: 4.4.3 version: 4.4.3 diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index e80797f675..434efaca09 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -314,15 +314,6 @@ describe('package payload constraints', () => { .toEqual([expect.stringContaining('package.json files must be')]) }) - it('publishes emitted JavaScript for an explicit Node Client entry', () => { - const client = { types: './lib/types/client/index.d.ts', default: './lib/client.js' } - expect(expectedDshPackageFiles({ exports: { './client': client } })) - .not.toContain('lib/types/**/*.js') - expect(expectedDshPackageFiles({ exports: { - './client': { ...client, node: './lib/types/client/index.js' }, - } })).toContain('lib/types/**/*.js') - }) - it.each([ { exports: { './locale/*.json': './locale/*.json' }, resources: ['locale/*.json'] }, { exports: { './search/locale/*.json': './resources/search/*.json' }, resources: ['resources/search/*.json'] }, diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 6f08254d12..e4a038cf1b 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -289,7 +289,7 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str // browser-safe source channels rehomed off src so plain Node can import // them without type stripping) publish the emitted JS alongside the // declarations. - ...usesEmittedTreeRuntime(manifest) ? ['lib/types/**/*.js'] : [], + ...usesEmittedTreeDefaults(manifest) ? ['lib/types/**/*.js'] : [], 'lib/types/**/*.d.ts', ...hasExportPair(manifest, './typert', './lib/typert.host.d.ts', './lib/typert.host.js') ? ['lib/typert.host.js', 'lib/typert.host.d.ts'] @@ -331,13 +331,10 @@ function exportDefault(manifest: PackageManifest, subpath: string): string | und return typeof target === 'string' ? target : undefined } -/** Whether an export's default or explicit Node runtime uses the tsc-emitted tree. */ -function usesEmittedTreeRuntime(manifest: PackageManifest): boolean { - return Object.entries(manifest.exports ?? {}).some(([subpath, entry]) => { - const node = exportFields(entry)?.node - return exportDefault(manifest, subpath)?.startsWith('./lib/types/') === true - || typeof node === 'string' && node.startsWith('./lib/types/') - }) +/** Whether any export's runtime default points into the tsc-emitted lib/types tree. */ +function usesEmittedTreeDefaults(manifest: PackageManifest): boolean { + return Object.keys(manifest.exports ?? {}).some(subpath => + exportDefault(manifest, subpath)?.startsWith('./lib/types/') === true) } /** Experimental manifest requirements, including explicit private exceptions. */ diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index b088e5b467..7c082e8c9a 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -170,7 +170,9 @@ "8f28ec738a702607e9ad458a76d5c695a459fed28009b6aac835ef4789026bfb": 1, "97b408a8e61b28b507aab6f709875826b82d6505d4b967aec56914e7f831b20b": 1, "a25e6d8d728e1de71699956f42f62a2e38af439317082fbb0354161ead666ab6": 1, - "b121cee14635b00f7891217013e4682493cf96f676710be60722fdf8b862aea3": 1 + "b121cee14635b00f7891217013e4682493cf96f676710be60722fdf8b862aea3": 1, + "b428751e788e277116156e105c127f813dc300b61c6664b08d46e6c5ccdc3ae8": 1, + "da388e50e43adcb0b1331d55e3551780c382448686fe6a9dca486937c1667df0": 1 }, "packages/api/gateway/tests/gateway.host.spec.ts": { "344e2cbbed2a8ef8d5356a24def92ac3453ffaa2efa58f5481d59206f5b79cbc": 1, diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 5f99a3e534..9c2cf02a10 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -896,7 +896,6 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { 'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts', 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts', 'packages/api/remotes/tests/built-lib.e2e.ts', - 'packages/api/remotes/tests/session-client.e2e.ts', 'packages/experimental/agent-team/tests/built-lib.e2e.ts', // Built execution consumers: the only automated proof that package-name // imports reach their lib/ entrypoints under plain Node. The e2e lane runs diff --git a/scripts/verify-package-dependencies.spec.ts b/scripts/verify-package-dependencies.spec.ts index 5494cb4877..3548871b00 100644 --- a/scripts/verify-package-dependencies.spec.ts +++ b/scripts/verify-package-dependencies.spec.ts @@ -560,33 +560,6 @@ describe('face-aware source classification', () => { } }) - it('retains runtime dependencies reached by an explicit Node Client entry', () => { - const subject = sourceFacts({ - 'src/index.ts': 'export function apply() {}', - 'src/client/index.ts': "import 'client-node-runtime'", - 'src/client/browser.ts': "import 'browser-only'", - }, { - exports: { - './client': { - types: './lib/types/client/index.d.ts', - node: './lib/types/client/index.js', - default: './lib/client.js', - }, - './client/browser': './lib/client/browser.js', - }, - }) - - expect([...subject.hostRuntimeSourceUses.keys()]).toEqual(['client-node-runtime']) - expect(expectedPackageDependencies(subject).get('client-node-runtime')?.section).toBe('dependencies') - expect(expectedPackageDependencies(subject).get('browser-only')?.section).toBe('devDependencies') - }) - - it('rejects an explicit Node Client entry without its source', () => { - expect(() => sourceFacts({ 'src/index.ts': 'export function apply() {}' }, { - exports: { './client': { node: './lib/types/client/index.js', default: './lib/client.js' } }, - })).toThrow('Host export ./client has no source entry') - }) - it.each([ './lib/missing.js', { types: './lib/types/missing.d.ts', default: './lib/renamed.js' }, diff --git a/scripts/verify-package-dependencies.ts b/scripts/verify-package-dependencies.ts index 59df626937..50e3ebcb5c 100644 --- a/scripts/verify-package-dependencies.ts +++ b/scripts/verify-package-dependencies.ts @@ -362,16 +362,11 @@ function hostSourceEntries(root: string, pkg: WorkspacePackageManifest): string[ const exports = pkg.manifest.exports if (exports === null || typeof exports !== 'object') return [...entries] for (const [subpath, declaration] of Object.entries(exports as Record)) { - if (subpath === '.' || subpath === './package.json' || /^\.\/src(?:\/|$)/.test(subpath)) continue - let runtimeDeclaration = declaration - if (/^\.\/client(?:\/|$)/.test(subpath)) { - if (declaration === null || typeof declaration !== 'object' || !('node' in declaration)) continue - runtimeDeclaration = declaration.node - } + if (subpath === '.' || subpath === './package.json' || /^\.\/(?:client|src)(?:\/|$)/.test(subpath)) continue const types = declaration !== null && typeof declaration === 'object' && 'types' in declaration ? declaration.types : undefined - for (const runtime of nodeExportTargets(runtimeDeclaration)) { + for (const runtime of nodeExportTargets(declaration)) { const generatedFace = subpath === './typert' ? 'host' : subpath === './remote' ? 'remote-client' : undefined if (generatedFace !== undefined && runtime === `./lib/typert.${generatedFace}.js` diff --git a/tsconfig.base.json b/tsconfig.base.json index 8e65cfb83d..f4323f2b48 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -247,7 +247,6 @@ "@deepseek-ai/dsh-client-connection": ["./packages/client/connection/src"], "@deepseek-ai/dsh-client-file-upload": ["./packages/client/file-upload/src"], "@deepseek-ai/dsh-client-file-upload/client": ["./packages/client/file-upload/src/client/index.ts"], - "@deepseek-ai/dsh-client-file-upload/types": ["./packages/client/file-upload/src/types.ts"], "@deepseek-ai/dsh-api-remotes": ["./packages/api/remotes/src"], "@deepseek-ai/dsh-api-remotes/client": ["./packages/api/remotes/src/client/index.ts"], "@deepseek-ai/dsh-client-hmr": ["./packages/client/hmr/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index b11984cbf2..f6e4e3309a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -224,8 +224,6 @@ "scripts/*.client.spec.ts", "scripts/client-bundle-css.spec.ts", "packages/typert/generator/tests/fixtures/**", - // Compiled against ordinary generated artifacts by session-client.e2e.ts. - "packages/api/remotes/tests/fixtures/session-client/**", "scripts/client-bundle-purity.spec.ts" ], "references": [ From d4d904bc4f3c33b54ae631d93cd2ebdbb6000353 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:06:08 +0800 Subject: [PATCH 08/17] fix(typert): resolve the file-upload types subpath in host analysis The Typert host program analyzes packages/api/session-controller/src/commands.ts on a clean checkout, where only tsconfig paths resolve @deepseek-ai/dsh-client-file-upload/types; without the mapping the dependency policy gate fails with TS2307. --- tsconfig.base.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.base.json b/tsconfig.base.json index f4323f2b48..8e65cfb83d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -247,6 +247,7 @@ "@deepseek-ai/dsh-client-connection": ["./packages/client/connection/src"], "@deepseek-ai/dsh-client-file-upload": ["./packages/client/file-upload/src"], "@deepseek-ai/dsh-client-file-upload/client": ["./packages/client/file-upload/src/client/index.ts"], + "@deepseek-ai/dsh-client-file-upload/types": ["./packages/client/file-upload/src/types.ts"], "@deepseek-ai/dsh-api-remotes": ["./packages/api/remotes/src"], "@deepseek-ai/dsh-api-remotes/client": ["./packages/api/remotes/src/client/index.ts"], "@deepseek-ai/dsh-client-hmr": ["./packages/client/hmr/src"], From e6278de7cc06a97eea818465c9176fd4d6139439 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:22:25 +0800 Subject: [PATCH 09/17] docs(typert): label the note's illustrative fences as text The eight snippets in this note are before/after sketches: two import the Remote source modules this change deletes, and the rest are fragments without their surrounding context. They were never type-checked, yet each one counted against the repository-wide opt-out ratio, which now sits above the 50% ceiling that `doc-typecheck:contracts-ready` enforces. Label them as text so the ceiling reflects blocks that could be checked. --- .../2026-09-20-unified-typert-remote-metadata.md | 16 ++++++++-------- ...26-09-20-unified-typert-remote-metadata.zh.md | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md index a264640705..29edd8d602 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md +++ b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md @@ -66,7 +66,7 @@ The old examples are historical comparisons of source-export consumers in the de **Operation owner and strict Host.** Preserve the business signature; leave generated registration to ordinary Typert Loader instead of the Service constructor. -```ts ignore-check +```text // Old source delivery. import { TYPERT_REMOTE } from './remote.generated.ts' // Constructor: super(ctx, 'sessionController', { namespace: 'session', definitions: TYPERT_REMOTE }) @@ -85,7 +85,7 @@ page(request: SessionPageRequest, signal: AbortSignal): Promise { **Browser and Node invocation.** A source-export consumer changes `/remote-source` to `/remote`; existing ordinary consumers already use this path. The live Context mounts once, without separate Registry registration. -```ts ignore-check +```text import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' await ctx.remote.$mount(TYPERT_REMOTE) const result = await ctx.remote.session.page(request, signal) @@ -99,13 +99,13 @@ try { **Offline CLI and framework metadata.** The old selection reads a source table; the replacement reads the profile-owned Registry before target discovery. The two snippets are alternatives, not simultaneous declarations. -```ts ignore-check +```text // Old selection. import { REMOTE_METHODS } from '@deepseek-ai/dsh-api-session-controller/remote-source' const page = REMOTE_METHODS['session/page'] ``` -```ts ignore-check +```text // Metadata plugin: inject: ['typert']; no Gateway, Connection, or Host controller. import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' import { isTypertRemoteMethod } from '@deepseek-ai/dsh-typert-protocol/remote-info' @@ -120,7 +120,7 @@ The registration belongs to that plugin's fiber. Help uses `description` and `in **CLI input and dispatch.** After local `current` resolution and attachment preparation, either metadata access path uses the same invocation sequence: -```ts ignore-check +```text if (page.mode === 'stream') throw new Error('session/page must be unary') const input = page.input({ strict: true }) const arguments_ = input.create().parse(raw) @@ -131,7 +131,7 @@ const output = page.result.create().parse(result.value) **MCP discovery and calls.** Selection validates tool-specific requirements; generation itself does not require them. -```ts ignore-check +```text const input = page.input({ strict: true }) if (!page.description) throw new Error('session/page has no tool description') if (input.jsonSchema === undefined) throw new Error('session/page has no supported tool input schema') @@ -147,13 +147,13 @@ This compact object shows only the metadata dependency. The real MCP adapter ret **Native tools.** Query `ctx.typert.local.get('session/page')` and narrow it with `isTypertRemoteMethod` instead of reading the source table. Native tools can select `method.input({ strict: true })` for matching parsing and input projection. Native registration still requires supported input and result projections, including core Tool's mandatory `output.schema`; optional MCP output projection is not sufficient here. Once the facade obtains a typed request, both designs execute the same local call: -```ts ignore-check +```text const value = await ctx.sessionController.page(request, signal) ``` **Independent consumers.** Read the ordinary info instance's typed index; no Registry is required: -```ts ignore-check +```text import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' const page = TYPERT_REMOTE.methods['session/page'] const arguments_ = page.input().create().parse(raw) diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md index 2611475e6f..09afa4b052 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md +++ b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md @@ -66,7 +66,7 @@ Host 元数据就绪仍由组合负责。要求严格校验的 Host 必须等待 **操作拥有者与严格 Host。** 保留业务签名;将生成定义注册交给普通 Typert Loader,而非 Service 构造函数。 -```ts ignore-check +```text // Old source delivery. import { TYPERT_REMOTE } from './remote.generated.ts' // Constructor: super(ctx, 'sessionController', { namespace: 'session', definitions: TYPERT_REMOTE }) @@ -85,7 +85,7 @@ page(request: SessionPageRequest, signal: AbortSignal): Promise { **Browser 与 Node 调用。** 源码导出消费方将 `/remote-source` 改为 `/remote`;已有普通消费方本来就使用此路径。实际调用 Context 挂载一次,不额外向 Registry 注册。 -```ts ignore-check +```text import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' await ctx.remote.$mount(TYPERT_REMOTE) const result = await ctx.remote.session.page(request, signal) @@ -99,13 +99,13 @@ try { **离线 CLI 与框架元数据。** 旧选择读取源码方法表;替代方式在目标发现之前读取 profile 拥有的 Registry。两个片段是不同选择,不是同时声明。 -```ts ignore-check +```text // Old selection. import { REMOTE_METHODS } from '@deepseek-ai/dsh-api-session-controller/remote-source' const page = REMOTE_METHODS['session/page'] ``` -```ts ignore-check +```text // Metadata plugin: inject: ['typert']; no Gateway, Connection, or Host controller. import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' import { isTypertRemoteMethod } from '@deepseek-ai/dsh-typert-protocol/remote-info' @@ -120,7 +120,7 @@ if (page === undefined || !isTypertRemoteMethod(page)) { **CLI 输入与分发。** 本地 `current` 解析和附件准备之后,两种元数据访问方式使用同样的调用步骤: -```ts ignore-check +```text if (page.mode === 'stream') throw new Error('session/page must be unary') const input = page.input({ strict: true }) const arguments_ = input.create().parse(raw) @@ -131,7 +131,7 @@ const output = page.result.create().parse(result.value) **MCP 发现与调用。** 选择时验证工具特有要求;生成过程本身不要求它们。 -```ts ignore-check +```text const input = page.input({ strict: true }) if (!page.description) throw new Error('session/page has no tool description') if (input.jsonSchema === undefined) throw new Error('session/page has no supported tool input schema') @@ -147,13 +147,13 @@ const tool = { **原生工具。** 查询 `ctx.typert.local.get('session/page')`,并用 `isTypertRemoteMethod` 收窄,而非读取源码方法表。原生工具可以选择 `method.input({ strict: true })`,取得匹配的解析与输入投影。原生注册仍要求受支持的输入与结果投影,包括 core Tool 必需的 `output.schema`;MCP 的可选输出投影在这里并不足够。facade 取得类型化请求后,两种设计执行同样的本地调用: -```ts ignore-check +```text const value = await ctx.sessionController.page(request, signal) ``` **独立消费方。** 读取普通 info 实例的类型化索引,不要求 Registry: -```ts ignore-check +```text import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' const page = TYPERT_REMOTE.methods['session/page'] const arguments_ = page.input().create().parse(raw) From bd91a4a5701585429d5fd99ee006567975ce94d3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:22:47 +0800 Subject: [PATCH 10/17] fix(typert): inject zod into remote metadata factories --- ...0-unified-typert-remote-metadata.i18n.yaml | 8 ++--- ...26-09-20-unified-typert-remote-metadata.md | 7 +++-- ...09-20-unified-typert-remote-metadata.zh.md | 7 +++-- packages/typert/generator/README.i18n.yaml | 4 +-- packages/typert/generator/README.md | 2 +- packages/typert/generator/README.zh.md | 2 +- packages/typert/generator/src/emitter.ts | 2 +- .../generator/tests/remote-metadata.spec.ts | 31 +++++++++++++++++++ .../typert/generator/tests/remote-module.ts | 2 +- .../typert/loader/tests/lazy-info.spec.ts | 2 +- packages/typert/loader/tests/loader.spec.ts | 5 +-- packages/typert/protocol/README.i18n.yaml | 4 +-- packages/typert/protocol/README.md | 2 +- packages/typert/protocol/README.zh.md | 2 +- packages/typert/protocol/src/remote-info.ts | 22 ++++++++----- .../protocol/tests/built-runtime.e2e.ts | 10 ++++++ .../typert/protocol/tests/remote-info.spec.ts | 24 ++++++++------ packages/typert/registry/tests/typert.spec.ts | 2 +- 18 files changed, 97 insertions(+), 41 deletions(-) create mode 100644 packages/typert/protocol/tests/built-runtime.e2e.ts diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.i18n.yaml index 75110207f0..c9c690211e 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.i18n.yaml @@ -9,8 +9,8 @@ en: 3aa575e4c0778df1 zh: 5277fc49b040da50 /agent-note-unified-typert-remote-metadata/technical-architecture: - en: 23fa081ffc7d9489 - zh: 0128d572e821227f + en: eb12e56cdcc7c76b + zh: bebff68d354c1ba7 /agent-note-unified-typert-remote-metadata/proposal: en: f5dcecbcae90a898 zh: 923cf3a3074a3a2d @@ -27,8 +27,8 @@ en: 151b1f66af030bc5 zh: 44fea0898acfbdcb /agent-note-unified-typert-remote-metadata/acceptance-criteria: - en: a42c93afee12b51c - zh: da8fdfb8c68ba88c + en: 875ea831cf90f310 + zh: 67f57449a55fe8ec /agent-note-unified-typert-remote-metadata/risks: en: a770478eac8f5fd1 zh: f24aa378f875f47f diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md index 29edd8d602..25154bcb8a 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md +++ b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md @@ -22,13 +22,13 @@ Both delivery paths derive from the same [compiler-independent model](../../impl [Registry](../../../../packages/typert/registry/src/service.ts) instances belong to their Cordis Context and process. Host and Client entries share implementation, not state. Existing local/Remote `get()` and `list()` preserve the registered descriptor objects; registration retains atomic duplicate checks, notifications, tombstones, and fiber-owned disposal. No Registry API, store, subscription, metadata RPC, or cross-process synchronization is added. Separate Programs and processes do not share object identity. -The [emitter](../../../../packages/typert/generator/src/emitter.ts) writes one static `TypertRemoteMethodSpec` per endpoint and calls `createTypertRemoteInfo({ package, methods })` from the published pure `@deepseek-ai/dsh-typert-protocol/remote-info` entry. The endpoint key supplies `namespace` and `method`, and `id` combines it with the package. Each spec keeps the service, receiver, ordered parameters, single `result` codec, and optional stream, scope, uplink, cancellation, documentation, and source-location fields. Every parameter has both `codec` and `strictCodec` from the same checker-resolved type model, with no per-method policy in the analyzer or model. Parameter `wire` defaults to `name`; parameter documentation stays on `parameters[].description`. There is no nested `metadata`, separate `output` or `parameterDescriptions`, or top-level `REMOTE_METHODS` export. +The [emitter](../../../../packages/typert/generator/src/emitter.ts) writes one static `TypertRemoteMethodSpec` per endpoint and calls `createTypertRemoteInfo({ package, methods }, z)` from the published pure `@deepseek-ai/dsh-typert-protocol/remote-info` entry. The endpoint key supplies `namespace` and `method`, and `id` combines it with the package. Each spec keeps the service, receiver, ordered parameters, single `result` codec, and optional stream, scope, uplink, cancellation, documentation, and source-location fields. Every parameter has both `codec` and `strictCodec` from the same checker-resolved type model, with no per-method policy in the analyzer or model. Parameter `wire` defaults to `name`; parameter documentation stays on `parameters[].description`. There is no nested `metadata`, separate `output` or `parameterDescriptions`, or top-level `REMOTE_METHODS` export. -The [factory](../../../../packages/typert/protocol/src/remote-info.ts) returns an actual class instance, not a class constructor or eager object literal. Construction retains the spec only. The first `descriptors` or `methods` read, including mounting or querying, builds and caches one shared index. Each `methods[endpoint]` aliases the exact direct descriptor object; Context-only receivers remain in descriptors and scoped declarations. Direct methods with scoped projections retain their scope fields but their generic named invoker requires an unscoped Remote. +The [factory](../../../../packages/typert/protocol/src/remote-info.ts) returns an actual class instance, not a class constructor or eager object literal. Construction retains only the spec and the Zod runtime explicitly supplied by the generated artifact, without constructing schemas. The first `descriptors` or `methods` read, including mounting or querying, builds and caches one shared index. Each `methods[endpoint]` aliases the exact direct descriptor object; Context-only receivers remain in descriptors and scoped declarations. Direct methods with scoped projections retain their scope fields but their generic named invoker requires an unscoped Remote. A direct method's `input(options?: TypertRemoteInputOptions)` returns a cached `TypertRemoteValue`; `strict?: boolean` is the only option. `method.input()` and `{ strict: false }` use normal codecs: declared objects accept and strip unknown fields. `{ strict: true }` rejects undeclared fields recursively. Both named-argument envelopes reject extra arguments; explicit JSON and dictionary keys stay open, and constrained dictionary values still validate. Consumers select one view for matching `input.create().parse(raw)` and `input.jsonSchema`. Host dispatch and the single `result` codec retain normal behavior, including binary `encode`/`decode`. -Each input-policy view, its named parser, and its JSON Schema projection are independently lazy and cached. Individual generated Zod factories are also lazy and success-cached. The raw spec requires Zod factories internally, while consumer-facing `TypertSchema` promises only `parse(value: unknown): T`. JSON Schema remains an optional generator projection, not runtime `z.toJSONSchema()` conversion. The pure leaf depends only on Zod at runtime, not Host source or transport. Imports and metadata/schema access never invoke the network; the protocol root re-exports the factory too but imports Cordis. +Each input-policy view, its named parser, and its JSON Schema projection are independently lazy and cached. Individual generated Zod factories are also lazy and success-cached. The raw spec requires Zod factories internally, while consumer-facing `TypertSchema` promises only `parse(value: unknown): T`. JSON Schema remains an optional generator projection, not runtime `z.toJSONSchema()` conversion. Generated artifacts import and supply Zod, including Remote endpoints without parameters or exported schemas. The pure leaf retains only Zod type imports and has no runtime module dependencies; the protocol root re-exports the factory and imports Cordis without adding Zod to every protocol consumer's static module graph. Imports and metadata/schema access never invoke the network. Registry queries return base descriptors. `isTypertRemoteMethod(descriptor)` from the pure leaf or protocol root narrows direct capabilities with `object`/`unknown` types; exact endpoint types come from `TYPERT_REMOTE.methods`, not dynamic lookup. Unary `mode` is omitted; streaming `mode` is `'stream'`. `invoke(remote, input, signal?)` resolves the mounted namespace method at call time, preserving its `this`, optional positional holes, and signal. It forwards the same full `Promise>` or `RemoteStreamHandle` without an async wrapper; stream iteration, `send`, `end`, and `dispose` remain available. The fifth `TypertRemoteMethod` generic retains the carrier-decoded unary byte type independently of the result parser's authored output. @@ -180,6 +180,7 @@ pnpm dsh --profile - Ordinary artifacts emit one static method spec per endpoint and return lazy class instances; direct method indexes alias descriptor objects, with full stream/binary typing, separate compiler faces, and full-face augmentation retention. - Descriptor indexes and each input-policy view, parser, and projection are independently lazy and cached; Loader validates static specs and callable helpers without calling selectors, parsers, or projections. - Every input parameter emits normal and strict codecs from the same model; consumers select policy without a method annotation. Both envelopes reject extra named arguments; strict input recursively closes declared objects without closing JSON or dictionary keys or changing Host dispatch and results. +- Protocol source and built entries have no Zod runtime dependency; generated artifacts explicitly supply Zod, zero-argument endpoints create empty inputs that reject extra fields, and the production-profile Loader regression asserts actual Session events. - Registry identity, atomic registration, withdrawal, and disposal remain intact. - Missing documentation or unsupported optional projection does not disable generation; selected tools reject missing requirements and still parse outputs. - Strict source/artifact invocation and limited fallback have separate tests; a withdrawn strict descriptor never enables fallback. diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md index 09afa4b052..1b09c77379 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md +++ b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md @@ -22,13 +22,13 @@ CLI 帮助、参数解析和 MCP 工具发现需要在连接受控 Host 前取 [Registry](../../../../packages/typert/registry/src/service.ts) 实例属于各自的 Cordis Context 和进程。Host 与 Client 入口共享实现,而非状态。既有本地/Remote `get()` 与 `list()` 保留已注册的描述符对象;注册保留原子重复检查、通知、tombstone 和 fiber 管理的释放。不新增 Registry API、存储、订阅、元数据 RPC 或跨进程同步。独立 Program 和进程不共享对象身份。 -[发射器](../../../../packages/typert/generator/src/emitter.ts)为每个端点写入一份静态 `TypertRemoteMethodSpec`,并调用已发布纯入口 `@deepseek-ai/dsh-typert-protocol/remote-info` 提供的 `createTypertRemoteInfo({ package, methods })`。端点键提供 `namespace` 和 `method`,`id` 由包名与端点键组合。每份 spec 保留 service、接收者、有序参数、唯一的 `result` codec,以及可选的流模式、scope、uplink、取消、文档和源码位置字段。每个参数都有从同一 checker 解析后类型模型派生的 `codec` 和 `strictCodec`,分析器与模型不携带逐方法策略。参数 `wire` 默认使用 `name`;参数文档保留在 `parameters[].description`。不存在嵌套 `metadata`、独立 `output` 或 `parameterDescriptions`,也不导出顶层 `REMOTE_METHODS`。 +[发射器](../../../../packages/typert/generator/src/emitter.ts)为每个端点写入一份静态 `TypertRemoteMethodSpec`,并调用已发布纯入口 `@deepseek-ai/dsh-typert-protocol/remote-info` 提供的 `createTypertRemoteInfo({ package, methods }, z)`。端点键提供 `namespace` 和 `method`,`id` 由包名与端点键组合。每份 spec 保留 service、接收者、有序参数、唯一的 `result` codec,以及可选的流模式、scope、uplink、取消、文档和源码位置字段。每个参数都有从同一 checker 解析后类型模型派生的 `codec` 和 `strictCodec`,分析器与模型不携带逐方法策略。参数 `wire` 默认使用 `name`;参数文档保留在 `parameters[].description`。不存在嵌套 `metadata`、独立 `output` 或 `parameterDescriptions`,也不导出顶层 `REMOTE_METHODS`。 -[factory](../../../../packages/typert/protocol/src/remote-info.ts)返回真正的类实例,而非类构造函数或立即求值的对象字面量。构造时只保留 spec。首次读取 `descriptors` 或 `methods` 时(包括挂载或查询时)才构建并缓存一个共享索引。每个 `methods[endpoint]` 就是同一个直接方法描述符对象;仅使用 Context 接收者的方法保留在描述符和作用域声明中。带作用域投影的直接方法保留 scope 字段,但其通用具名调用器要求无作用域 Remote。 +[factory](../../../../packages/typert/protocol/src/remote-info.ts)返回真正的类实例,而非类构造函数或立即求值的对象字面量。构造时只保留 spec 和生成产物显式传入的 Zod 运行时,不构造 schema。首次读取 `descriptors` 或 `methods` 时(包括挂载或查询时)才构建并缓存一个共享索引。每个 `methods[endpoint]` 就是同一个直接方法描述符对象;仅使用 Context 接收者的方法保留在描述符和作用域声明中。带作用域投影的直接方法保留 scope 字段,但其通用具名调用器要求无作用域 Remote。 直接方法的 `input(options?: TypertRemoteInputOptions)` 返回缓存的 `TypertRemoteValue`;唯一选项是 `strict?: boolean`。`method.input()` 与 `{ strict: false }` 使用普通 codec:已声明对象接受并剥离未知字段。`{ strict: true }` 递归拒绝未声明字段。两种具名参数封装都拒绝额外参数;显式 JSON 与字典键保持开放,有约束的字典值仍然校验。消费方选择一个视图,通过 `input.create().parse(raw)` 与 `input.jsonSchema` 取得匹配的解析和投影。Host 分发与唯一的 `result` codec 保留普通行为,包括二进制 `encode`/`decode`。 -每种输入策略的视图、具名解析器及 JSON Schema 投影都独立惰性求值并缓存。生成的独立 Zod factory 同样惰性求值且只缓存成功结果。原始 spec 内部要求 Zod factory,而面向消费方的 `TypertSchema` 只承诺 `parse(value: unknown): T`。JSON Schema 仍是可选的生成器投影,而非运行时 `z.toJSONSchema()` 转换。纯叶入口运行时仅依赖 Zod,不依赖 Host 源码或传输。导入和元数据/schema 读取绝不发起网络调用;协议根入口也重新导出 factory,但会导入 Cordis。 +每种输入策略的视图、具名解析器及 JSON Schema 投影都独立惰性求值并缓存。生成的独立 Zod factory 同样惰性求值且只缓存成功结果。原始 spec 内部要求 Zod factory,而面向消费方的 `TypertSchema` 只承诺 `parse(value: unknown): T`。JSON Schema 仍是可选的生成器投影,而非运行时 `z.toJSONSchema()` 转换。生成产物导入并传入 Zod,包括没有参数或导出 schema的 Remote 端点。纯叶入口只保留 Zod 类型导入,没有运行时模块依赖;协议根入口重新导出 factory 并导入 Cordis,但不会因此将 Zod 加入所有协议消费方的静态模块图。导入和元数据/schema 读取绝不发起网络调用。 Registry 查询返回基础描述符。纯叶入口或协议根入口的 `isTypertRemoteMethod(descriptor)` 以 `object`/`unknown` 类型收窄直接调用能力;精确端点类型来自 `TYPERT_REMOTE.methods`,而非动态查询。一元 `mode` 省略;流式 `mode` 为 `'stream'`。`invoke(remote, input, signal?)` 在调用时解析已挂载的命名空间方法,保留其 `this`、可选位置参数空位和 signal。它不使用 async 包装,直接转发同一个完整 `Promise>` 或 `RemoteStreamHandle`;流迭代、`send`、`end` 和 `dispose` 均保留。`TypertRemoteMethod` 的第五个泛型参数独立于结果解析器的原始输出,保留载体解码后的一元字节类型。 @@ -180,6 +180,7 @@ pnpm dsh --profile - 普通产物为每个端点生成一份静态方法 spec,并返回惰性类实例;直接方法索引引用描述符对象,保留完整流式/二进制类型、独立编译面及完整编译面的声明合并。 - 描述符索引与每种输入策略的视图、解析器和投影都独立惰性求值并缓存;Loader 校验静态 spec 及可调用辅助能力,不调用选择器、解析器或投影。 - 每个输入参数从同一模型生成普通与严格 codec;消费方无需方法注解即可选择策略。两种封装都拒绝额外具名参数;严格输入递归关闭已声明对象,但不关闭 JSON 或字典键,也不改变 Host 分发和结果。 +- 协议源码与构建入口没有 Zod 运行时依赖;生成产物显式提供 Zod,无参端点也能创建拒绝额外字段的空输入对象;生产 profile 的 Loader 回归保留真实 Session 事件断言。 - Registry 身份、原子注册、撤销和释放保持不变。 - 缺少文档或不支持可选投影不阻止生成;选中的工具拒绝缺少必需信息的条目,并仍然解析输出。 - 严格源码/产物调用与有限 fallback 分别测试;撤销严格 descriptor 绝不启用 fallback。 diff --git a/packages/typert/generator/README.i18n.yaml b/packages/typert/generator/README.i18n.yaml index b36fdd7f4f..ad58e457f2 100644 --- a/packages/typert/generator/README.i18n.yaml +++ b/packages/typert/generator/README.i18n.yaml @@ -21,8 +21,8 @@ en: c7078becc8f12609 zh: 4b8838b559ecb677 /deepseek-ai-dsh-typert-generator/use-this-package/ordinary-remote-metadata: - en: 0baa110b030d9b80 - zh: 67ff18ef50c35002 + en: f9c84d8080f2bac7 + zh: 4c0cf9f4d7600660 /deepseek-ai-dsh-typert-generator/use-this-package/analyzing-a-workspace-statically: en: 8227097859169cdf zh: 249790ed62e8558a diff --git a/packages/typert/generator/README.md b/packages/typert/generator/README.md index bb3dd232a9..fea69f0dac 100644 --- a/packages/typert/generator/README.md +++ b/packages/typert/generator/README.md @@ -46,7 +46,7 @@ After the build, `lib/typert.host.js` and `lib/typert.host.d.ts` exist and the [ ### Ordinary Remote metadata -The emitter writes one static `TypertRemoteMethodSpec` per endpoint and calls the pure `createTypertRemoteInfo` factory. Ordinary consumer `./remote` artifacts retain their default export and `TYPERT_REMOTE`, typed as `TypertRemoteInfo` with precise `TypertRemoteMethod` entries in `TYPERT_REMOTE.methods`. Each entry is the same object as its direct descriptor in `TYPERT_REMOTE.descriptors`; Context-only receivers remain in descriptors and scoped declarations. There is no separate `REMOTE_METHODS` export, nested `metadata`, or second `output` codec. The [protocol](<../protocol/README.md>) owns the factory and method API. +The emitter writes one static `TypertRemoteMethodSpec` per endpoint and calls the pure `createTypertRemoteInfo` factory with the spec and the artifact's own imported Zod runtime. Ordinary consumer `./remote` artifacts retain their default export and `TYPERT_REMOTE`, typed as `TypertRemoteInfo` with precise `TypertRemoteMethod` entries in `TYPERT_REMOTE.methods`. Each entry is the same object as its direct descriptor in `TYPERT_REMOTE.descriptors`; Context-only receivers remain in descriptors and scoped declarations. There is no separate `REMOTE_METHODS` export, nested `metadata`, or second `output` codec. The [protocol](<../protocol/README.md>) owns the factory and method API. The factory returns a class instance that retains the spec until its `descriptors` or `methods` getter builds their shared index. Each consumer-selected input-policy view, parser, and projection is independently lazy and cached, and generated individual Zod factories also materialize and cache on first use. Ordinary Host `./typert` builds a private info instance whose descriptors are exposed by the `TYPERT.invocations` getter. Host and consumer artifacts share definitions, not object identity across Programs or processes. diff --git a/packages/typert/generator/README.zh.md b/packages/typert/generator/README.zh.md index 0fff52d6a1..93ae06fc5a 100644 --- a/packages/typert/generator/README.zh.md +++ b/packages/typert/generator/README.zh.md @@ -46,7 +46,7 @@ files: ### 普通 Remote 元数据 -发射器为每个端点写入一份静态 `TypertRemoteMethodSpec`,并调用纯 `createTypertRemoteInfo` factory。普通消费方 `./remote` 产物保留默认导出和 `TYPERT_REMOTE`,其类型为 `TypertRemoteInfo`,`TYPERT_REMOTE.methods` 中的条目具有精确的 `TypertRemoteMethod` 类型。每个条目就是 `TYPERT_REMOTE.descriptors` 中同一个直接方法描述符对象;仅使用 Context 接收器的方法保留在描述符和作用域声明中。不存在独立的 `REMOTE_METHODS` 导出、嵌套 `metadata` 或第二份 `output` codec。[协议](<../protocol/README.zh.md>)负责 factory 和方法 API。 +发射器为每个端点写入一份静态 `TypertRemoteMethodSpec`,并将 spec 和产物自身导入的 Zod 运行时传给纯 `createTypertRemoteInfo` factory。普通消费方 `./remote` 产物保留默认导出和 `TYPERT_REMOTE`,其类型为 `TypertRemoteInfo`,`TYPERT_REMOTE.methods` 中的条目具有精确的 `TypertRemoteMethod` 类型。每个条目就是 `TYPERT_REMOTE.descriptors` 中同一个直接方法描述符对象;仅使用 Context 接收器的方法保留在描述符和作用域声明中。不存在独立的 `REMOTE_METHODS` 导出、嵌套 `metadata` 或第二份 `output` codec。[协议](<../protocol/README.zh.md>)负责 factory 和方法 API。 factory 返回类实例,并保留 spec,直到 `descriptors` 或 `methods` getter 构建两者共享的索引。消费方选择的每种输入策略视图、解析器和投影都独立惰性求值并缓存,生成的独立 Zod factory 同样在首次使用时物化并缓存。普通 Host `./typert` 创建私有 info 实例,由 `TYPERT.invocations` getter 暴露其描述符。Host 与消费方产物共享定义,不承诺跨 Program 或进程的对象身份。 diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index 6b1684959b..b620934ee3 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -290,7 +290,7 @@ export class FaceModelEmitter { ...packageModel.invocations.map(invocation => ` ${quote(`${invocation.namespace}/${invocation.method}`)}: ${indent(this.invocationLiteral(invocation, schemas), 4).trimStart()},`), ' },', - '})', + '}, z)', ].join('\n') } diff --git a/packages/typert/generator/tests/remote-metadata.spec.ts b/packages/typert/generator/tests/remote-metadata.spec.ts index 275920225e..91fa4fb4a4 100644 --- a/packages/typert/generator/tests/remote-metadata.spec.ts +++ b/packages/typert/generator/tests/remote-metadata.spec.ts @@ -527,6 +527,37 @@ void [strictParsed, normalInput, wrongInput, output, value, wrongResult, wrongOu expect(method.parameters[1]!.codec.create().parse(extra.request)).toEqual(input.request) }) + it('supplies Zod to parameterless Host and Remote artifacts without exported schemas', async () => { + const root = fixture() + editFile(root, 'packages/remote/src/index.ts', () => `import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' +export class GoalService extends TypertRemoteService { + constructor() { super(undefined, 'goals') } + @Remote + ready(): boolean { return true } +} +`) + const [artifact] = new WorkspaceTypertGenerator(root).generate() + if (artifact?.remote === undefined) throw new Error('missing Remote artifact') + for (const source of [artifact.js, artifact.remote.js]) { + expect(source).toContain("import { z } from 'zod'") + expect(source).toContain('}, z)') + } + const host = await executableRemoteModule<{ TYPERT: { schemas: unknown[]; invocations: RuntimeMethod[] } }>(artifact.js) + const remote = await executable(artifact.remote.js) + expect(host.TYPERT.schemas).toEqual([]) + expect(host.TYPERT.invocations).toHaveLength(1) + for (const method of [host.TYPERT.invocations[0]!, remote.TYPERT_REMOTE.methods['goals/ready']!]) { + for (const strict of [false, true]) { + const input = method.input({ strict }) + const parser = input.create() + expect(input.create()).toBe(parser) + expect(parser.parse({})).toEqual({}) + expect(() => parser.parse({ extra: true })).toThrow() + } + expect(method.result.create().parse(true)).toBe(true) + } + }) + it('supports optional named arguments and parameterless methods', async () => { const root = fixture() editFile(root, 'packages/remote/src/index.ts', source => source.replace(' constructor() {', ` @Remote diff --git a/packages/typert/generator/tests/remote-module.ts b/packages/typert/generator/tests/remote-module.ts index 88946cd6f3..c04138f6f5 100644 --- a/packages/typert/generator/tests/remote-module.ts +++ b/packages/typert/generator/tests/remote-module.ts @@ -13,7 +13,7 @@ export async function executableRemoteModule(source: string): Promise { const { outputText } = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ESNext }, }) - // The factory's only runtime dependency resolves through this fixture's node_modules/zod link. + const module = ts.createSourceFile('remote-info.js', outputText, ts.ScriptTarget.ESNext, true, ts.ScriptKind.JS) + expect(module.statements.filter(ts.isImportDeclaration)).toEqual([]) const path = join(base, 'remote-info.mjs') await writeFile(path, outputText, { flag: 'wx', mode: 0o600 }) return pathToFileURL(path).href @@ -109,7 +110,7 @@ function invocationTypertSource(pkgName: string, factoryUrl: string): string { " sourceLocation: { file: 'src/index.ts', line: 8, column: 3 },", ' },', ' },', - '})', + '}, z)', 'export const TYPERT = {', ' package: REMOTE_INFO.package,', ' face: \'host\',', diff --git a/packages/typert/protocol/README.i18n.yaml b/packages/typert/protocol/README.i18n.yaml index 2494160eeb..50ec613960 100644 --- a/packages/typert/protocol/README.i18n.yaml +++ b/packages/typert/protocol/README.i18n.yaml @@ -18,8 +18,8 @@ en: c479b5b665822198 zh: 1ffb789c18043baf /deepseek-ai-dsh-typert-protocol/use-this-package/exposing-a-host-method: - en: 6b91536e2e5e4792 - zh: da86a5bddda6f01f + en: 82a3db72a51105ff + zh: 46848784380732be /deepseek-ai-dsh-typert-protocol/use-this-package/associating-host-objects-and-contexts-with-wire-identities: en: 3db384ae6f215632 zh: 56207184b09d4018 diff --git a/packages/typert/protocol/README.md b/packages/typert/protocol/README.md index 9aa040cc6c..d0ffd3996a 100644 --- a/packages/typert/protocol/README.md +++ b/packages/typert/protocol/README.md @@ -52,7 +52,7 @@ A stream method (`@Remote({ mode: 'stream' })`) returns `Iterable`, `AsyncIterab Ordinary `./remote` artifacts export `TYPERT_REMOTE` as a `TypertRemoteInfo`, extending the mountable `TypertRemoteContribution`. Its typed `methods[endpoint]` entries are the same direct descriptor objects found in `descriptors`, with derived `input(options?)` and `invoke(remote, input, signal?)`. Parameter documentation lives in `parameters[].description`; `result` is the single result codec, parser, and optional projection. Unary descriptors omit `mode`; streams use `'stream'`. Context-only receivers remain in `descriptors` and scoped declarations, not `methods`. -`createTypertRemoteInfo(spec)` from the published pure `@deepseek-ai/dsh-typert-protocol/remote-info` entry returns a class instance, not a constructor. Construction retains only the static spec; the first `descriptors` or `methods` read builds and caches their shared index. Each input-policy view, its parser, and its optional JSON Schema projection are independently lazy and cached; `method.input().create()` requests the ordinary parser. The raw spec requires Zod factories, while consumer-facing `TypertSchema` promises only `parse(value: unknown): T`. JSON Schema comes from generator projections, not runtime `z.toJSONSchema()`. Metadata and schema access never invokes the network; the root also re-exports the factory but imports Cordis. +`createTypertRemoteInfo(spec, z)` from the published pure `@deepseek-ai/dsh-typert-protocol/remote-info` entry returns a class instance, not a constructor. Construction retains only the static spec and the caller-supplied Zod runtime; the first `descriptors` or `methods` read builds and caches their shared index. Each input-policy view, its parser, and its optional JSON Schema projection are independently lazy and cached; `method.input().create()` requests the ordinary parser. The raw spec requires Zod factories, while consumer-facing `TypertSchema` promises only `parse(value: unknown): T`. JSON Schema comes from generator projections, not runtime `z.toJSONSchema()`. The leaf has no runtime imports; generated artifacts supply Zod. Metadata and schema access never invokes the network; the root also re-exports the factory but imports Cordis. The invoker takes a caller-owned, mounted, unscoped Client Remote and resolves its namespace method at call time. It preserves the namespace receiver, optional positional holes, and signal, forwarding the same `Promise>` or complete `RemoteStreamHandle` without an async wrapper. `DecodedOutput` defaults to the authored output; generation supplies `RemoteDecoded` only for byte-bearing unary methods, avoiding recursive remapping of ordinary JSON results; `result.create().parse()` retains the authored output type and does not normalize shared byte buffers. Documentation and parsing remain available when either input variant or the result lacks JSON Schema support; each variant's `jsonSchemaError` explains its absent projection. Registry readers use `isTypertRemoteMethod(descriptor)` from the same pure entry to narrow a base descriptor to these direct capabilities. diff --git a/packages/typert/protocol/README.zh.md b/packages/typert/protocol/README.zh.md index 1f672d300c..af4a7e7470 100644 --- a/packages/typert/protocol/README.zh.md +++ b/packages/typert/protocol/README.zh.md @@ -52,7 +52,7 @@ export class GoalService extends TypertRemoteService { 普通 `./remote` 产物将 `TYPERT_REMOTE` 导出为 `TypertRemoteInfo`,它扩展可挂载的 `TypertRemoteContribution`。类型化的 `methods[endpoint]` 条目就是 `descriptors` 中同一个直接方法描述符对象,并带有派生的 `input(options?)` 和 `invoke(remote, input, signal?)`。参数文档位于 `parameters[].description`;`result` 是唯一的结果 codec、解析器和可选投影。一元描述符省略 `mode`;流使用 `'stream'`。仅使用 Context 接收器的方法保留在 `descriptors` 和作用域声明中,不进入 `methods`。 -已发布的纯入口 `@deepseek-ai/dsh-typert-protocol/remote-info` 提供 `createTypertRemoteInfo(spec)`,返回类实例而非构造函数。构造时只保留静态 spec;首次读取 `descriptors` 或 `methods` 时才构建并缓存两者共享的索引。每种输入策略的视图、解析器及可选 JSON Schema 投影都独立惰性求值并缓存;`method.input().create()` 请求普通解析器。原始 spec 要求 Zod factory,而面向消费方的 `TypertSchema` 仅保证 `parse(value: unknown): T`。JSON Schema 来自生成器投影,而非运行时 `z.toJSONSchema()`。读取元数据和 schema 绝不发起网络调用;根入口也重新导出该 factory,但会导入 Cordis。 +已发布的纯入口 `@deepseek-ai/dsh-typert-protocol/remote-info` 提供 `createTypertRemoteInfo(spec, z)`,返回类实例而非构造函数。构造时只保留静态 spec 和调用方传入的 Zod 运行时;首次读取 `descriptors` 或 `methods` 时才构建并缓存两者共享的索引。每种输入策略的视图、解析器及可选 JSON Schema 投影都独立惰性求值并缓存;`method.input().create()` 请求普通解析器。原始 spec 要求 Zod factory,而面向消费方的 `TypertSchema` 仅保证 `parse(value: unknown): T`。JSON Schema 来自生成器投影,而非运行时 `z.toJSONSchema()`。纯叶入口没有运行时导入;Zod 由生成产物提供。读取元数据和 schema 绝不发起网络调用;根入口也重新导出该 factory,但会导入 Cordis。 调用器接收调用方拥有、已挂载且无作用域的 Client Remote,并在调用时解析其命名空间方法。它保留命名空间接收者、可选位置参数空位和 signal,不使用 async 包装,直接转发同一个 `Promise>` 或完整 `RemoteStreamHandle`。`DecodedOutput` 默认采用原始输出类型;生成器仅为含字节的一元方法指定 `RemoteDecoded`,避免递归重映射普通 JSON 结果;`result.create().parse()` 保留原始输出类型,不会规范化共享字节缓冲区。任一输入变体或结果不支持 JSON Schema 时,文档与解析能力仍然保留;各变体的 `jsonSchemaError` 说明其投影缺失的原因。Registry 读取方使用同一纯入口提供的 `isTypertRemoteMethod(descriptor)`,将基础描述符收窄为这些直接调用能力。 diff --git a/packages/typert/protocol/src/remote-info.ts b/packages/typert/protocol/src/remote-info.ts index 71209f2a7d..f646c263d6 100644 --- a/packages/typert/protocol/src/remote-info.ts +++ b/packages/typert/protocol/src/remote-info.ts @@ -1,6 +1,6 @@ -/** Lazy assembly of generated Remote definitions; imports no Service, Registry, or transport. */ +/** Lazy assembly of generated Remote definitions using caller-supplied Zod; no runtime imports. */ -import { z } from 'zod' +import type { z } from 'zod' import type { InvocationDescriptor, TypertClientRemote, @@ -22,16 +22,17 @@ type Index = Pick * The class instance builds its shared descriptor/method index on first access; * input schemas and JSON Schema projections are cached only when requested. * @param spec - compiler-produced method facts and Zod factories. + * @param zod - the generated artifact's Zod runtime for named-input parsers. * @returns a lazy, mountable info instance with no registration or transport effects. */ -export function createTypertRemoteInfo(spec: TypertRemoteSpec): TypertRemoteInfo { - return new RemoteInfo(spec) +export function createTypertRemoteInfo(spec: TypertRemoteSpec, zod: typeof z): TypertRemoteInfo { + return new RemoteInfo(spec, zod) } class RemoteInfo implements TypertRemoteInfo { private index?: Index - constructor(private readonly spec: TypertRemoteSpec) {} + constructor(private readonly spec: TypertRemoteSpec, private readonly zod: typeof z) {} get package(): string { return this.spec.package } get descriptors(): readonly InvocationDescriptor[] { return this.resolve().descriptors } @@ -39,6 +40,7 @@ class RemoteInfo implements TypertRemoteInfo { private resolve(): Index { if (this.index !== undefined) return this.index + const zod = this.zod const descriptors: InvocationDescriptor[] = [] const methods = Object.create(null) as Record for (const [endpoint, definition] of Object.entries(this.spec.methods)) { @@ -59,7 +61,7 @@ class RemoteInfo implements TypertRemoteInfo { const direct = Object.assign(descriptor, { input(options?: TypertRemoteInputOptions) { const strict = options?.strict === true - return inputs[strict ? 1 : 0] ??= new NamedInput(parameters, strict) + return inputs[strict ? 1 : 0] ??= new NamedInput(parameters, strict, zod) }, invoke(remote: TypertClientRemote, input: object, signal?: AbortSignal) { const named = input as Readonly> @@ -99,13 +101,17 @@ class NamedInput implements TypertRemoteValue { private schema?: z.ZodType private projection?: Pick - constructor(private readonly parameters: readonly Parameter[], private readonly strict: boolean) {} + constructor( + private readonly parameters: readonly Parameter[], + private readonly strict: boolean, + private readonly zod: typeof z, + ) {} private codec(parameter: Parameter): TypertRemoteCodecSpec { return this.strict ? parameter.strictCodec : parameter.codec } - readonly create = (): z.ZodType => this.schema ??= z.strictObject(Object.fromEntries(this.parameters.map((parameter) => { + readonly create = (): z.ZodType => this.schema ??= this.zod.strictObject(Object.fromEntries(this.parameters.map((parameter) => { const value = this.codec(parameter).create() return [parameter.wire, optional(parameter) ? value.optional() : value.nonoptional()] }))) diff --git a/packages/typert/protocol/tests/built-runtime.e2e.ts b/packages/typert/protocol/tests/built-runtime.e2e.ts new file mode 100644 index 0000000000..cb276e2764 --- /dev/null +++ b/packages/typert/protocol/tests/built-runtime.e2e.ts @@ -0,0 +1,10 @@ +import { readFile } from 'node:fs/promises' +import { expect, it } from 'vitest' + +it.each(['../lib/index.js', '../lib/types/remote-info.js'])( + 'keeps the published protocol entry %s free of Zod runtime imports', + async (entry) => { + const source = await readFile(new URL(entry, import.meta.url), 'utf8') + expect(source).not.toMatch(/\b(?:from|import|require)\s*(?:\(\s*)?['"]zod(?:\/[^'"]*)?['"]/) + }, +) diff --git a/packages/typert/protocol/tests/remote-info.spec.ts b/packages/typert/protocol/tests/remote-info.spec.ts index 4a1dfac112..992d121aaf 100644 --- a/packages/typert/protocol/tests/remote-info.spec.ts +++ b/packages/typert/protocol/tests/remote-info.spec.ts @@ -30,7 +30,9 @@ describe('lazy Remote info', () => { package: '@fixture/goals', get methods() { inventoryReads++; return { 'goals/create': definition({ parameters: [{ name: 'request', source: 'json', codec: input, strictCodec: input }], result: output }) } }, } - const info = createTypertRemoteInfo(spec) + const runtime = { ...z } + const strictObject = vi.spyOn(runtime, 'strictObject') + const info = createTypertRemoteInfo(spec, runtime) expect(Object.getPrototypeOf(info)).not.toBe(Object.prototype) expect(info.package).toBe('@fixture/goals') expect(inventoryReads).toBe(0) @@ -44,13 +46,17 @@ describe('lazy Remote info', () => { expect(inventoryReads).toBe(1) expect(inputProjectionReads + outputProjectionReads).toBe(0) expect(create).not.toHaveBeenCalled() + expect(strictObject).not.toHaveBeenCalled() expect(method).not.toHaveProperty('metadata') expect(method).not.toHaveProperty('output') expect(method.mode).toBeUndefined() expect(isTypertRemoteMethod(method)).toBe(true) - const parser = method.input().create() + const inputView = method.input() + expect(strictObject).not.toHaveBeenCalled() + const parser = inputView.create() expect(method.input().create()).toBe(parser) + expect(strictObject).toHaveBeenCalledTimes(1) expect(create).toHaveBeenCalledTimes(1) expect(parser.parse({ request: 'ship' })).toEqual({ request: 'ship' }) expect(inputProjectionReads + outputProjectionReads).toBe(0) @@ -75,7 +81,7 @@ describe('lazy Remote info', () => { const strictCodec = { ...codec, create: createStrict, jsonSchema: { ...codec.jsonSchema, additionalProperties: false } } const info = createTypertRemoteInfo({ package: '@fixture/goals', methods: { 'goals/create': definition({ parameters: [{ name: 'request', source: 'json', codec, strictCodec }] }), - } }) + } }, z) const method = info.methods['goals/create']! const normal = method.input() const strict = method.input({ strict: true }) @@ -110,7 +116,7 @@ describe('lazy Remote info', () => { cancellation: { parameter: 'signal' }, scope: { context: 'agent', wire: 'agentId' }, }), - } }) + } }, z) const method = info.methods['goals/create']! const parser = method.input().create() expect(parser.parse({ agentId: 'agent' })).toEqual({ agentId: 'agent' }) @@ -139,7 +145,7 @@ describe('lazy Remote info', () => { const info = createTypertRemoteInfo({ package: '@fixture/goals', methods: { 'goals/required': definition({ parameters: [{ name: 'value', source: 'json', codec: unknown, strictCodec: unknown }] }), 'goals/empty': definition({ parameters: [] }), - } }) + } }, z) expect(() => info.methods['goals/required']!.input().create().parse({})).toThrow() const empty = info.methods['goals/empty']! expect(empty.input().create().parse({})).toEqual({}) @@ -170,7 +176,7 @@ describe('lazy Remote info', () => { it('keeps Context-only descriptors without inventing a named direct caller', () => { const info = createTypertRemoteInfo({ package: '@fixture/goals', methods: { 'goals/rename': definition({ invocation: { kind: 'context', context: 'agent', wire: 'agentId', codec: text }, implementation: 'renameCurrent' }), - } }) + } }, z) expect(Object.keys(info.methods)).toEqual([]) expect(info.descriptors[0]).toMatchObject({ id: '@fixture/goals#goals/rename', namespace: 'goals', method: 'rename', implementation: 'renameCurrent' }) expect(info.descriptors[0]).not.toHaveProperty('input') @@ -181,7 +187,7 @@ describe('lazy Remote info', () => { const uplink = { codec: text } const info = createTypertRemoteInfo({ package: '@fixture/goals', methods: { 'goals/watch': definition({ mode: 'stream', parameters: [], uplink }), - } }) + } }, z) const handle = { async *[Symbol.asyncIterator]() { yield 'frame' }, send: vi.fn(), end: vi.fn(), dispose: vi.fn() } const namespace = { watch: () => handle } const remote: object = { goals: namespace } @@ -202,7 +208,7 @@ describe('lazy Remote info', () => { parameters: [{ name: 'request', source: 'json', description: 'Still documented.', codec: unsupported, strictCodec: unsupported }], result: { ...unsupported, description: 'Still a result.', decode, encode }, }), - } }) + } }, z) const method = info.methods['goals/create']! expect(method.input().jsonSchema).toBeUndefined() expect(method.input().jsonSchemaError).toBe('Unsupported tool type.') @@ -217,7 +223,7 @@ describe('lazy Remote info', () => { expect(() => method.input().create()).toThrow('invalid schema factory') const unprojected = createTypertRemoteInfo({ package: '@fixture/goals', methods: { 'goals/create': definition({ parameters: [{ name: 'request', source: 'json', codec: { ...text, jsonSchema: undefined }, strictCodec: text }] }), - } }).methods['goals/create']! + } }, z).methods['goals/create']! expect(unprojected.input().jsonSchemaError).toContain('request') expect(unprojected.input().create().parse({ request: 'valid' })).toEqual({ request: 'valid' }) }) diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index e2d9cd7543..f1ca217e7a 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -108,7 +108,7 @@ describe('TypertRegistry', () => { service: 'goals', invocation: { kind: 'direct' }, description: 'Create a goal.', parameters: [{ name: 'request', source: 'json', codec, strictCodec: codec }], result: codec, }, - } }) + } }, z) const descriptor = info.methods['goals/create']! expect(info.descriptors[0]).toBe(descriptor) try { From b769d1ce7a4dad4f766207ed23ee57387be078d7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:39:53 +0800 Subject: [PATCH 11/17] docs(typert): correct Remote metadata references --- .../2026-09-20-unified-typert-remote-metadata.i18n.yaml | 4 ++-- .../architecture/2026-09-20-unified-typert-remote-metadata.md | 2 +- .../2026-09-20-unified-typert-remote-metadata.zh.md | 2 +- .../2026-10-05-working-directory-attribution.i18n.yaml | 4 ++-- .../2026-10-05-working-directory-attribution.md | 2 +- .../2026-10-05-working-directory-attribution.zh.md | 2 +- docs/subsystems/typert.i18n.yaml | 4 ++-- docs/subsystems/typert.md | 2 +- docs/subsystems/typert.zh.md | 2 +- packages/typert/protocol/src/index.ts | 4 ++-- 10 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.i18n.yaml index c9c690211e..e780a00501 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.i18n.yaml @@ -27,8 +27,8 @@ en: 151b1f66af030bc5 zh: 44fea0898acfbdcb /agent-note-unified-typert-remote-metadata/acceptance-criteria: - en: 875ea831cf90f310 - zh: 67f57449a55fe8ec + en: d011ec1516706b20 + zh: 945c6e3eba101368 /agent-note-unified-typert-remote-metadata/risks: en: a770478eac8f5fd1 zh: f24aa378f875f47f diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md index 25154bcb8a..c99fd9effd 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md +++ b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md @@ -180,7 +180,7 @@ pnpm dsh --profile - Ordinary artifacts emit one static method spec per endpoint and return lazy class instances; direct method indexes alias descriptor objects, with full stream/binary typing, separate compiler faces, and full-face augmentation retention. - Descriptor indexes and each input-policy view, parser, and projection are independently lazy and cached; Loader validates static specs and callable helpers without calling selectors, parsers, or projections. - Every input parameter emits normal and strict codecs from the same model; consumers select policy without a method annotation. Both envelopes reject extra named arguments; strict input recursively closes declared objects without closing JSON or dictionary keys or changing Host dispatch and results. -- Protocol source and built entries have no Zod runtime dependency; generated artifacts explicitly supply Zod, zero-argument endpoints create empty inputs that reject extra fields, and the production-profile Loader regression asserts actual Session events. +- Protocol source and built entries have no Zod runtime dependency; generated artifacts explicitly supply Zod, zero-argument endpoints create empty inputs that reject extra fields, and the [production headless-profile Loader regression](<../../../../packages/context/time-context/tests/time-context.e2e.ts>) asserts actual Session events. - Registry identity, atomic registration, withdrawal, and disposal remain intact. - Missing documentation or unsupported optional projection does not disable generation; selected tools reject missing requirements and still parse outputs. - Strict source/artifact invocation and limited fallback have separate tests; a withdrawn strict descriptor never enables fallback. diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md index 1b09c77379..9370060e1e 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md +++ b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md @@ -180,7 +180,7 @@ pnpm dsh --profile - 普通产物为每个端点生成一份静态方法 spec,并返回惰性类实例;直接方法索引引用描述符对象,保留完整流式/二进制类型、独立编译面及完整编译面的声明合并。 - 描述符索引与每种输入策略的视图、解析器和投影都独立惰性求值并缓存;Loader 校验静态 spec 及可调用辅助能力,不调用选择器、解析器或投影。 - 每个输入参数从同一模型生成普通与严格 codec;消费方无需方法注解即可选择策略。两种封装都拒绝额外具名参数;严格输入递归关闭已声明对象,但不关闭 JSON 或字典键,也不改变 Host 分发和结果。 -- 协议源码与构建入口没有 Zod 运行时依赖;生成产物显式提供 Zod,无参端点也能创建拒绝额外字段的空输入对象;生产 profile 的 Loader 回归保留真实 Session 事件断言。 +- 协议源码与构建入口没有 Zod 运行时依赖;生成产物显式提供 Zod,无参端点也能创建拒绝额外字段的空输入对象;[生产 headless profile 的 Loader 回归](<../../../../packages/context/time-context/tests/time-context.e2e.ts>)断言真实 Session 事件。 - Registry 身份、原子注册、撤销和释放保持不变。 - 缺少文档或不支持可选投影不阻止生成;选中的工具拒绝缺少必需信息的条目,并仍然解析输出。 - 严格源码/产物调用与有限 fallback 分别测试;撤销严格 descriptor 绝不启用 fallback。 diff --git a/docs/persistence-changes/2026-10-05-working-directory-attribution.i18n.yaml b/docs/persistence-changes/2026-10-05-working-directory-attribution.i18n.yaml index 1ae5d09e4f..080248eabe 100644 --- a/docs/persistence-changes/2026-10-05-working-directory-attribution.i18n.yaml +++ b/docs/persistence-changes/2026-10-05-working-directory-attribution.i18n.yaml @@ -21,8 +21,8 @@ en: 39494aa3af994e6f zh: 07c2880503668176 /2026-10-05-working-directory-attribution/verification: - en: 5963942c0d87e518 - zh: 5dbb6ac0fca1f279 + en: 9b79379292881d10 + zh: 54d670c09ce1bfc7 /2026-10-05-working-directory-attribution/dev-note: en: b75c8458928b72b9 zh: 003ba4e17b4b5269 diff --git a/docs/persistence-changes/2026-10-05-working-directory-attribution.md b/docs/persistence-changes/2026-10-05-working-directory-attribution.md index 80996eee5c..6496413c37 100644 --- a/docs/persistence-changes/2026-10-05-working-directory-attribution.md +++ b/docs/persistence-changes/2026-10-05-working-directory-attribution.md @@ -52,7 +52,7 @@ Existing records remain valid. The additive working-directory source kind is att ## Verification -pnpm exec vitest run packages/session/working-directory/tests packages/shell/tool-bash/tests/tools.spec.ts packages/shell/tool-pwsh/tests/tools.spec.ts packages/subagent/subagent-in-process-driver/tests/schedule-tools.spec.ts: 4 files and 203 tests passed. +pnpm exec vitest run packages/session/working-directory/tests packages/shell/tool-bash/tests/tools.spec.ts packages/shell/tool-pwsh/tests/tools.spec.ts packages/subagent/subagent/tests/schedule-tools.spec.ts: 5 files and 207 tests passed. ## Dev Note diff --git a/docs/persistence-changes/2026-10-05-working-directory-attribution.zh.md b/docs/persistence-changes/2026-10-05-working-directory-attribution.zh.md index e7ed00e08f..56157cc7d2 100644 --- a/docs/persistence-changes/2026-10-05-working-directory-attribution.zh.md +++ b/docs/persistence-changes/2026-10-05-working-directory-attribution.zh.md @@ -52,7 +52,7 @@ changes: ## 验证 -pnpm exec vitest run packages/session/working-directory/tests packages/shell/tool-bash/tests/tools.spec.ts packages/shell/tool-pwsh/tests/tools.spec.ts packages/subagent/subagent-in-process-driver/tests/schedule-tools.spec.ts:4 个文件、203 个测试通过。 +pnpm exec vitest run packages/session/working-directory/tests packages/shell/tool-bash/tests/tools.spec.ts packages/shell/tool-pwsh/tests/tools.spec.ts packages/subagent/subagent/tests/schedule-tools.spec.ts:5 个文件、207 个测试通过。 ## 开发备注 diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index bb46fc7d0b..0030b50111 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.i18n.yaml @@ -18,5 +18,5 @@ en: 90fab88fe51d17bb zh: 2d4c3849cf2dc7d3 /typert-remote-calls/consumer-remote: - en: 37f48bfcc9e3e0d1 - zh: 45509a69a67143ad + en: 324386c600a5fdf5 + zh: 7963501eb663e228 diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 6bea7bf887..e9cabd714b 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -372,7 +372,7 @@ interface TypertGateway { `ctx.remote` exposes only namespaces contributed by imported `/remote` artifacts. `$mount()` installs generated descriptors and concrete methods as one fiber-owned operation. Each namespace is a traced `remote.` Cordis child Service whose lifetime spans its mounted methods; no JavaScript Proxy or Host business Service type enters the consumer. -Metadata ownership is separate from connection ownership; the [registry reference](<../../packages/typert/registry/README.md>) covers offline registration and mount lifetimes. Importing, registering, listing, or calling Remote methods does not acquire interaction-waterfall ownership. Gateway Clients remain passive unless the connection opts into Host events with `forwardedEvents: true`; the Web bundle publishes that choice to served pages through the Host Gateway row. +Metadata ownership is separate from connection ownership; the [registry reference](<../../packages/typert/registry/README.md>) covers offline registration and mount lifetimes. ```ts type-equiv /** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */ diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index 72cba0f04c..287e81a5fa 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -372,7 +372,7 @@ interface TypertGateway { `ctx.remote` 只暴露由已导入 `/remote` 产物贡献的 namespace。`$mount()` 会把生成的 descriptor 与具体方法作为一项由 fiber 持有的操作统一注册。每个 namespace 都是可追踪的 `remote.` Cordis 子服务,其生命周期覆盖已挂载的方法;JavaScript Proxy 与 Host 业务服务类型都不会进入消费方。 -元数据所有权与连接所有权相互独立;[注册表参考](<../../packages/typert/registry/README.zh.md>)说明离线注册与挂载生命周期。导入、注册、列出或调用 Remote 方法都不会取得交互 waterfall 的所有权。Gateway Client 保持被动,除非连接通过 `forwardedEvents: true` 选择加入 Host 事件;Web bundle 通过 Host Gateway 行把这一选择发布给已服务的页面。 +元数据所有权与连接所有权相互独立;[注册表参考](<../../packages/typert/registry/README.zh.md>)说明离线注册与挂载生命周期。 ```ts type-equiv /** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */ diff --git a/packages/typert/protocol/src/index.ts b/packages/typert/protocol/src/index.ts index 22d5036650..cac28eace5 100644 --- a/packages/typert/protocol/src/index.ts +++ b/packages/typert/protocol/src/index.ts @@ -219,8 +219,8 @@ export function Remote( context: ClassMethodDecoratorContext Result>, ): void /** - * Mark one method with an exported name, stream delivery, or closed generated request objects. - * @param option - endpoint name or explicit Remote options. + * Mark one public instance method with an endpoint name or stream delivery. + * @param option - endpoint name or Remote options for name and mode. * @returns a standard method decorator. */ export function Remote(option: string | RemoteMethodOptions): RemoteMethodDecorator From c36c077c5d27f39a676cc8aad457ef7f9f0deb2c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:06:37 +0800 Subject: [PATCH 12/17] docs: correct Gateway Config source reference after rebase --- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b4dcc3c429..d04b96eaa4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -182,7 +182,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-gateway` - `inject`: `typert` -- `source`: [`packages/api/gateway/src/index.ts:146`](../packages/api/gateway/src/index.ts) +- `source`: [`packages/api/gateway/src/index.ts:147`](../packages/api/gateway/src/index.ts) ```ts config-catalog /** Gateway transport configuration. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index a16e5d1f1d..e9586906a6 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -184,7 +184,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-gateway` - `inject`: `typert` -- `source`: [`packages/api/gateway/src/index.ts:146`](../packages/api/gateway/src/index.ts) +- `source`: [`packages/api/gateway/src/index.ts:147`](../packages/api/gateway/src/index.ts) ```ts config-catalog /** Gateway transport configuration. */ From 557e4137bfa6559ee2c302998ea3247037d7ab04 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:10:54 +0800 Subject: [PATCH 13/17] fix(typert): leave Node client bundling to operator layer --- packages/typert/registry/README.i18n.yaml | 8 ++++---- packages/typert/registry/README.md | 4 +--- packages/typert/registry/README.zh.md | 4 +--- packages/typert/registry/package.json | 1 - 4 files changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/typert/registry/README.i18n.yaml b/packages/typert/registry/README.i18n.yaml index 76539a521a..22ef5546f7 100644 --- a/packages/typert/registry/README.i18n.yaml +++ b/packages/typert/registry/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-typert-registry/use-this-package: - en: 688f3656b0f62771 - zh: 223d00ab0dc9fe20 + en: d885ccd8f13315f1 + zh: cea7c258f5f57e2d /deepseek-ai-dsh-typert-registry/use-this-package/minimal-setup: en: 1ec4b304a024bb3c zh: f17e796c26faabab @@ -24,8 +24,8 @@ en: 254d265a3e5b1638 zh: bfa325ec79afbc72 /deepseek-ai-dsh-typert-registry/use-this-package/registering-a-contribution: - en: 9ca425515904d5c9 - zh: b251226ecfa32002 + en: 2b2f750a1b145d85 + zh: d15c55c01af1755a /deepseek-ai-dsh-typert-registry/use-this-package/lookup-and-context-providers: en: bc9ea8f035ec1199 zh: d25f1dc16cfc8654 diff --git a/packages/typert/registry/README.md b/packages/typert/registry/README.md index 613891b6b1..f7c654dc42 100644 --- a/packages/typert/registry/README.md +++ b/packages/typert/registry/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -Mount the registry in any Host or Client composition that stores or consumes generated Typert artifacts; it provides `ctx.typert`. There is no configuration. Node resolves `/client` to ordinary ESM for programmatic Cordis composition; browsers retain the Client bundle, and both use the same registry implementation. +Mount the registry in any Host or Client composition that stores or consumes generated Typert artifacts; it provides `ctx.typert`. There is no configuration. ### Minimal setup @@ -45,8 +45,6 @@ Invocation information is available through existing `ctx.typert.local.get/list` Generated artifacts register through the [loader](../loader/README.md) in Loader compositions; any other owner calls `ctx.typert.register(contribution)` directly and receives the exact disposer that withdraws it. Duplicate package-face identities, schema keys, invocation ids, or endpoints reject the whole batch before anything is committed. -Harness offline integrations register an ordinary `TYPERT_REMOTE` contribution with `ctx.typert.remotes.register(contribution)` in their profile Context. `$mount(contribution)` already performs that registration, so registering and mounting the same contribution in the same registry duplicates it and fails. Keep offline metadata ownership separate from the live connection-and-mount Context. Independent offline consumers may instead read `TYPERT_REMOTE.methods` without any Registry. Registry state belongs to its Context and process, is not shared across processes, and neither registration nor querying opens a connection. - ### Lookup and Context providers Remote calls resolve Host objects and scoped Contexts through `ctx.typert.lookups` and `ctx.typert.contexts`. `registerHost()` installs the Host wire declaration and its wire-to-Context resolver, while `configureHost()` replaces only that resolver. `registerClient()` installs the bidirectional Client adapter for the same merge-declared kind. Host-to-Client event sources carry their domain identity explicitly instead of projecting it back from a Context. diff --git a/packages/typert/registry/README.zh.md b/packages/typert/registry/README.zh.md index 3bc30c0e49..6d117d9fa5 100644 --- a/packages/typert/registry/README.zh.md +++ b/packages/typert/registry/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -在存储或消费生成 Typert 产物的任何 Host 或 Client 组合中挂载本注册表;它提供 `ctx.typert`。没有配置。Node 将 `/client` 解析为普通 ESM,供程序化 Cordis 组合使用;浏览器仍使用 Client bundle,两者使用相同的注册表实现。 +在存储或消费生成 Typert 产物的任何 Host 或 Client 组合中挂载本注册表;它提供 `ctx.typert`。没有配置。 ### 最小设置 @@ -45,8 +45,6 @@ kind: "package-reference" 生成产物在 Loader 组合中通过 [loader](../loader/README.zh.md) 注册;其他所有者直接调用 `ctx.typert.register(contribution)`,并获得撤销它的同一资源释放函数。重复的包与 face 组合键、schema 键、调用 id 或端点会在提交任何内容之前拒绝整个批次。 -Harness 离线集成在各自的 profile Context 中通过 `ctx.typert.remotes.register(contribution)` 注册普通 `TYPERT_REMOTE` 贡献。`$mount(contribution)` 已执行该注册,因此在同一个注册表中先注册再挂载同一贡献会因重复而失败。离线元数据所有权应与建立实际连接及挂载的 Context 分离。独立离线消费方也可以直接读取 `TYPERT_REMOTE.methods`,无需任何 Registry。注册表状态归所属 Context 与进程所有,不跨进程共享;注册和查询都不会打开连接。 - ### Lookup 与 Context 提供方 Remote 调用通过 `ctx.typert.lookups` 与 `ctx.typert.contexts` 解析 Host 对象与作用域 Context。`registerHost()` 安装 Host wire 声明及其 wire 到 Context 的 resolver,`configureHost()` 只替换该 resolver。`registerClient()` 为同一个 merge-declared kind 安装双向 Client adapter。Host 到 Client 的事件源显式携带领域 identity,而不从 Context 反向投影。 diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index 986c16a894..a5f4e98079 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -20,7 +20,6 @@ }, "./client": { "types": "./lib/types/client/index.d.ts", - "node": "./lib/types/client/index.js", "default": "./lib/client.js" }, "./types": { From 2e56caee963b6f7d2b1c4490e66ee97a993f1554 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:46:05 +0800 Subject: [PATCH 14/17] fix(typert): enforce strict named dictionary index constraints --- packages/typert/generator/README.md | 2 +- packages/typert/generator/README.zh.md | 2 +- packages/typert/generator/src/emitter.ts | 60 +++++++++++++---- .../generator/tests/remote-metadata.spec.ts | 67 +++++++++++++++++++ .../generator/tests/remote-model.spec.ts | 7 -- .../typert/protocol/tests/protocol.spec.ts | 4 -- 6 files changed, 117 insertions(+), 25 deletions(-) diff --git a/packages/typert/generator/README.md b/packages/typert/generator/README.md index fea69f0dac..ccfe034935 100644 --- a/packages/typert/generator/README.md +++ b/packages/typert/generator/README.md @@ -54,7 +54,7 @@ Generated declarations use type-only imports for owner and schema types. Importi Missing JSDoc and unsupported tool JSON Schema do not block otherwise valid ordinary generation or its strict codecs. Metadata retains parsers and available documentation independently of projection: each unsupported input variant or output projection omits `jsonSchema` and records its own `jsonSchemaError`. A tool consumer explicitly selects methods and requires its documentation and supported input schema; an output parser is required, but output JSON Schema is optional unless that consumer requires it. Metadata alone does not make every Remote method a tool. -Every generated parameter spec contains both `codec` and `strictCodec`, derived from the same checker-resolved type model; the analyzer and model carry no per-method input policy. `method.input()` and `{ strict: false }` select the normal codec used by Host dispatch, whose declared objects accept and strip unknown fields. `method.input({ strict: true })` selects recursively closed declared objects for both parsing and supported JSON Schema. Both named-argument envelopes reject extra arguments; explicit JSON and dictionary keys remain open, while constrained dictionary values still validate. `result` keeps its single normal codec. Tool projection targets the tool-schema subset that [`@deepseek-ai/dsh-tools`](../../core/tools/src/json-schema.ts) enforces on tool parameters, tool output, and the generated PTC SDK; it supports scalars, objects, arrays, and disjoint unions, while canonical `JsonValue` and string-keyed JSON dictionaries need no recursive references. A type outside that subset is reported instead of projected more weakly. Numeric bounds and other semantic rules absent from TypeScript remain with the owning operation. +Every generated parameter spec contains both `codec` and `strictCodec`, derived from the same checker-resolved type model; the analyzer and model carry no per-method input policy. `method.input()` and `{ strict: false }` select the normal codec used by Host dispatch, whose declared objects accept and strip unknown fields. `method.input({ strict: true })` selects recursively closed declared objects for both parsing and supported JSON Schema. Both named-argument envelopes reject extra arguments; explicit JSON and dictionary keys remain open, while constrained dictionary values also validate named fields against their index value type. Strict parsing closes the combined nested fields of a named dictionary value, not each constraint separately. `result` keeps its single normal codec. Tool projection targets the tool-schema subset that [`@deepseek-ai/dsh-tools`](../../core/tools/src/json-schema.ts) enforces on tool parameters, tool output, and the generated PTC SDK; it supports scalars, objects, arrays, and disjoint unions, while canonical `JsonValue` and string-keyed JSON dictionaries need no recursive references. A type outside that subset is reported instead of projected more weakly. Numeric bounds and other semantic rules absent from TypeScript remain with the owning operation. `WorkspaceTypertGenerator` analyzes all contributors of each selected face before filtering emitted packages, so a package subset retains the same declaration merges and output bytes as a complete build. diff --git a/packages/typert/generator/README.zh.md b/packages/typert/generator/README.zh.md index 93ae06fc5a..cf52b099db 100644 --- a/packages/typert/generator/README.zh.md +++ b/packages/typert/generator/README.zh.md @@ -54,7 +54,7 @@ factory 返回类实例,并保留 spec,直到 `descriptors` 或 `methods` ge 缺少 JSDoc 或不支持工具 JSON Schema,不会阻止其他方面有效的普通生成及其严格 codec。元数据独立于投影保留解析器与可用文档:每个不受支持的输入变体或输出投影都省略 `jsonSchema`,并记录各自的 `jsonSchemaError`。工具消费方显式选择方法,并要求其所需文档和受支持的输入 schema;输出解析器是必需的,但输出 JSON Schema 是可选的,除非该消费方要求提供。仅有元数据不会让每个 Remote 方法自动成为工具。 -每个生成参数 spec 都包含从同一个 checker 解析后类型模型派生的 `codec` 和 `strictCodec`;分析器与模型不携带逐方法输入策略。`method.input()` 与 `{ strict: false }` 选择 Host 分发使用的普通 codec,其已声明对象接受并剥离未知字段。`method.input({ strict: true })` 为解析和受支持的 JSON Schema 选择递归关闭的已声明对象。两种具名参数封装都拒绝额外参数;显式 JSON 与字典键保持开放,有约束的字典值仍然校验。`result` 保留唯一的普通 codec。工具投影的目标是 [`@deepseek-ai/dsh-tools`](../../core/tools/src/json-schema.ts) 对工具参数、工具输出和生成的 PTC SDK 强制执行的 tool-schema 子集;它支持标量、对象、数组和互斥联合类型,规范的 `JsonValue` 与字符串键 JSON 字典无需递归引用。子集之外的类型会被报告,而不是以更弱的 schema 投影。TypeScript 未表达的数值范围与其他语义规则仍由操作拥有者负责。 +每个生成参数 spec 都包含从同一个 checker 解析后类型模型派生的 `codec` 和 `strictCodec`;分析器与模型不携带逐方法输入策略。`method.input()` 与 `{ strict: false }` 选择 Host 分发使用的普通 codec,其已声明对象接受并剥离未知字段。`method.input({ strict: true })` 为解析和受支持的 JSON Schema 选择递归关闭的已声明对象。两种具名参数封装都拒绝额外参数;显式 JSON 与字典键保持开放,具名字典字段也须满足索引值类型的约束。严格解析会合并具名字典值的嵌套字段后再关闭对象,而不是分别关闭每项约束。`result` 保留唯一的普通 codec。工具投影的目标是 [`@deepseek-ai/dsh-tools`](../../core/tools/src/json-schema.ts) 对工具参数、工具输出和生成的 PTC SDK 强制执行的 tool-schema 子集;它支持标量、对象、数组和互斥联合类型,规范的 `JsonValue` 与字符串键 JSON 字典无需递归引用。子集之外的类型会被报告,而不是以更弱的 schema 投影。TypeScript 未表达的数值范围与其他语义规则仍由操作拥有者负责。 `WorkspaceTypertGenerator` 先分析每个选中 face 的所有贡献方,再筛选要生成产物的包,因此包子集保留与完整构建相同的声明合并结果和输出字节。 diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index b620934ee3..fa55bda935 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -937,9 +937,10 @@ class SchemaEmitter { subject: string, substitutions: ReadonlyMap, ): string { - const properties: string[] = [] + const properties: Extract[] = [] const indices: string[] = [] let stringIndexValue: string | undefined + let stringIndexType: TypeNodeId | undefined let symbolMembers = 0 for (const member of members) { if (member.static || member.visibility !== 'public') continue @@ -957,32 +958,67 @@ class SchemaEmitter { } const key = this.typeSchema(parameter.type, substitutions) const value = this.typeSchema(member.signature.returns, substitutions) - if (key === 'z.string()') stringIndexValue = value + if (key === 'z.string()') { + stringIndexValue = value + stringIndexType = member.signature.returns + } indices.push(this.readonly(`z.record(${key}, ${value})`, member.readonly)) continue } if (member.kind !== 'property') this.fail(subject, `${member.kind} member ${member.name} is not data-schema projectable`) - // Freezing a union may freeze its populated Uint8Array member. - const schema = this.readonly(this.typeSchema(member.type, substitutions), - member.readonly && !containsBytes(this.renderer, member.type, false)) - const property = this.describe( - this.optional(schema, member.optional), - member, - ) - properties.push(`${quote(member.jsonName ?? member.name)}: ${property}`) + properties.push(member) } if (indices.length > 1) this.fail(subject, 'object type has more than one JSON index signature') // A unique-symbol-only object is a compile-time marker and imposes no JSON shape. if (properties.length === 0 && indices.length === 0 && symbolMembers > 0) return 'z.unknown()' - const object = `z.${this.options.closedObjects && indices.length === 0 ? 'strictObject' : 'object'}({${properties.length === 0 ? '' : `\n${properties.map(property => ` ${property},`).join('\n')}\n`}})` + const fields = properties.map((member) => { + // Freezing a union may freeze its populated Uint8Array member. + const type = this.options.closedObjects && stringIndexType !== undefined + ? this.indexedPropertySchema(member.type, stringIndexType, substitutions) + : this.typeSchema(member.type, substitutions) + const schema = this.readonly(type, + member.readonly && !containsBytes(this.renderer, member.type, false)) + return `${quote(member.jsonName ?? member.name)}: ${this.describe(this.optional(schema, member.optional), member)}` + }) + const object = `z.${this.options.closedObjects && indices.length === 0 ? 'strictObject' : 'object'}({${fields.length === 0 ? '' : `\n${fields.map(field => ` ${field},`).join('\n')}\n`}})` const index = indices[0] if (index === undefined) return object if (properties.length === 0) return index - // Declared properties can narrow index values; only additional keys use the index schema. + // Named keys enforce both their declared type and the index value; the catchall checks other keys. if (this.options.closedObjects && stringIndexValue !== undefined) return `${object}.catchall(${stringIndexValue})` return `z.intersection(${object}, ${index})` } + private indexedPropertySchema( + declared: TypeNodeId, + indexed: TypeNodeId, + substitutions: ReadonlyMap, + ): string { + if (declared === indexed) return this.typeSchema(declared, substitutions) + const own = this.renderer.node(declared) + const value = this.renderer.node(indexed) + const ownProperties = own.kind === 'object' + ? own.members.filter((member): member is Extract => member.kind === 'property') : [] + const valueProperties = value.kind === 'object' + ? value.members.filter((member): member is Extract => member.kind === 'property') : [] + if (own.kind === 'object' && value.kind === 'object' && !own.json && !value.json + && ownProperties.length === own.members.length && valueProperties.length === value.members.length + && [...ownProperties, ...valueProperties].every(member => !member.static + && member.visibility === 'public' && member.computed === undefined)) { + const indexedFields = new Map(valueProperties.map(member => [member.jsonName ?? member.name, member])) + const overlaps = ownProperties.flatMap((member) => { + const inherited = indexedFields.get(member.jsonName ?? member.name) + if (inherited === undefined) return [] + const combined = this.indexedPropertySchema(member.type, inherited.type, substitutions) + const schema = this.readonly(this.optional(combined, member.optional && inherited.optional), + (member.readonly || inherited.readonly) && !containsBytes(this.renderer, member.type, false)) + return [`${quote(member.jsonName ?? member.name)}: ${this.describe(schema, member)}`] + }) + return `z.strictObject({ ...${this.typeSchema(indexed, substitutions)}.shape, ...${this.typeSchema(declared, substitutions)}.shape${overlaps.length === 0 ? '' : `, ${overlaps.join(', ')}`} })` + } + return `z.intersection(${this.typeSchema(declared, substitutions)}, ${this.typeSchema(indexed, substitutions)})` + } + private exportSchemaName(model: SchemaModel): string { const name = this.schemaName(model.symbol) const declaration = this.renderer.declaration(model.symbol) diff --git a/packages/typert/generator/tests/remote-metadata.spec.ts b/packages/typert/generator/tests/remote-metadata.spec.ts index 91fa4fb4a4..6da3429526 100644 --- a/packages/typert/generator/tests/remote-metadata.spec.ts +++ b/packages/typert/generator/tests/remote-metadata.spec.ts @@ -737,6 +737,73 @@ void ready expect(() => method.input({ strict: true }).create().parse(withExtraValue)).toThrow() }) + it('enforces index values on named fields without rejecting their merged nested fields', async () => { + const root = fixture() + editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string', + 'readonly title: string\n readonly payload: Record & { readonly fixed: { readonly own: string } }')) + const [artifact] = new WorkspaceTypertGenerator(root).generate() + const generated = await executable(artifact!.remote!.js) + const method = generated.TYPERT_REMOTE.methods['goals/create']! + const valid = { agentId: 'agent', request: { title: 'ship', payload: { + fixed: { base: 'common', own: 'specific' }, extra: { base: 'value' }, + } } } + const strict = method.input({ strict: true }) + expect(strict.jsonSchema).toBeUndefined() + expect(strict.jsonSchemaError).toContain('selected dictionaries') + for (const view of [method.input(), strict]) { + expect(view.create().parse(valid)).toEqual(valid) + expect(() => view.create().parse({ ...valid, request: { ...valid.request, payload: { + ...valid.request.payload, fixed: { own: 'specific' }, + } } })).toThrow() + } + expect(() => strict.create().parse({ ...valid, request: { ...valid.request, payload: { + ...valid.request.payload, fixed: { base: 'common' }, + } } })).toThrow() + expect(() => strict.create().parse({ ...valid, request: { ...valid.request, payload: { + ...valid.request.payload, fixed: { ...valid.request.payload.fixed, unexpected: true }, + } } })).toThrow() + expect(() => strict.create().parse({ ...valid, request: { ...valid.request, payload: { + ...valid.request.payload, extra: { base: 'value', unexpected: true }, + } } })).toThrow() + expect(() => strict.create().parse({ ...valid, request: { ...valid.request, payload: { + ...valid.request.payload, extra: { own: 'missing base' }, + } } })).toThrow() + const outer = { ...valid, request: { ...valid.request, payload: { ...valid.request.payload, extraOuter: true } } } + expect(() => strict.create().parse(outer)).toThrow() + }) + + it('merges overlapping indexed fields before closing their nested objects', async () => { + const root = fixture() + editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string', + 'readonly title: string\n readonly payload: Record & { readonly fixed: { readonly own: string; readonly nested: { readonly right: string } } }')) + const [artifact] = new WorkspaceTypertGenerator(root).generate() + const generated = await executable(artifact!.remote!.js) + const strict = generated.TYPERT_REMOTE.methods['goals/create']!.input({ strict: true }) + const fixed = { base: 'common', own: 'specific', nested: { left: 'index', right: 'declared' } } + const input = { agentId: 'agent', request: { title: 'ship', payload: { fixed } } } + expect(strict.create().parse(input)).toEqual(input) + for (const nested of [{ right: 'declared' }, { left: 'index' }, { ...fixed.nested, extra: true }]) { + expect(() => strict.create().parse({ ...input, request: { ...input.request, payload: { + fixed: { ...fixed, nested }, + } } })).toThrow() + } + }) + + it('keeps a shared indexed value type valid for a named field', async () => { + const root = fixture() + editFile(root, 'packages/remote/src/types.ts', source => `${source}\ntype Entry = { readonly base: string }\n`) + editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string', + 'readonly title: string\n readonly payload: Record & { readonly fixed: Entry }')) + const [artifact] = new WorkspaceTypertGenerator(root).generate() + const generated = await executable(artifact!.remote!.js) + const strict = generated.TYPERT_REMOTE.methods['goals/create']!.input({ strict: true }) + const input = { agentId: 'agent', request: { title: 'ship', payload: { fixed: { base: 'common' } } } } + expect(strict.create().parse(input)).toEqual(input) + expect(() => strict.create().parse({ ...input, request: { ...input.request, payload: { + fixed: { base: 'common', extra: true }, + } } })).toThrow() + }) + it('keeps normal and closed recursive helper identifiers distinct', async () => { const root = fixture() editFile(root, 'packages/remote/src/types.ts', source => `${source}\nexport interface Branch { readonly value: string; readonly next?: Branch }\nexport interface closed$Branch { readonly value: string; readonly next?: closed$Branch }\n`) diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index f2e156e991..4cd083d6bf 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -553,13 +553,6 @@ export type {`, expect(() => analyzeRemote(root, false)).toThrow(new RegExp(message)) }) - it.each(['committed', 'emitSourceDef', 'rejectUnknownFields'])('rejects the removed %s option', (option) => { - const root = copyFixture() - editFile(root, 'packages/remote/src/index.ts', source => source.replace(' @Remote\n', ` @Remote({ ${option}: true })\n`)) - expect(() => analyzeRemote(root, false)) - .toThrow('Remote() options require name or mode: "stream"') - }) - it('evaluates declaration-merged mapped and conditional boundaries for codecs without widening consumer types', async () => { const root = copyFixture() editFile(root, 'packages/remote/src/types.ts', source => `${source} diff --git a/packages/typert/protocol/tests/protocol.spec.ts b/packages/typert/protocol/tests/protocol.spec.ts index e013aedee7..b163009e66 100644 --- a/packages/typert/protocol/tests/protocol.spec.ts +++ b/packages/typert/protocol/tests/protocol.spec.ts @@ -310,10 +310,6 @@ describe('typert-protocol Remote declarations', () => { const bogusMode: string = 'duplex' expect(() => Remote({ mode: bogusMode } as { mode: 'stream' })) .toThrow('Remote options require name or mode: "stream"') - expect(() => Remote({ rejectUnknownFields: false } as never)).toThrow('Remote options require') - expect(() => Remote({ strict: true } as never)).toThrow('Remote options require') - expect(() => Remote({ committed: true } as never)).toThrow('Remote options require') - expect(() => Remote({ rejectUnknownFields: true } as never)).toThrow('Remote options require') expect(() => Remote({})).toThrow('Remote options require') expect(() => RemoteScope('' as 'metaFixture')).toThrow('Scope key') expect(() => RemoteScope('metaFixture', 'bad/name')).toThrow('export name') From 7ee6da00396c2b23a3de3bb58b5463a5b4693c1b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:58:01 +0800 Subject: [PATCH 15/17] docs(typert): refresh strict input bilingual pairing --- packages/typert/generator/README.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/typert/generator/README.i18n.yaml b/packages/typert/generator/README.i18n.yaml index ad58e457f2..1182f1e804 100644 --- a/packages/typert/generator/README.i18n.yaml +++ b/packages/typert/generator/README.i18n.yaml @@ -21,8 +21,8 @@ en: c7078becc8f12609 zh: 4b8838b559ecb677 /deepseek-ai-dsh-typert-generator/use-this-package/ordinary-remote-metadata: - en: f9c84d8080f2bac7 - zh: 4c0cf9f4d7600660 + en: 01d4182811cd65f9 + zh: 808247fbebee1639 /deepseek-ai-dsh-typert-generator/use-this-package/analyzing-a-workspace-statically: en: 8227097859169cdf zh: 249790ed62e8558a From 29249a4a8330fc24818c945a8f589816113c8261 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:43:19 +0800 Subject: [PATCH 16/17] fix(typert): distribute indexed constraints across unions --- packages/typert/generator/README.i18n.yaml | 8 +-- packages/typert/generator/README.md | 3 +- packages/typert/generator/README.zh.md | 3 +- packages/typert/generator/src/emitter.ts | 6 +++ .../generator/tests/remote-metadata.spec.ts | 53 +++++++++++++++++++ .../generator/tests/remote-model.spec.ts | 4 +- 6 files changed, 69 insertions(+), 8 deletions(-) diff --git a/packages/typert/generator/README.i18n.yaml b/packages/typert/generator/README.i18n.yaml index 1182f1e804..6bd1155f81 100644 --- a/packages/typert/generator/README.i18n.yaml +++ b/packages/typert/generator/README.i18n.yaml @@ -21,8 +21,8 @@ en: c7078becc8f12609 zh: 4b8838b559ecb677 /deepseek-ai-dsh-typert-generator/use-this-package/ordinary-remote-metadata: - en: 01d4182811cd65f9 - zh: 808247fbebee1639 + en: 6bb456bb03fc6617 + zh: 029407c373b8e6a6 /deepseek-ai-dsh-typert-generator/use-this-package/analyzing-a-workspace-statically: en: 8227097859169cdf zh: 249790ed62e8558a @@ -60,8 +60,8 @@ en: 09310605f7d136d4 zh: 3535ec7eba83b90f /deepseek-ai-dsh-typert-generator/known-limitations-and-deferred-work: - en: f3b1002133fe8736 - zh: e8d5fecd10db5e3a + en: fdc6707a82ceb4c3 + zh: 91fb8cab9fcf56bc /deepseek-ai-dsh-typert-generator/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/typert/generator/README.md b/packages/typert/generator/README.md index ccfe034935..c4551f4bcb 100644 --- a/packages/typert/generator/README.md +++ b/packages/typert/generator/README.md @@ -54,7 +54,7 @@ Generated declarations use type-only imports for owner and schema types. Importi Missing JSDoc and unsupported tool JSON Schema do not block otherwise valid ordinary generation or its strict codecs. Metadata retains parsers and available documentation independently of projection: each unsupported input variant or output projection omits `jsonSchema` and records its own `jsonSchemaError`. A tool consumer explicitly selects methods and requires its documentation and supported input schema; an output parser is required, but output JSON Schema is optional unless that consumer requires it. Metadata alone does not make every Remote method a tool. -Every generated parameter spec contains both `codec` and `strictCodec`, derived from the same checker-resolved type model; the analyzer and model carry no per-method input policy. `method.input()` and `{ strict: false }` select the normal codec used by Host dispatch, whose declared objects accept and strip unknown fields. `method.input({ strict: true })` selects recursively closed declared objects for both parsing and supported JSON Schema. Both named-argument envelopes reject extra arguments; explicit JSON and dictionary keys remain open, while constrained dictionary values also validate named fields against their index value type. Strict parsing closes the combined nested fields of a named dictionary value, not each constraint separately. `result` keeps its single normal codec. Tool projection targets the tool-schema subset that [`@deepseek-ai/dsh-tools`](../../core/tools/src/json-schema.ts) enforces on tool parameters, tool output, and the generated PTC SDK; it supports scalars, objects, arrays, and disjoint unions, while canonical `JsonValue` and string-keyed JSON dictionaries need no recursive references. A type outside that subset is reported instead of projected more weakly. Numeric bounds and other semantic rules absent from TypeScript remain with the owning operation. +Every generated parameter spec contains both `codec` and `strictCodec`, derived from the same checker-resolved type model; the analyzer and model carry no per-method input policy. `method.input()` and `{ strict: false }` select the normal codec used by Host dispatch, whose declared objects accept and strip unknown fields. `method.input({ strict: true })` selects recursively closed declared objects for both parsing and supported JSON Schema. Both named-argument envelopes reject extra arguments; explicit JSON and dictionary keys remain open, while constrained dictionary values also validate named fields against their index value type. Strict parsing distributes named-field and index-value unions before merging and closing each supported object combination, including optional fields and nullable index values. `result` keeps its single normal codec. Tool projection targets the tool-schema subset that [`@deepseek-ai/dsh-tools`](../../core/tools/src/json-schema.ts) enforces on tool parameters, tool output, and the generated PTC SDK; it supports scalars, objects, arrays, and disjoint unions, while canonical `JsonValue` and string-keyed JSON dictionaries need no recursive references. A type outside that subset is reported instead of projected more weakly. Numeric bounds and other semantic rules absent from TypeScript remain with the owning operation. `WorkspaceTypertGenerator` analyzes all contributors of each selected face before filtering emitted packages, so a package subset retains the same declaration merges and output bytes as a complete build. @@ -157,6 +157,7 @@ These limits define what the generator cannot model or emit; they are current pa - **Package export patterns are skipped** — contributing packages need concrete export targets; wildcard export patterns are not analyzed. - **Namespace re-exports across faces fail** — named and star re-exports produce links, but a namespace re-export cannot be represented until `TypeTargetModel` can model a module namespace without flattening it. - **The Zod emitter supports a deliberate subset** — generic schema declarations and computed constructs such as conditional or mapped schema roots fail until a concrete schema-factory policy exists. +- **Strict indexed object merging has limits** — named-field and index-value unions are combined branch by branch when their object members are concrete properties. Recursive references, nested index signatures, and computed members retain separate schema intersections; strict parsing can reject otherwise valid values whose fields come from different operands. - **No generated schema imports across faces** — cross-face links are represented for analysis, but no generated schema requires a runtime cross-face Zod import. - **Discovery covers concrete public exports only** — declarations neither exported nor imported by the reachable graph are intentionally outside the package model. diff --git a/packages/typert/generator/README.zh.md b/packages/typert/generator/README.zh.md index cf52b099db..d5d3d1b92d 100644 --- a/packages/typert/generator/README.zh.md +++ b/packages/typert/generator/README.zh.md @@ -54,7 +54,7 @@ factory 返回类实例,并保留 spec,直到 `descriptors` 或 `methods` ge 缺少 JSDoc 或不支持工具 JSON Schema,不会阻止其他方面有效的普通生成及其严格 codec。元数据独立于投影保留解析器与可用文档:每个不受支持的输入变体或输出投影都省略 `jsonSchema`,并记录各自的 `jsonSchemaError`。工具消费方显式选择方法,并要求其所需文档和受支持的输入 schema;输出解析器是必需的,但输出 JSON Schema 是可选的,除非该消费方要求提供。仅有元数据不会让每个 Remote 方法自动成为工具。 -每个生成参数 spec 都包含从同一个 checker 解析后类型模型派生的 `codec` 和 `strictCodec`;分析器与模型不携带逐方法输入策略。`method.input()` 与 `{ strict: false }` 选择 Host 分发使用的普通 codec,其已声明对象接受并剥离未知字段。`method.input({ strict: true })` 为解析和受支持的 JSON Schema 选择递归关闭的已声明对象。两种具名参数封装都拒绝额外参数;显式 JSON 与字典键保持开放,具名字典字段也须满足索引值类型的约束。严格解析会合并具名字典值的嵌套字段后再关闭对象,而不是分别关闭每项约束。`result` 保留唯一的普通 codec。工具投影的目标是 [`@deepseek-ai/dsh-tools`](../../core/tools/src/json-schema.ts) 对工具参数、工具输出和生成的 PTC SDK 强制执行的 tool-schema 子集;它支持标量、对象、数组和互斥联合类型,规范的 `JsonValue` 与字符串键 JSON 字典无需递归引用。子集之外的类型会被报告,而不是以更弱的 schema 投影。TypeScript 未表达的数值范围与其他语义规则仍由操作拥有者负责。 +每个生成参数 spec 都包含从同一个 checker 解析后类型模型派生的 `codec` 和 `strictCodec`;分析器与模型不携带逐方法输入策略。`method.input()` 与 `{ strict: false }` 选择 Host 分发使用的普通 codec,其已声明对象接受并剥离未知字段。`method.input({ strict: true })` 为解析和受支持的 JSON Schema 选择递归关闭的已声明对象。两种具名参数封装都拒绝额外参数;显式 JSON 与字典键保持开放,具名字典字段也须满足索引值类型的约束。严格解析先展开具名字段与索引值的联合分支,再合并并关闭每个受支持的对象组合,包括可选字段和可空索引值。`result` 保留唯一的普通 codec。工具投影的目标是 [`@deepseek-ai/dsh-tools`](../../core/tools/src/json-schema.ts) 对工具参数、工具输出和生成的 PTC SDK 强制执行的 tool-schema 子集;它支持标量、对象、数组和互斥联合类型,规范的 `JsonValue` 与字符串键 JSON 字典无需递归引用。子集之外的类型会被报告,而不是以更弱的 schema 投影。TypeScript 未表达的数值范围与其他语义规则仍由操作拥有者负责。 `WorkspaceTypertGenerator` 先分析每个选中 face 的所有贡献方,再筛选要生成产物的包,因此包子集保留与完整构建相同的声明合并结果和输出字节。 @@ -157,6 +157,7 @@ Host 与 Client 是两个独立的 TypeScript 程序。直接项目引用确定 - **包导出模式会被跳过**——参与贡献的包需要具体的导出目标;通配符导出模式不会被分析。 - **跨 face 的命名空间重新导出会失败**——具名与星号重新导出会生成链接,但在 `TypeTargetModel` 能够不经展平表示模块命名空间之前,命名空间重新导出无法表示。 - **Zod 生成器只支持有意限定的子集**——泛型 schema 声明,以及以条件类型或映射类型为 schema 根的计算构造,都会失败,直到存在明确的 schema 工厂策略。 +- **严格索引对象合并存在限制**——当对象成员都是具体属性时,具名字段与索引值的联合类型会逐分支组合。递归引用、嵌套索引签名和计算成员仍使用独立 schema 的交集;严格解析可能拒绝字段来自不同交集分量的合法值。 - **没有生成的 schema 跨 face 导入**——跨 face 链接会在模型中表示以供分析,但生成的 schema 均不需要跨 face 的运行时 Zod 导入。 - **发现范围只覆盖具体公开导出**——既未导出、也未由可达图导入的声明按设计排除在包模型之外。 diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index fa55bda935..fea5ebb062 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -997,6 +997,12 @@ class SchemaEmitter { if (declared === indexed) return this.typeSchema(declared, substitutions) const own = this.renderer.node(declared) const value = this.renderer.node(indexed) + if (own.kind === 'union') { + return `z.union([${own.types.map(type => this.indexedPropertySchema(type, indexed, substitutions)).join(', ')}])` + } + if (value.kind === 'union') { + return `z.union([${value.types.map(type => this.indexedPropertySchema(declared, type, substitutions)).join(', ')}])` + } const ownProperties = own.kind === 'object' ? own.members.filter((member): member is Extract => member.kind === 'property') : [] const valueProperties = value.kind === 'object' diff --git a/packages/typert/generator/tests/remote-metadata.spec.ts b/packages/typert/generator/tests/remote-metadata.spec.ts index 6da3429526..60a979ac5e 100644 --- a/packages/typert/generator/tests/remote-metadata.spec.ts +++ b/packages/typert/generator/tests/remote-metadata.spec.ts @@ -804,6 +804,59 @@ void ready } } })).toThrow() }) + it.each([ + { + name: 'a union of named object fields', + type: 'Record & { fixed: { own: string } | { other: number } }', + valid: [{ base: 'base', own: 'own' }, { base: 'base', other: 1 }], + invalid: [{ own: 'own' }, { other: 1 }, { base: 'base' }, null], + optional: false, + }, + { + name: 'an optional named field', + type: 'Record & { fixed?: { own: string } }', + valid: [{ base: 'base', own: 'own' }], + invalid: [{ own: 'own' }, { base: 'base' }, null], + optional: true, + }, + { + name: 'a nullable index value', + type: 'Record & { fixed: { own: string } }', + valid: [{ base: 'base', own: 'own' }], + invalid: [{ own: 'own' }, { base: 'base' }, null], + optional: false, + }, + { + name: 'unions on both the named field and index value', + type: 'Record & { fixed: { own: string } | null }', + valid: [{ base: 'base', own: 'own' }, null], + invalid: [{ own: 'own' }, { base: 'base' }], + optional: false, + }, + ])('combines indexed constraints for $name before closing objects', async ({ type, valid, invalid, optional }) => { + const root = fixture() + editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string', + `readonly title: string\n readonly payload: ${type}`)) + const [artifact] = new WorkspaceTypertGenerator(root).generate() + const generated = await executable(artifact!.remote!.js) + const method = generated.TYPERT_REMOTE.methods['goals/create']! + const input = (payload: object) => ({ agentId: 'agent', request: { title: 'ship', payload } }) + for (const view of [method.input(), method.input({ strict: true })]) { + expect(view.jsonSchema).toBeUndefined() + expect(view.jsonSchemaError).toMatch(/selected dictionaries|selected union branches/) + for (const fixed of valid) expect(view.create().parse(input({ fixed }))).toEqual(input({ fixed })) + for (const fixed of invalid) expect(() => view.create().parse(input({ fixed }))).toThrow() + if (optional) expect(view.create().parse(input({}))).toEqual(input({})) + else expect(() => view.create().parse(input({}))).toThrow() + } + for (const fixed of valid) { + if (fixed === null) continue + const extra = input({ fixed: { ...fixed, unexpected: true } }) + expect(method.input().create().parse(extra)).toEqual(input({ fixed })) + expect(() => method.input({ strict: true }).create().parse(extra)).toThrow() + } + }) + it('keeps normal and closed recursive helper identifiers distinct', async () => { const root = fixture() editFile(root, 'packages/remote/src/types.ts', source => `${source}\nexport interface Branch { readonly value: string; readonly next?: Branch }\nexport interface closed$Branch { readonly value: string; readonly next?: closed$Branch }\n`) diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 4cd083d6bf..cea8dfaa8c 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -757,10 +757,10 @@ export interface ClientMarker { message: 'Gateway service key must be a string literal', }, { - name: 'source definitions in a Gateway binding', + name: 'an unsupported Gateway binding option', edit: (source: string) => source.replace( " super(undefined, 'goals')", - " super(undefined, 'goals', { definitions: {} })", + " super(undefined, 'goals', { extra: true })", ), message: 'only supports a namespace option', }, From b67cf4d3814ab5503def5e9019f098d9ad73d290 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:44:18 +0800 Subject: [PATCH 17/17] test(web): verify plugin header wrapping by intrinsic fit --- .../header-responsive.expected.md | 13 +- apps/web/tests/plugin-manager.e2e.ts | 129 ++++++++++++------ 2 files changed, 91 insertions(+), 51 deletions(-) diff --git a/apps/web/tests/expected/plugin-manager/header-responsive.expected.md b/apps/web/tests/expected/plugin-manager/header-responsive.expected.md index 956ddbda34..7967bfe352 100644 --- a/apps/web/tests/expected/plugin-manager/header-responsive.expected.md +++ b/apps/web/tests/expected/plugin-manager/header-responsive.expected.md @@ -1,18 +1,19 @@ # Plugin header responsive layout en 1680px: actions beside the heading, single-line; contained; information follows subtitle; focus retained -en 768px: actions below the heading, single-line; contained; information follows subtitle; focus retained -en 520px: actions below the heading, single-line; contained; information follows subtitle; focus retained -en 440px: actions below the heading, single-line; contained; information follows subtitle; focus retained +en 768px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained +en 520px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained +en 440px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained en 390px: actions below the heading, single-line; contained; information follows subtitle; focus retained en 430px: actions below the heading, single-line; contained; information follows subtitle; focus retained en 1024px: actions beside the heading, single-line; contained; information follows subtitle; focus retained zh 1680px: actions beside the heading, single-line; contained; information follows subtitle; focus retained -zh 768px: actions beside the heading, single-line; contained; information follows subtitle; focus retained -zh 520px: actions beside the heading, single-line; contained; information follows subtitle; focus retained -zh 440px: actions below the heading, single-line; contained; information follows subtitle; focus retained +zh 768px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained +zh 520px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained +zh 440px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained zh 390px: actions below the heading, single-line; contained; information follows subtitle; focus retained zh 430px: actions below the heading, single-line; contained; information follows subtitle; focus retained zh 1024px: actions beside the heading, single-line; contained; information follows subtitle; focus retained +A forced no-wrap layout at 390px fails the same measured layout check in both locales; restoring wrap passes. Add menu dismisses on Escape and returns focus in both locales. diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 15937b97ff..560837cb67 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -103,9 +103,8 @@ describe('web e2e: plugin manager', () => { // view-installation button, which moves the wrap threshold. const headerScaffold = await launchWebScaffold() try { - // The widths where the header must have wrapped or not are pinned; the - // remaining widths assert only that the action group is placed on one side - // of the heading, and the golden records which. + // Pin the extremes; at intermediate widths, the rendered font and locale + // determine whether the two intrinsic items fit beside each other. const PINNED_PLACEMENT: Record = { 1680: 'beside', 1024: 'beside', 390: 'below', 430: 'below' } for (const language of ['en', 'zh'] as const) { const context = await browser.newContext({ @@ -125,52 +124,92 @@ describe('web e2e: plugin manager', () => { const add = header.getByRole('button', { name: language === 'en' ? 'Add plugin' : '添加插件', exact: true }) await expect.poll(() => add.isEnabled()).toBe(true) await add.focus() + const measure = () => header.evaluate((element) => { + const heading = element.firstElementChild! + const actions = element.lastElementChild! + const headingRect = heading.getBoundingClientRect() + const actionRect = actions.getBoundingClientRect() + const headerRect = element.getBoundingClientRect() + const intrinsicHeading = heading.cloneNode(true) as HTMLElement + Object.assign(intrinsicHeading.style, { + position: 'absolute', width: 'max-content', maxWidth: 'none', visibility: 'hidden', pointerEvents: 'none', + }) + element.append(intrinsicHeading) + let intrinsicHeadingWidth: number + try { + intrinsicHeadingWidth = intrinsicHeading.getBoundingClientRect().width + } finally { + intrinsicHeading.remove() + } + const headerStyle = getComputedStyle(element) + const contentWidth = headerRect.width - Number.parseFloat(headerStyle.paddingLeft) - Number.parseFloat(headerStyle.paddingRight) + const fitsBeside = intrinsicHeadingWidth + actionRect.width + Number.parseFloat(headerStyle.columnGap) <= contentWidth + 1 + const subtitle = heading.querySelector('h1 + div > span')! + const range = document.createRange() + range.selectNodeContents(subtitle) + const lastText = Array.from(range.getClientRects()).at(-1)! + const info = heading.querySelector('button')!.getBoundingClientRect() + const buttons = Array.from(actions.querySelectorAll('button')) + const singleLine = buttons.every((button) => { + const walker = document.createTreeWalker(button, NodeFilter.SHOW_TEXT) + const tops: number[] = [] + while (walker.nextNode()) { + if (!walker.currentNode.textContent?.trim()) continue + range.selectNodeContents(walker.currentNode) + for (const rect of range.getClientRects()) tops.push(rect.top) + } + return tops.length === 0 || Math.max(...tops) - Math.min(...tops) < 1 + }) + return { + singleLine, fitsBeside, + diagnostic: { flexWrap: headerStyle.flexWrap, headingMaxWidth: getComputedStyle(heading).maxWidth, + introDisplay: getComputedStyle(heading.querySelector('h1 + div')!).display, + intrinsicHeadingWidth, headingWidth: headingRect.width, actionsWidth: actionRect.width, + contentWidth, gap: Number.parseFloat(headerStyle.columnGap), + subtitleRight: lastText.right, infoLeft: info.left, + }, + placement: actionRect.left >= headingRect.right - 1 + ? 'beside' as const + : actionRect.top >= headingRect.bottom - 1 ? 'below' as const : 'overlapping' as const, + contained: actionRect.left >= headerRect.left - 1 && actionRect.right <= headerRect.right + 1, + infoFollowsText: info.left >= lastText.right && info.left - lastText.right <= 5 + && info.top < lastText.bottom && info.bottom > lastText.top, + focused: buttons.includes(document.activeElement as HTMLButtonElement), + } + }) + const validLayout = (layout: Awaited>) => + layout.singleLine && layout.contained && layout.infoFollowsText && layout.focused + && layout.placement === (layout.fitsBeside ? 'beside' : 'below') for (const width of [1680, 768, 520, 440, 390, 430, 1024]) { await probe.setViewportSize({ width, height: 1000 }) - const measure = () => header.evaluate((element) => { - const heading = element.firstElementChild! - const actions = element.lastElementChild! - const headingRect = heading.getBoundingClientRect() - const actionRect = actions.getBoundingClientRect() - const headerRect = element.getBoundingClientRect() - const subtitle = heading.querySelector('h1 + div > span')! - const range = document.createRange() - range.selectNodeContents(subtitle) - const textRects = Array.from(range.getClientRects()) - const lastText = textRects.at(-1)! - const info = heading.querySelector('button')!.getBoundingClientRect() - const buttons = Array.from(actions.querySelectorAll('button')) - const singleLine = buttons.every((button) => { - const walker = document.createTreeWalker(button, NodeFilter.SHOW_TEXT) - const tops: number[] = [] - while (walker.nextNode()) { - if (!walker.currentNode.textContent?.trim()) continue - range.selectNodeContents(walker.currentNode) - for (const rect of range.getClientRects()) tops.push(rect.top) - } - return tops.length === 0 || Math.max(...tops) - Math.min(...tops) < 1 - }) - return { - singleLine, - placement: actionRect.left >= headingRect.right - 1 - ? 'beside' as const - : actionRect.top >= headingRect.bottom - 1 ? 'below' as const : 'overlapping' as const, - contained: actionRect.left >= headerRect.left - 1 && actionRect.right <= headerRect.right + 1, - infoFollowsText: info.left >= lastText.right && info.left - lastText.right <= 5 - && info.top < lastText.bottom && info.bottom > lastText.top, - focused: buttons.includes(document.activeElement as HTMLButtonElement), - } - }) + await expect.poll(async () => { + const layout = await measure() + return validLayout(layout) ? true : layout + }, { message: `${language} ${width}px plugin header geometry` }).toBe(true) + const { placement } = await measure() const pinned = PINNED_PLACEMENT[width] - await expect.poll(measure).toMatchObject({ - singleLine: true, contained: true, infoFollowsText: true, focused: true, - }) - const placed = (await measure()).placement - if (pinned === undefined) expect(['beside', 'below']).toContain(placed) - else expect(placed).toBe(pinned) - facts.push(`${language} ${width}px: actions ${placed} the heading, single-line; contained; information follows subtitle; focus retained`) + if (pinned !== undefined) expect(placement).toBe(pinned) + facts.push(`${language} ${width}px: actions ${pinned === undefined ? 'beside or below (according to intrinsic fit)' : placement} the heading, single-line; contained; information follows subtitle; focus retained`) } await probe.setViewportSize({ width: 390, height: 1000 }) + const previousWrap = await header.evaluate((element) => { + const style = element.style + const previous = { value: style.getPropertyValue('flex-wrap'), priority: style.getPropertyPriority('flex-wrap') } + style.setProperty('flex-wrap', 'nowrap') + return previous + }) + try { + const noWrap = await measure() + expect(noWrap.fitsBeside).toBe(false) + expect(noWrap.placement).not.toBe('below') + expect(validLayout(noWrap)).toBe(false) + } finally { + await header.evaluate((element, previous) => { + if (previous.value === '') element.style.removeProperty('flex-wrap') + else element.style.setProperty('flex-wrap', previous.value, previous.priority) + }, previousWrap) + } + await expect.poll(async () => validLayout(await measure())).toBe(true) const menuTrigger = header.getByRole('button', { name: language === 'en' ? 'Choose how to add a plugin' : '选择添加插件方式' }) await menuTrigger.click() await probe.getByRole('menu').waitFor() @@ -184,7 +223,7 @@ describe('web e2e: plugin manager', () => { } await compareOrRefreshGolden( join(SNAPSHOT_DIR, 'header-responsive.expected.md'), - `# Plugin header responsive layout\n\n${facts.join('\n')}\n\nAdd menu dismisses on Escape and returns focus in both locales.`, + `# Plugin header responsive layout\n\n${facts.join('\n')}\n\nA forced no-wrap layout at 390px fails the same measured layout check in both locales; restoring wrap passes.\nAdd menu dismisses on Escape and returns focus in both locales.`, MODE, ) } finally {