From 3244ff29a9f497e4af59205ba06e5fa7d44df663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=AC=E8=AF=AD=E6=83=8A=E8=8A=B1?= <105714958+Nwflower@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:50:10 +0800 Subject: [PATCH] feat: tool plugin source attribution in the Context browser (#25) Tool-schema rows in the Context browser carry a best-effort source tag for the registering plugin, recovered without a harness change: - a harness-logged `plugin` field passes through verbatim (future-proof); - MCP-proxied tools resolve `mcp:` from their public names; - shipped first-party tools resolve from a pinned package map; - a live `tools.register()` hook (cordis `internal/get` reader slot, with a caller-package stack fallback for local-link installs) tags third-party, agent-scoped, and dynamic registrations, and peels a prior incarnation's wrapper on reload; - tools already registered when the plugin mounts render a localized "unknown plugin" tag explaining the boot-order gap. Priority: `mcp:` > live record > pinned map > unknown. The chip rides the shared tag frame tinted with the brand badge tokens. Rebased onto v0.35.0 with maintainer review fixes; the attribution priority and reload-safety rework and the chip theming are described in the PR review comment. Co-authored-by: Nwflower <1679659@qq.com> Co-authored-by: bowenliang123 --- README.md | 2 + src/client/components/browser.tsx | 21 +- src/client/i18n.ts | 6 + src/client/styles.css | 5 + src/host/attribution.ts | 245 ++++++++++++++ src/host/headers.ts | 23 +- src/host/index.ts | 8 +- src/host/toolSources.ts | 95 ++++++ src/shared/types.ts | 19 ++ tests/client/components/browser.spec.ts | 37 +- tests/host/attribution.spec.ts | 316 ++++++++++++++++++ tests/host/fixtures/anon-plugin/index.d.ts | 3 + tests/host/fixtures/anon-plugin/index.js | 7 + tests/host/fixtures/anon-plugin/package.json | 6 + tests/host/fixtures/bad-pkg/index.js | 6 + tests/host/fixtures/bad-pkg/package.json | 5 + tests/host/fixtures/local-plugin/index.d.ts | 3 + tests/host/fixtures/local-plugin/index.js | 7 + tests/host/fixtures/local-plugin/package.json | 6 + tests/host/headers.spec.ts | 54 +++ tests/host/index.spec.ts | 52 +++ tests/host/toolSources.spec.ts | 57 ++++ 22 files changed, 974 insertions(+), 9 deletions(-) create mode 100644 src/host/attribution.ts create mode 100644 src/host/toolSources.ts create mode 100644 tests/host/attribution.spec.ts create mode 100644 tests/host/fixtures/anon-plugin/index.d.ts create mode 100644 tests/host/fixtures/anon-plugin/index.js create mode 100644 tests/host/fixtures/anon-plugin/package.json create mode 100644 tests/host/fixtures/bad-pkg/index.js create mode 100644 tests/host/fixtures/bad-pkg/package.json create mode 100644 tests/host/fixtures/local-plugin/index.d.ts create mode 100644 tests/host/fixtures/local-plugin/index.js create mode 100644 tests/host/fixtures/local-plugin/package.json create mode 100644 tests/host/toolSources.spec.ts diff --git a/README.md b/README.md index ed1d5e4..abe9a0d 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,8 @@ Pick **Live (next request)** or any retained step from the picker, and browse wh Six collapsible category sections (system prompt, tool schemas, user messages, injected context, assistant replies, tool results) expand into one row per element — each with its token price — and every element expands again into its **actual content**: the full system prompt, each tool's description and JSON schema, message text, reasoning, tool-call arguments, and tool outputs. +- **Who provides each tool** — tool-schema rows carry a best-effort source tag when attribution is knowable: the pinned first-party package map (`@deepseek-ai/dsh-tool-*`) for shipped tools, `mcp:` for MCP-proxied tools (their public names encode the server), or a harness-logged `plugin` field. Beyond those, the host also watches live `tools.register()` calls and tags each tool to the plugin that registered it — third-party, agent-scoped and dynamically named tools included, even plugins installed from a local path or npm/pnpm link (their package name is resolved from the registering code's directory). The session log itself records only each tool's name/description/parameters, so a tool that was **already registered when the context plugin mounted** (e.g. local-link plugins that load earlier in the bundle) shows an **Unknown plugin** tag instead: the registering plugin is gone from every observable surface by the time attribution starts. Tools not in the current registry at all (older sessions) stay untagged. + - **Linked with the trend chart** — hover any bar in the Context trend card and the browser previews that step instantly; leave the chart and it returns to your own pick. Keep a category open while scrubbing to compare one category across steps. Clicking a step-brief row (**User** / **In** / **Response**) opens that exact message here, expanded and scrolled into view. - **Honest about coverage** — steps before a compaction are reconstructed from the removed-message archive, and the card says so when a step's makeup is only approximate. Elements older than the loaded chat window page older history in automatically when you expand them, and live injections (AGENTS.md, session-start context, …) are always listed — never a token sum without its items. - **Diff against the previous turn** — switch the picker to **vs previous turn** and every category gets signed delta badges (`+N` items, `+Nk` tokens), so one glance tells you what the conversation added since the end of the last turn. diff --git a/src/client/components/browser.tsx b/src/client/components/browser.tsx index e01da89..aa73c88 100644 --- a/src/client/components/browser.tsx +++ b/src/client/components/browser.tsx @@ -1,5 +1,5 @@ import type * as ReactNS from 'react' -import type { Category, ContextHeaders, ContextTimeline, HeaderTool, RequestRecord, SurfaceNode } from '../../shared/types' +import { UNKNOWN_TOOL_SOURCE, type Category, type ContextHeaders, type ContextTimeline, type HeaderTool, type RequestRecord, type SurfaceNode } from '../../shared/types' import { assemble } from '../assemble' import type { Assembled } from '../assemble' import { CATS, partsOf } from '../categories' @@ -658,9 +658,9 @@ export function makeContextBrowser( * result scans while collapsed. */ const elemRow = ( - key: string, tag: string | null, preview: string, + key: string, tag: ReactNS.ReactNode | null, preview: string, tokens: number, time: number | undefined, body: ReactNS.ReactNode, - err = false, + err = false, trailing: ReactNS.ReactNode = null, ) => { const open = openElem === key return ( @@ -670,6 +670,7 @@ export function makeContextBrowser( {err ? : null} {tag !== null ? {tag} : null} {preview} + {trailing !== null ? trailing : null} {time !== undefined ? {fmtTime(time)} : null} {'≈' + fmt(tokens)} @@ -699,8 +700,20 @@ export function makeContextBrowser( hide: t('tool.jsonHide'), } return view.header.tools.slice().sort((a, b) => b.tokens - a.tokens).map((tool: HeaderTool) => { + // The registering plugin (best-effort host attribution — see + // toolSources.ts) trails the tool name as a standalone chip, so it + // needs no extra frame around it. A tool whose provider predates + // the attribution hook arrives with the UNKNOWN_TOOL_SOURCE + // sentinel and renders a localized "unknown plugin" tag whose + // tooltip explains why no provider is shown. + const trailing = tool.plugin !== undefined + ? + {tool.plugin === UNKNOWN_TOOL_SOURCE ? t('tool.unknown') : tool.plugin} + + : null return elemRow('tool:' + tool.name, null, tool.name, tool.tokens, undefined, - ) + , + false, trailing) }) } // List surface nodes newest first (the live surface's reading order). diff --git a/src/client/i18n.ts b/src/client/i18n.ts index fb1a08e..2ea0fc0 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -157,6 +157,9 @@ export const DICT_ZH: Record = { 'tool.desc': '描述', 'tool.params': '参数', 'tool.paramsEmpty': '(无参数)', + 'tool.plugin': '该工具的注册插件(仅供参考)', + 'tool.unknown': '未知插件', + 'tool.unknownTitle': '因早于上下文插件加载或原插件已卸载,无法查询该工具的提供者', 'tool.jsonToggle': '查看原始 JSON', 'tool.jsonHide': '收起', 'rich.raw': '原文', @@ -337,6 +340,9 @@ export const DICT_EN: Record = { 'tool.desc': 'Description', 'tool.params': 'Parameters', 'tool.paramsEmpty': '(no parameters)', + 'tool.plugin': 'The registering plugin of this tool (for reference)', + 'tool.unknown': 'Unknown plugin', + 'tool.unknownTitle': 'The tool was registered before the context plugin loaded, or its plugin has been unloaded, so its provider cannot be determined', 'tool.jsonToggle': 'View Raw JSON', 'tool.jsonHide': 'Collapse', 'rich.raw': 'Raw', diff --git a/src/client/styles.css b/src/client/styles.css index a783b01..f031564 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -358,6 +358,11 @@ button.lc-brief-row-link { cursor: pointer; } .lc-br-err-dot { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--dsw-alias-state-error-primary); box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); } /* Shrinkable under width pressure (min-width: 0) so a long tool/call tag ellipsizes instead of forcing the row past the card edge. */ .lc-br-tag { min-width: 0; font-size: 11px; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); border-radius: 4px; padding: 0 6px; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* Registering-plugin chip on tool-schema rows: package/server names scan on a + brand-tinted pill (the badge tint idiom), in the same face and size as the + tool name they tag (the row inherits the UI font at 12px) so the attribution + reads as part of the name. */ +.lc-br-tool-plugin { font-size: 12px; border-radius: 6px; color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 14%, transparent); border-color: transparent; } .lc-br-preview { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .lc-br-time { flex: none; color: var(--dsw-alias-label-secondary); font-size: 11px; } .lc-br-content { padding: 2px 6px 8px 26px; display: flex; flex-direction: column; gap: 6px; } diff --git a/src/host/attribution.ts b/src/host/attribution.ts new file mode 100644 index 0000000..3a396a8 --- /dev/null +++ b/src/host/attribution.ts @@ -0,0 +1,245 @@ +/// +/** + * Live tool→plugin attribution layered on the static recovery in + * toolSources.ts. + * + * The session log records tools as plain `ToolSchema` entries (name / + * description / parameters) — the registering plugin is not in there. + * toolSources.ts derives the deterministic sources (harness-logged field, + * `mcp:` naming, pinned first-party map). This module additionally + * watches RUNTIME registrations: cordis fires the `internal/get` waterfall on + * every context read of a service property, passing the READING context as the + * first argument, so `reader.fiber.name` identifies the plugin that is about + * to call `register()`. + * + * - The `internal/get` handler records who last read the `tools` service and + * wraps that instance's `register` (once — an earlier wrapper of a previous + * hook incarnation is peeled back to the original, so a plugin reload + * re-wraps without stacking) to capture the reader at registration time + * into a live map. + * - When the reader slot is missing, root-named, or this plugin's own (e.g. + * LOCAL-LINK plugins — dev installs via `dsh plugin add ` or + * npm/pnpm link — whose anonymous entrypoints make cordis fall back to the + * root name), the wrapped `register` falls back to the call stack: the first + * frame outside this package is resolved to its nearest `package.json` + * `name`. That covers both npm installs (`node_modules/`) and local + * links (any directory carrying a package.json), which never pass through + * node_modules. Frames that resolve back to this package are skipped. + * - `ownerOf(name)` prefers the name-derived `mcp:` label (it names + * the actual provider, where the live record would only ever name the MCP + * proxy client), then the LIVE record — for a post-boot registration it is + * the truth, even when the name collides with a pinned first-party tool — + * then the pinned map (the boot-time guess for tools registered before the + * hook), and finally tags tools that were ALREADY registered when the hook + * installed (the boot snapshot — third-party bundles, e.g. local links like + * dsh-file-claim, that applied before dsh-context) with the + * `UNKNOWN_TOOL_SOURCE` sentinel: their registering plugin is unknowable, + * and a bare gap would read as "no plugin" instead of "unknown plugin". + * + * Best-effort by design: a read separated from `register()` by an `await` can + * be overwritten by another plugin's read (misattribution) and the stack + * fallback needs a resolvable package.json — both degrade to the name/pinned + * chain; registrations that predate the hook degrade to the unknown tag. The + * hook costs roughly +1.4us per service-property read and is negligible on + * the rare register path (the stack walk only runs when the reader slot is + * unusable, and its package lookups are cached per directory). + */ + +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join, normalize } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Context } from '@deepseek-ai/cordis' +import { UNKNOWN_TOOL_SOURCE } from '../shared/types' +import { mcpSourceOf, pinnedSourceOf } from './toolSources' + +export interface ToolAttribution { + /** Best-effort label of the plugin that registered `name`, if any is known. */ + ownerOf(name: string): string | undefined +} + +/** This module's own file URL — the stack walk skips its own frames. */ +const selfUrl = normalize(fileURLToPath(import.meta.url)) + +/** Directory → package-name cache for the synchronous walk below. */ +const packageCache = new Map() + +/** + * Best-effort package name for a module file: walk up to the nearest + * `package.json` carrying a `name`. Works for dependencies installed under + * `node_modules` as well as local links whose package root is any on-disk + * directory. The per-directory results are cached. + * @param file - absolute path of a module file. + */ +export function packageNameFrom(file: string): string | undefined { + let dir = dirname(file) + for (let depth = 0; depth < 12; depth++) { + const cached = packageCache.get(dir) + if (cached !== undefined) return cached + const packageFile = join(dir, 'package.json') + if (existsSync(packageFile)) { + try { + const name = (JSON.parse(readFileSync(packageFile, 'utf8')) as { name?: unknown }).name + if (typeof name === 'string' && name) { + packageCache.set(dir, name) + return name + } + } catch { + // Malformed or unreadable package.json — keep walking up. + } + } + const parent = dirname(dir) + packageCache.set(dir, undefined) + if (parent === dir) return undefined + dir = parent + } + return undefined +} + +const FRAME_POSITION = /:\d+:\d+$/ + +/** Package name of this module's own package (self-fallbacks are filtered). */ +const selfPackage = packageNameFrom(selfUrl) + +/** + * Resolve the registering package from a stack trace: walk frames from the + * innermost out, skipping this module's own frames and frames that resolve to + * this package, and return the package name of the first frame that resolves + * elsewhere. Works with both `file://` URLs and bare absolute paths + * (transpiled modules render without a scheme), with optional `fn (...)` and + * `async` wrappers. + * @param stack - `Error().stack`, or undefined when no fallback is desired. + */ +export function callerPackageFrom(stack: string | undefined): string | undefined { + if (!stack) return undefined + for (const raw of stack.split('\n').slice(1)) { + let line = raw.trim() + if (!line.startsWith('at ')) continue + line = line.slice(3) + if (line.startsWith('async ')) line = line.slice(6) + line = line.replace(/\)\s*$/, '') + const position = FRAME_POSITION.exec(line) + if (!position) continue + let target = line.slice(0, -position[0].length) + if (target.includes('(')) { + target = target.slice(target.lastIndexOf('(') + 1) + } + if (target.startsWith('file://')) { + try { + target = normalize(fileURLToPath(target)) + } catch { + // Unparseable file URL — try the next frame. + continue + } + } else if (!/^[A-Za-z]:[\\/]/.test(target) && !target.startsWith('/') && !target.startsWith('\\\\')) { + continue + } else { + target = normalize(target) + } + if (target === selfUrl) continue + const name = packageNameFrom(target) + if (name !== undefined && name !== selfPackage) return name + } + return undefined +} + +/** The tool service's boot-time surface this module reads: the global layer's name→∞ entries. */ +interface ToolServiceLike { + layers?: { + global?: { + tools?: { + entries?: () => Iterable<[string, unknown]> + } + } + } +} + +/** + * Install the runtime-attribution hook on a cordis app context. The hook + * rides the calling fiber's lifetime (`ctx.on` / `ctx.get`), so it is + * disposed with the plugin. + * @param ctx - the context the dsh-context plugin runs in; its fiber name is + * excluded from attributions. + */ +export function createToolAttribution(ctx: Context): ToolAttribution { + const live = new Map() + const wrapped = new WeakSet() + const self = ctx.fiber.name + let lastReader: Context | undefined + + const wrapInstance = (tools: unknown) => { + if (!tools || typeof tools !== 'object' || wrapped.has(tools)) return + const register = (tools as { register?: unknown }).register + if (typeof register !== 'function') return + wrapped.add(tools) + // A reload of this plugin re-installs the hook on a still-wrapped + // instance: peel the previous incarnation's wrapper back to the original + // (marked below) so wrappers never stack across reloads. + const original = (register as { attributedOriginal?: unknown }).attributedOriginal ?? register + if (typeof original !== 'function') return + const instance = tools as { register: (this: unknown, definition?: { name?: unknown }) => unknown } + const wrappedRegister = function (this: unknown, definition?: { name?: unknown }) { + const toolName = definition?.name + let owner = lastReader?.fiber.name + if (!owner || owner === 'root' || owner === self) { + owner = callerPackageFrom(new Error().stack) + } + const dispose = (original as (this: unknown, definition?: unknown) => unknown).call(this, definition) + if (typeof toolName === 'string' && owner && owner !== 'root' && owner !== self && owner !== selfPackage) { + live.set(toolName, owner) + if (typeof dispose === 'function') { + return () => { + try { + return (dispose as () => unknown)() + } finally { + live.delete(toolName) + } + } + } + } + return dispose + } + ;(wrappedRegister as { attributedOriginal?: unknown }).attributedOriginal = original + instance.register = wrappedRegister + } + + ctx.on('internal/get', (reader, name, _error, next) => { + if (name !== 'tools') return next() as unknown + const tools = next() as unknown + lastReader = reader + wrapInstance(tools) + return tools + }) + + // An instance provided before this plugin started is still wrapped so later + // registrations on it are captured. The tools it already holds were + // registered before this hook could observe them (boot-time third-party + // bundles that applied first — e.g. local links like dsh-file-claim), so + // their provider is unknowable; the boot snapshot lets ownerOf tag them + // with UNKNOWN_TOOL_SOURCE instead of silently showing nothing. + const toolsService = ctx.get('tools', false) as ToolServiceLike | undefined + wrapInstance(toolsService) + const boot = new Set() + try { + const toolEntries = toolsService?.layers?.global?.tools + // Method-style call: NamedEntries.entries() reads `this.data`, so the + // receiver must be preserved. Any shape surprise just leaves the boot + // snapshot empty (static chain + live map still rule); attribution must + // never take the caller's startup down. + if (toolEntries !== undefined && typeof toolEntries.entries === 'function') { + for (const [name] of toolEntries.entries()) boot.add(name) + } + } catch { + // Unsupported tool-service internals — degrade to no boot snapshot. + } + + return { + // Priority: the name-derived MCP label names the actual provider (the + // live record would only ever name the proxying client plugin); a live + // record outranks the pinned map (for a post-boot registration it IS the + // truth, even under a first-party-looking name); the pinned map is the + // boot-time guess; the sentinel marks boot-predating tools. + ownerOf: name => + mcpSourceOf(name) ?? live.get(name) ?? pinnedSourceOf(name) + ?? (boot.has(name) ? UNKNOWN_TOOL_SOURCE : undefined), + } +} diff --git a/src/host/headers.ts b/src/host/headers.ts index 049a99a..72ac2f7 100644 --- a/src/host/headers.ts +++ b/src/host/headers.ts @@ -34,6 +34,7 @@ const headerToolSchema = z.object({ name: z.string(), tokens: z.number().int().nonnegative(), description: z.string().optional(), + plugin: z.string().optional(), schema: z.unknown().optional(), }).strict() @@ -64,7 +65,7 @@ function recordOf(event: SessionEvent): HeaderRecord | null { tools: tools.map((t): HeaderTool => { // The log is untrusted input: a null or primitive entry degrades to an // unnamed, JSON-priced tool instead of throwing the fold. - const tool = (t !== null && typeof t === 'object' ? t : {}) as { name?: unknown; description?: unknown } + const tool = (t !== null && typeof t === 'object' ? t : {}) as { name?: unknown; description?: unknown; plugin?: unknown } const entry: HeaderTool = { name: typeof tool.name === 'string' ? tool.name : '?', tokens: estimateToolSchema(t), @@ -73,6 +74,11 @@ function recordOf(event: SessionEvent): HeaderRecord | null { if (typeof tool.description === 'string' && tool.description !== '') { entry.description = tool.description } + // A harness/MCP-provided attribution rides the raw entry; kept verbatim + // so the view-time resolver never overrides it. + if (typeof tool.plugin === 'string' && tool.plugin !== '') { + entry.plugin = tool.plugin + } return entry }), } @@ -86,10 +92,21 @@ function recordOf(event: SessionEvent): HeaderRecord | null { * The context-headers projection unit; registered alongside the timeline unit (host/index.ts); clients read it through * `useProjection('contextHeaders')` and degrade to tokens-only header sections when the key is absent. Dual-contract definition (see * compat.ts). + * @param resolve - best-effort tool-to-plugin attribution (see toolSources.ts); fills a missing `plugin` at view time so + * epochs folded without attribution still render a tag when the source is known. */ -export function createContextHeadersDefinition(): ProjectionDefinition<'contextHeaders', HeadersState> { +export function createContextHeadersDefinition( + resolve?: (name: string) => string | undefined, +): ProjectionDefinition<'contextHeaders', HeadersState> { const view = (state: HeadersState): ContextHeaders => ({ - headers: state.headers.map(h => ({ ...h, tools: h.tools.map(t => ({ ...t })) })), + headers: state.headers.map(h => ({ + ...h, + tools: h.tools.map((t) => { + if (resolve === undefined || t.plugin !== undefined) return { ...t } + const plugin = resolve(t.name) + return plugin === undefined ? { ...t } : { ...t, plugin } + }), + })), }) const definition: CompatProjectionDefinition<'contextHeaders', HeadersState> = { key: 'contextHeaders', diff --git a/src/host/index.ts b/src/host/index.ts index 5354dd4..06dad65 100644 --- a/src/host/index.ts +++ b/src/host/index.ts @@ -18,6 +18,7 @@ */ import type { Context } from '@deepseek-ai/cordis' +import { createToolAttribution } from './attribution' import { Config } from './config' import { createContextHeadersDefinition } from './headers' import { installSettings } from './settings' @@ -34,8 +35,13 @@ export const inject = ['sessionProjections'] export { Config } from './config' export function apply(ctx: Context, config: Config): void { + // Tool-to-plugin attribution (see attribution.ts): the static chain from + // toolSources.ts stays the backbone, the runtime register() hook adds + // third-party / agent-scoped / dynamic tools on top. Strictly additive — an + // unsupported cordis or a missed read degrades to the static chain. + const attribution = createToolAttribution(ctx) ctx.sessionProjections.register(createContextTimelineDefinition(config)) - ctx.sessionProjections.register(createContextHeadersDefinition()) + ctx.sessionProjections.register(createContextHeadersDefinition(name => attribution.ownerOf(name))) installSettings(ctx) } diff --git a/src/host/toolSources.ts b/src/host/toolSources.ts new file mode 100644 index 0000000..805ebac --- /dev/null +++ b/src/host/toolSources.ts @@ -0,0 +1,95 @@ +/** + * Tool-to-plugin attribution for the Context browser's tool schema rows. + * + * The durable `request/header` event logs each tool as a plain `ToolSchema` + * (`name`/`description`/`parameters` only) — the harness strips everything + * else at assembly, so the registering plugin is not in the session log. This + * module recovers what it can without a harness change: + * + * 1. A `plugin` field a (future) harness or MCP client carries on the raw + * header entry — passed through verbatim by headers.ts's recordOf, ahead + * of everything here. + * 2. MCP tools: `dsh-mcp-client` names every proxied tool + * `mcp____`, so the server is recoverable from the name. + * 3. Shipped first-party tools: a PINNED name → package map derived from the + * deepseek-harness tool-schema catalog (packages/core/tools + the shipped + * `tool-*` packages at dsh 0.1.1-rc.2; names are stable across the plugin's + * supported 0.1.0-rc7+ / 0.1.1-rc2+ range). Third-party tools stay + * unattributed rather than guessed. + * + * Pure string functions: fully unit-testable, no harness imports. + */ + +/** MCP server-name prefix of `dsh-mcp-client` proxied tools. */ +export const MCP_PREFIX = 'mcp__' + +/** + * Recover the MCP server display label from a proxied tool name, or undefined + * for non-MCP names. `dsh-mcp-client` names tools `mcp____` + * (normalized, and hash-appended when overlong/invalid — the label then shows + * whatever of the server survived the truncation). The separating `__` is the + * LAST one in the name, and it must sit AFTER the `mcp__` prefix: the prefix's + * own separator (or an empty server right after it) is not a server. + */ +export function mcpServerOf(name: string): string | undefined { + if (!name.startsWith(MCP_PREFIX)) return undefined + const cut = name.lastIndexOf('__') + if (cut < MCP_PREFIX.length) return undefined + const server = name.slice(MCP_PREFIX.length, cut) + return server.length > 0 ? server : undefined +} + +/** The `mcp:` display label of a proxied tool name, or undefined. */ +export function mcpSourceOf(name: string): string | undefined { + const server = mcpServerOf(name) + return server !== undefined ? `mcp:${server}` : undefined +} + +/** The pinned package of a first-party tool name, or undefined. */ +export function pinnedSourceOf(name: string): string | undefined { + return FIRST_PARTY_SOURCES[name] +} + +/** + * Pinned first-party tool → plugin package map (see header comment). One entry + * per model-facing name of the shipped tool packages at dsh 0.1.1-rc.2, from + * the official tool-schema catalog; names are stable across the supported + * harness range. Packages that mount under distinct names per composition + * (bash/pwsh persistent variants, subagent fork) map to their primary package. + */ +export const FIRST_PARTY_SOURCES: Readonly> = Object.freeze({ + read: '@deepseek-ai/dsh-tool-fs', + write: '@deepseek-ai/dsh-tool-fs', + edit: '@deepseek-ai/dsh-tool-fs', + read_image: '@deepseek-ai/dsh-tool-fs', + glob: '@deepseek-ai/dsh-tool-fs-search', + grep: '@deepseek-ai/dsh-tool-fs-search', + str_replace_editor: '@deepseek-ai/dsh-tool-str-replace-editor', + bash: '@deepseek-ai/dsh-tool-bash', + pwsh: '@deepseek-ai/dsh-tool-pwsh', + web_search: '@deepseek-ai/dsh-tool-web', + web_fetch: '@deepseek-ai/dsh-tool-web', + job_output: '@deepseek-ai/dsh-tool-jobs', + job_list: '@deepseek-ai/dsh-tool-jobs', + job_kill: '@deepseek-ai/dsh-tool-jobs', + ask_user_question: '@deepseek-ai/dsh-tool-ask-user', + plan: '@deepseek-ai/dsh-plan-mode', + exit_plan_mode: '@deepseek-ai/dsh-plan-mode', + skill: '@deepseek-ai/dsh-tool-skill', + todo_write: '@deepseek-ai/dsh-tool-todo', + subagent: '@deepseek-ai/dsh-tool-subagent', + subagent_fork: '@deepseek-ai/dsh-tool-subagent', + send_message: '@deepseek-ai/dsh-tool-subagent-control', + interrupt_agent: '@deepseek-ai/dsh-tool-subagent-control', + list_agents: '@deepseek-ai/dsh-tool-subagent-control', + ralph: '@deepseek-ai/dsh-tool-ralph', + workflow: '@deepseek-ai/dsh-tool-workflow', + run_code: '@deepseek-ai/dsh-tools', + schedule_create: '@deepseek-ai/dsh-schedule', + schedule_list: '@deepseek-ai/dsh-schedule', + schedule_delete: '@deepseek-ai/dsh-schedule', + create_goal: '@deepseek-ai/dsh-tool-goal', + get_goal: '@deepseek-ai/dsh-tool-goal', + update_goal: '@deepseek-ai/dsh-tool-goal', + lsp: '@deepseek-ai/dsh-tool-lsp', +}) diff --git a/src/shared/types.ts b/src/shared/types.ts index ed443c7..c12f210 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -278,12 +278,31 @@ export interface ContextEventRecord { step?: number } +/** + * Sentinel `HeaderTool.plugin` value marking a tool whose provider could not + * be attributed: it was already registered in the harness tool service when + * this plugin's runtime attribution hook installed (boot-time third-party + * tools — e.g. local-link plugins that apply before dsh-context). The client + * renders a localized "unknown plugin" tag with an explanatory tooltip. No + * real plugin name can collide (it is not a valid package identifier). + */ +export const UNKNOWN_TOOL_SOURCE = '' + /** One tool schema as assembled into a request header, with its display price. */ export interface HeaderTool { name: string tokens: number /** Producer-declared description (may be long; the browser truncates). */ description?: string + /** + * The registering plugin's label, when attribution is known: either a + * `plugin` field carried by the raw header entry (harness-provided) or the + * host's best-effort attribution (`mcp:` for MCP tools, or the + * pinned first-party package map). `UNKNOWN_TOOL_SOURCE` marks a tool whose + * provider predates the attribution hook; absent means nothing is known and + * the browser shows no tag. + */ + plugin?: string /** The raw JSON schema object the model received (plain JSON). */ schema?: unknown } diff --git a/tests/client/components/browser.spec.ts b/tests/client/components/browser.spec.ts index 8ac18a4..403e91b 100644 --- a/tests/client/components/browser.spec.ts +++ b/tests/client/components/browser.spec.ts @@ -9,7 +9,7 @@ import { describe, test } from 'vitest' import { h } from '../../../src/client/react' import { makeContextBrowser, type ContextBrowserProps } from '../../../src/client/components/browser' import { makeStackedBar } from '../../../src/client/components/stackedBar' -import type { ContextHeaders, ContextTimeline, RequestRecord, SurfaceNode } from '../../../src/shared/types' +import { UNKNOWN_TOOL_SOURCE, type ContextHeaders, type ContextTimeline, type RequestRecord, type SurfaceNode } from '../../../src/shared/types' import type { ConversationNodeLike, ImageLoader, UseSessionLike } from '../../../src/client/services' import { click, flush, hover, makeKit, mount, query, queryAll, text, unhover, type Mounted } from '../helpers/kit' @@ -510,6 +510,41 @@ describe('ContextBrowser tool schemas', () => { assert.equal(text(body), '') await m.unmount() }) + + test('tool rows tag the registering plugin when attribution exists', async () => { + const attributed: ContextHeaders = { + headers: [{ seq: 1, time: 1, system: 'SYS', tools: [ + { name: 'write', tokens: 5, plugin: '@deepseek-ai/dsh-tool-fs' }, + { name: 'mcp__github__get_issue', tokens: 3, plugin: 'mcp:github' }, + { name: 'agent_teams_add_member', tokens: 2 }, // no attribution → no chip + // Boot-predating tools arrive with the unknown sentinel → localized tag. + { name: 'claim_files', tokens: 2, plugin: UNKNOWN_TOOL_SOURCE }, + ] }], + } + const data = tl({ current: { system: 0, tools: 10, user: 0, inject: 0, assistant: 0, tool: 0, total: 10 } }) + const m = await mount(h(Browser, props({ data, headers: attributed }))) + await click(catRow(m, 'tools')) + const rows = elemRows(m) + assert.equal(rows.length, 4) + const chips = queryAll(m.container, '.lc-br-tool-plugin') + assert.equal(chips.length, 3, 'only attributed tools chip') + assert.ok(text(chips[0]).includes('@deepseek-ai/dsh-tool-fs')) + assert.ok(text(chips[1]).includes('mcp:github')) + assert.ok((chips[0] as HTMLElement).title.includes('The registering plugin of this tool'), 'the chip carries the i18n tooltip') + assert.ok(!text(rows[2]).includes('@'), 'unattributed tools stay untagged') + // The unknown-source sentinel renders the localized tag with the + // boot-timing explanation, never the raw sentinel string. + assert.equal(chips[2].textContent, 'Unknown plugin') + assert.ok((chips[2] as HTMLElement).title.includes('registered before the context plugin loaded'), 'unknown chips explain the boot gap') + // Layout: the tool name leads the row, the plugin chip trails it directly + // — one frame only, so `lc-br-tag` must not nest another `lc-br-tag`. + assert.equal(text(query(rows[0], '.lc-br-preview')), 'write', 'tool name leads') + const chip = chips[0] as HTMLElement + assert.equal(chip.previousElementSibling, query(rows[0], '.lc-br-preview'), 'plugin chip sits right after the tool name') + assert.equal(chip.parentElement!.className, 'lc-br-elem-row', 'plugin chip is a single frame, a direct row child') + assert.equal(queryAll(chip, '.lc-br-tag').length, 0, 'no nested tag wrapper') + await m.unmount() + }) }) describe('ContextBrowser message categories', () => { diff --git a/tests/host/attribution.spec.ts b/tests/host/attribution.spec.ts new file mode 100644 index 0000000..c5ee062 --- /dev/null +++ b/tests/host/attribution.spec.ts @@ -0,0 +1,316 @@ +// Unit tests for the runtime register() attribution hook (src/host/attribution.ts) +// against a REAL cordis Context: plugin fibers read `ctx.tools` (firing the +// internal/get waterfall) and call `register()` on a fake tools service. + +import assert from 'node:assert/strict' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, test } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { UNKNOWN_TOOL_SOURCE } from '../../src/shared/types' +import { callerPackageFrom, createToolAttribution, packageNameFrom, type ToolAttribution } from '../../src/host/attribution' + +/** Render an absolute fs path as a `file://` URL for synthetic stack frames. */ +const fileUrl = (file: string) => 'file:///' + file.replace(/\\/g, '/') + +/** Absolute path of this spec file (a file inside the dsh-context package). */ +const specFile = path.normalize(fileURLToPath(import.meta.url)) + +describe('createToolAttribution', () => { + test('falls back to the static chain when nothing was registered at runtime', () => { + const app = new Context() + const attribution = createToolAttribution(app) + assert.equal(attribution.ownerOf('read'), '@deepseek-ai/dsh-tool-fs') + assert.equal(attribution.ownerOf('mcp__github__get_issue'), 'mcp:github') + assert.equal(attribution.ownerOf('unknown_tool'), undefined) + }) + + test('attributes tools to the plugin that read ctx.tools and registered them', async () => { + const app = new Context() + const attribution = createToolAttribution(app) + const fake = { register() { return () => {} } } + app.provide('tools', fake) + let release: () => void = () => {} + await app.plugin({ + name: 'provider-a', + apply(sub) { + const tools = (sub as any).tools + assert.equal((sub as any).tools, fake, 'a wrapped instance is returned as-is') + release = tools.register({ name: 'alpha' }) + tools.register({ name: 'beta' }) + }, + }) + assert.equal(attribution.ownerOf('alpha'), 'provider-a') + assert.equal(attribution.ownerOf('beta'), 'provider-a') + release() + assert.equal(attribution.ownerOf('alpha'), undefined, 'the disposer forgets the tool') + assert.equal(attribution.ownerOf('beta'), 'provider-a') + }) + + test('falls back to the caller stack when no tools read happened', async () => { + const app = new Context() + const fake = { register() { return () => {} } } + app.provide('tools', fake) + const attribution = createToolAttribution(app) + const { registerBlind } = await import('./fixtures/anon-plugin/index.js') + const tools = app.get('tools', false) as { register(def?: unknown): () => void } + const release = registerBlind(tools) + assert.equal(attribution.ownerOf('blind_tool'), 'anon-plugin', 'no reader → the caller stack wins') + release() + assert.equal(attribution.ownerOf('blind_tool'), undefined, 'the disposer forgets the tool') + }) + + test('root and self reader slots fall back to the caller stack', async () => { + const app = new Context() + const fake = { register() { return () => {} } } + app.provide('tools', fake) + const { registerBlind } = await import('./fixtures/anon-plugin/index.js') + let attribution!: ToolAttribution + await app.plugin({ + name: 'dsh-context', + inject: ['tools'], + apply(sub) { + attribution = createToolAttribution(sub) + registerBlind((sub as any).tools) + }, + }) + await app.plugin({ + apply(sub) { + registerBlind((sub as any).tools) + }, + }) + assert.equal(attribution.ownerOf('blind_tool'), 'anon-plugin', 'self/root slots are never taken as-is') + }) + + test('ignores non-tools reads and keeps the static chain intact', async () => { + const app = new Context() + const attribution = createToolAttribution(app) + app.provide('bench', { value: 1 }) + await app.plugin({ + name: 'probe', + apply(sub) { + void (sub as any).bench.value + void (sub as any).bench.value + }, + }) + assert.equal(attribution.ownerOf('grep'), '@deepseek-ai/dsh-tool-fs-search') + }) + + test('tolerates falsy and non-object tools values', async () => { + for (const value of [undefined, 42]) { + const app = new Context() + const attribution = createToolAttribution(app) + app.provide('tools', value) + await app.plugin({ + name: 'probe', + apply(sub) { + void (sub as any).tools + }, + }) + assert.equal(attribution.ownerOf('x'), undefined) + } + }) + + test('leaves instances without a callable register untouched', async () => { + for (const tools of [{ register: 42 }, {}]) { + const app = new Context() + const attribution = createToolAttribution(app) + app.provide('tools', tools) + await app.plugin({ + name: 'probe', + apply(sub) { + void (sub as any).tools + }, + }) + assert.equal(attribution.ownerOf('whatever'), undefined) + } + }) + + test('a foreign wrapper carrying a garbage attribution marker is left untouched', async () => { + const app = new Context() + const attribution = createToolAttribution(app) + // A wrapper that copied the peel-marker name with a non-function value: + // there is no original to recover, so the instance is not re-wrapped. + const foreign = Object.assign(() => 'not-a-disposer', { attributedOriginal: 42 }) + const fake = { register: foreign } + app.provide('tools', fake) + await app.plugin({ + name: 'probe', + apply(sub) { + void (sub as any).tools + }, + }) + assert.equal((app.get('tools', false) as typeof fake).register, foreign, 'the instance was not re-wrapped') + assert.equal(attribution.ownerOf('whatever'), undefined) + }) + + test('ignores registrations without a string tool name or a disposer', async () => { + const app = new Context() + const attribution = createToolAttribution(app) + app.provide('tools', { register() { return undefined } }) + await app.plugin({ + name: 'weird', + apply(sub) { + const tools = (sub as any).tools + tools.register() + tools.register({ name: 42 }) + tools.register({ name: 'sticky' }) + }, + }) + assert.equal(attribution.ownerOf('sticky'), 'weird', 'records even without a disposer') + assert.equal(attribution.ownerOf('42'), undefined, 'non-string tool names are not recorded') + }) + + test('the live record outranks the pinned map; name-derived MCP labels outrank the live record', async () => { + const app = new Context() + const attribution = createToolAttribution(app) + const fake = { register() { return () => {} } } + app.provide('tools', fake) + await app.plugin({ + name: 'third-party', + apply(sub) { + const tools = (sub as any).tools + tools.register({ name: 'bash' }) + tools.register({ name: 'mcp__github__x' }) + tools.register({ name: 'brand_new_tool' }) + }, + }) + assert.equal(attribution.ownerOf('bash'), 'third-party', 'a post-boot registration is the truth, even under a first-party name') + assert.equal(attribution.ownerOf('mcp__github__x'), 'mcp:github', 'the name-derived MCP label names the actual provider') + assert.equal(attribution.ownerOf('brand_new_tool'), 'third-party', 'live record fills the gap') + }) + + test('a pinned first-party name still resolves when it was never registered at runtime', () => { + const app = new Context() + const attribution = createToolAttribution(app) + assert.equal(attribution.ownerOf('bash'), '@deepseek-ai/dsh-tool-bash', 'the pinned map serves boot-time first-party tools') + }) + + test('re-installing the hook re-wraps the instance without stacking wrappers', async () => { + const app = new Context() + let calls = 0 + app.provide('tools', { register() { calls++; return () => {} } }) + const first = createToolAttribution(app) + void first + // The second hook incarnation (a reload) peels the first wrapper back to + // the original: a registration records into the NEW hook only, and the + // original register still runs exactly once. + const second = createToolAttribution(app) + await app.plugin({ + name: 'provider-b', + apply(sub) { + ;((sub as any).tools as { register(def?: unknown): () => void }).register({ name: 'reload_tool' }) + }, + }) + assert.equal(calls, 1, 'the original register ran once, through one wrapper only') + assert.equal(second.ownerOf('reload_tool'), 'provider-b', 'the fresh hook records the registration') + }) + + test('tags boot-time tools whose providers predate the hook as unknown', () => { + const app = new Context() + const fake = { + register() { return () => {} }, + layers: { global: { tools: { entries: () => [ + ['claim_files', {}], + ['pending_write', {}], + ['read', {}], + ] } } }, + } + app.provide('tools', fake) + const attribution = createToolAttribution(app) + assert.equal(attribution.ownerOf('claim_files'), UNKNOWN_TOOL_SOURCE, 'boot-only tools carry the unknown sentinel') + assert.equal(attribution.ownerOf('pending_write'), UNKNOWN_TOOL_SOURCE) + assert.equal(attribution.ownerOf('read'), '@deepseek-ai/dsh-tool-fs', 'the pinned map wins over the unknown tag') + assert.equal(attribution.ownerOf('nope'), undefined, 'names outside the boot snapshot stay untagged') + }) + + test('a later runtime registration overrides the boot-time unknown tag', async () => { + const app = new Context() + const fake = { + register() { return () => {} }, + layers: { global: { tools: { entries: () => [['claim_files', {}]] } } }, + } + app.provide('tools', fake) + const attribution = createToolAttribution(app) + await app.plugin({ + name: 'claim-like', + apply(sub) { + ;((sub as any).tools as { register(def?: unknown): () => void }).register({ name: 'claim_files' }) + }, + }) + assert.equal(attribution.ownerOf('claim_files'), 'claim-like', 'a later live record beats the boot unknown tag') + }) + + test('tolerates tools values without an enumerable boot layer', () => { + for (const tools of [ + undefined, + { register() { return () => {} } }, + { register() { return () => {} }, layers: {} }, + { register() { return () => {} }, layers: { global: {} } }, + { register() { return () => {} }, layers: { global: { tools: {} } } }, + // A host tool service whose enumeration throws must not take the + // caller's startup down — the boot snapshot just stays empty. + { + register() { return () => {} }, + layers: { global: { tools: { entries() { throw new Error('boom') } } } }, + }, + ]) { + const app = new Context() + if (tools !== undefined) app.provide('tools', tools) + const attribution = createToolAttribution(app) + assert.equal(attribution.ownerOf('whatever'), undefined, 'no boot entries → no unknown tag') + } + }) + + test('resolves root-named local-link plugins through the register call stack', async () => { + const app = new Context() + const attribution = createToolAttribution(app) + const fake = { register() { return () => {} } } + app.provide('tools', fake) + const { registerTool } = await import('./fixtures/local-plugin/index.js') + await app.plugin({ + apply(sub) { + registerTool((sub as any).tools) + }, + }) + assert.equal(attribution.ownerOf('local_tool'), 'local-plugin') + }) + + test('callerPackageFrom resolves the registering package from a stack', () => { + const anon = path.normalize(fileURLToPath(new URL('./fixtures/anon-plugin/index.js', import.meta.url))) + const local = path.normalize(fileURLToPath(new URL('./fixtures/local-plugin/index.js', import.meta.url))) + assert.equal(callerPackageFrom(undefined), undefined) + assert.equal(callerPackageFrom('no frames here'), undefined) + // non-frame lines are skipped + assert.equal(callerPackageFrom(`Error\n note\n at ${local}:1:2`), 'local-plugin') + // frames without a :line:col position are skipped, unresolvable targets yield undefined + assert.equal(callerPackageFrom('Error\n at x (file:///D:/no-position.js)\n at y (file:///D:/has-position.js:1:2)'), undefined) + // bare absolute path frames (transpiled modules) resolve + assert.equal(callerPackageFrom(`Error\n at ${local}:1:2\n at ${specFile}:3:4`), 'local-plugin') + // file:// URL frames with fn (...) wrappers resolve + assert.equal(callerPackageFrom(`Error\n at registerBlind (${fileUrl(anon)}:1:2)\n at run (${fileUrl(specFile)}:3:4)`), 'anon-plugin') + // frames resolving to this package are skipped, the next package wins + assert.equal(callerPackageFrom(`Error\n at ${specFile}:1:2\n at ${local}:3:4`), 'local-plugin') + // this module's own frames are skipped too + const attributionFile = path.normalize(fileURLToPath(new URL('../../src/host/attribution.ts', import.meta.url))) + assert.equal(callerPackageFrom(`Error\n at ${attributionFile}:1:2\n at ${local}:3:4`), 'local-plugin') + // unparseable file URLs are skipped + assert.equal(callerPackageFrom(`Error\n at x (file:///D:/bad%zz.js:1:2)\n at y (${fileUrl(local)}:3:4)`), 'local-plugin') + // non-absolute targets (e.g. node: internal frames) are skipped + assert.equal(callerPackageFrom(`Error\n at ModuleJob.run (node:internal/modules/esm/module_job.js:346:20)\n at y (${fileUrl(local)}:3:4)`), 'local-plugin') + // async wrappers are tolerated + assert.equal(callerPackageFrom(`Error\n at async fn (${fileUrl(anon)}:1:2)`), 'anon-plugin') + }) + + test('packageNameFrom walks up to the nearest readable package.json', () => { + const local = fileURLToPath(new URL('./fixtures/local-plugin/index.js', import.meta.url)) + assert.equal(packageNameFrom(local), 'local-plugin') + assert.equal(packageNameFrom(local), 'local-plugin', 'results are cached per directory') + const malformed = fileURLToPath(new URL('./fixtures/bad-pkg/index.js', import.meta.url)) + assert.equal(packageNameFrom(malformed), 'dsh-context', 'a malformed package.json is skipped, the walk continues upward') + const rootless = path.join(path.parse(process.cwd()).root, '__dsh_context_nonexistent__', 'file.js') + assert.equal(packageNameFrom(rootless), undefined, 'no readable boundary up to the filesystem root') + const deep = path.join(path.parse(process.cwd()).root, ...Array.from({ length: 13 }, (_, i) => `__dsh_ctx_l${i}__`), 'file.js') + assert.equal(packageNameFrom(deep), undefined, 'the walk-depth cap returns undefined before reaching the root') + }) +}) \ No newline at end of file diff --git a/tests/host/fixtures/anon-plugin/index.d.ts b/tests/host/fixtures/anon-plugin/index.d.ts new file mode 100644 index 0000000..4036b5e --- /dev/null +++ b/tests/host/fixtures/anon-plugin/index.d.ts @@ -0,0 +1,3 @@ +// Typing for the throwaway plain-JS fixture (the runtime module mirrors this +// exactly); keeps tsc happy without allowJs. +export function registerBlind(tools: { register(def?: unknown): () => void }): () => void diff --git a/tests/host/fixtures/anon-plugin/index.js b/tests/host/fixtures/anon-plugin/index.js new file mode 100644 index 0000000..0b57be9 --- /dev/null +++ b/tests/host/fixtures/anon-plugin/index.js @@ -0,0 +1,7 @@ +// Throwaway fixture emulating an anonymous locally linked plugin: its +// entrypoint carries no name (so cordis falls back to the root fiber name), +// and it registers a tool straight from its own package directory. + +export function registerBlind(tools) { + return tools.register({ name: 'blind_tool' }) +} \ No newline at end of file diff --git a/tests/host/fixtures/anon-plugin/package.json b/tests/host/fixtures/anon-plugin/package.json new file mode 100644 index 0000000..2f9fc22 --- /dev/null +++ b/tests/host/fixtures/anon-plugin/package.json @@ -0,0 +1,6 @@ +{ + "name": "anon-plugin", + "version": "0.0.0", + "private": true, + "type": "module" +} \ No newline at end of file diff --git a/tests/host/fixtures/bad-pkg/index.js b/tests/host/fixtures/bad-pkg/index.js new file mode 100644 index 0000000..5a817bb --- /dev/null +++ b/tests/host/fixtures/bad-pkg/index.js @@ -0,0 +1,6 @@ +// Throwaway fixture with a malformed package.json: exercises the fallback +// walk that keeps ascending to a readable package boundary. + +export function broken() { + return 'broken' +} \ No newline at end of file diff --git a/tests/host/fixtures/bad-pkg/package.json b/tests/host/fixtures/bad-pkg/package.json new file mode 100644 index 0000000..dce92b0 --- /dev/null +++ b/tests/host/fixtures/bad-pkg/package.json @@ -0,0 +1,5 @@ +{ + "name": "bad-pkg", + "version": "0.0.0", + "private": true, + "name-extra": "malformed json" \ No newline at end of file diff --git a/tests/host/fixtures/local-plugin/index.d.ts b/tests/host/fixtures/local-plugin/index.d.ts new file mode 100644 index 0000000..5d3d27d --- /dev/null +++ b/tests/host/fixtures/local-plugin/index.d.ts @@ -0,0 +1,3 @@ +// Typing for the throwaway plain-JS fixture (the runtime module mirrors this +// exactly); keeps tsc happy without allowJs. +export function registerTool(tools: { register(def?: unknown): () => void }): void diff --git a/tests/host/fixtures/local-plugin/index.js b/tests/host/fixtures/local-plugin/index.js new file mode 100644 index 0000000..9f48633 --- /dev/null +++ b/tests/host/fixtures/local-plugin/index.js @@ -0,0 +1,7 @@ +// Throwaway fixture emulating a locally linked third-party plugin: it registers +// tools against a tools service instance handed to it (like a real plugin +// calling `ctx.tools.register(...)` from inside its own package directory). + +export function registerTool(tools) { + tools.register({ name: 'local_tool', description: 'from a local link' }) +} \ No newline at end of file diff --git a/tests/host/fixtures/local-plugin/package.json b/tests/host/fixtures/local-plugin/package.json new file mode 100644 index 0000000..69c0a60 --- /dev/null +++ b/tests/host/fixtures/local-plugin/package.json @@ -0,0 +1,6 @@ +{ + "name": "local-plugin", + "version": "0.0.0", + "private": true, + "type": "module" +} \ No newline at end of file diff --git a/tests/host/headers.spec.ts b/tests/host/headers.spec.ts index 4fd1acd..383a71a 100644 --- a/tests/host/headers.spec.ts +++ b/tests/host/headers.spec.ts @@ -155,6 +155,60 @@ describe('createContextHeadersDefinition', () => { assert.equal(c.wire.view, def.view, 'the wire block shares the view function') assert.equal(c.wire.viewSchema.safeParse(c.wire.view(state)).success, true) }) + + test('a harness-provided plugin field rides the raw tool entry verbatim', () => { + const def = createContextHeadersDefinition() + const view = def.view(fold(def, [headerEvent(1, { + tools: [ + { name: 'mcp__github__get_issue', plugin: 'mcp:github' }, + { name: 'plain', plugin: '' }, // empty plugin behaves as absent + { name: 'naked' }, + ], + })])) + const tools = view.headers[0].tools + assert.equal(tools[0].plugin, 'mcp:github') + assert.ok(!('plugin' in tools[1])) + assert.ok(!('plugin' in tools[2])) + }) + + test('the resolver fills an absent plugin at view time and never overrides a logged one', () => { + const resolve = (name: string): string | undefined => + name === 'bash' ? '@deepseek-ai/dsh-tool-bash' : name === 'mcp__github__x' ? 'mcp:github' : undefined + const def = createContextHeadersDefinition(resolve) + const view = def.view(fold(def, [headerEvent(1, { + tools: [ + { name: 'bash' }, + { name: 'mcp__github__x' }, + { name: 'bash', plugin: 'logged-owner' }, // logged wins over the resolver + { name: 'unknown' }, + ], + })])) + const tools = view.headers[0].tools + assert.equal(tools[0].plugin, '@deepseek-ai/dsh-tool-bash') + assert.equal(tools[1].plugin, 'mcp:github') + assert.equal(tools[2].plugin, 'logged-owner') + assert.ok(!('plugin' in tools[3])) + // The fill is a view-time projection: the folded state stays pure. + assert.ok(!('plugin' in fold(def, [headerEvent(9, { tools: [{ name: 'bash' }] })]).headers[0].tools[0])) + }) + + test('without a resolver no plugin is ever added', () => { + const def = createContextHeadersDefinition() + const view = def.view(fold(def, [headerEvent(1, { tools: [{ name: 'mcp__github__x' }] })])) + assert.ok(!('plugin' in view.headers[0].tools[0])) + }) + + test('plugin-bearing state and views pass both schemas', () => { + const def = createContextHeadersDefinition(name => (name === 'bash' ? '@deepseek-ai/dsh-tool-bash' : undefined)) + const state = fold(def, [headerEvent(1, { + tools: [{ name: 'bash', plugin: 'mcp:github' }, { name: 'read' }], + })]) + const view = def.view(state) + assert.equal(def.schema.safeParse(view).success, true) + const c = compat(def) + assert.deepEqual(c.stateSchema.parse(structuredClone(state)), state) + assert.equal(c.wire.viewSchema.safeParse(c.wire.view(state)).success, true) + }) }) describe('hostile tool entries', () => { diff --git a/tests/host/index.spec.ts b/tests/host/index.spec.ts index f21db0c..bdb77b0 100644 --- a/tests/host/index.spec.ts +++ b/tests/host/index.spec.ts @@ -103,6 +103,58 @@ describe('dsh-context host plugin', () => { assert.ok(seen.some(e => e.key === 'contextHeaders'), 'contextHeaders changes notified') }) + test('tool schema rows carry best-effort plugin attribution through real appends', async () => { + const { ctx } = await boot() + const session = ctx.sessions.create() + session.append('request/header', { + header: { + config: { model: 'deepseek-v4-flash', provider: 'deepseek' }, + tools: [ + { name: 'bash', description: 'run', parameters: { type: 'object' } }, + { name: 'mcp__github__get_issue', description: 'm', parameters: { type: 'object' } }, + { name: 'agent_teams_add_member', description: 't', parameters: { type: 'object' } }, + ], + }, + reason: 'initial', + } as never) + const headers = ctx.sessionProjections.snapshot(session).values.contextHeaders + assert.ok(headers !== undefined, 'contextHeaders served after real appends') + const tools = headers.headers[0].tools + assert.equal(tools.find(t => t.name === 'bash')?.plugin, '@deepseek-ai/dsh-tool-bash') + assert.equal(tools.find(t => t.name === 'mcp__github__get_issue')?.plugin, 'mcp:github') + assert.ok(!('plugin' in tools.find(t => t.name === 'agent_teams_add_member')!), 'unmapped third-party tools stay untagged') + }) + + test('register-hook attribution flows through to the rendered headers', async () => { + const { ctx } = await boot() + // dsh-context booted before any tools service existed; a late-provided + // instance is wrapped on first read inside the registering plugin. + ctx.provide('tools', { register() { return () => {} } }) + await ctx.plugin({ + name: 'my-agent-tools', + apply(agentCtx) { + const tools = (agentCtx as any).tools + tools.register({ name: 'custom_dynamic_tool', description: 'd', parameters: { type: 'object' } }) + }, + }) + const session = ctx.sessions.create() + session.append('request/header', { + header: { + config: { model: 'deepseek-v4-flash', provider: 'deepseek' }, + tools: [ + { name: 'custom_dynamic_tool', description: 'd', parameters: { type: 'object' } }, + { name: 'bash', description: 'run', parameters: { type: 'object' } }, + ], + }, + reason: 'initial', + } as never) + const headers = ctx.sessionProjections.snapshot(session).values.contextHeaders + assert.ok(headers !== undefined, 'contextHeaders served after real appends') + const tools = headers.headers[0].tools + assert.equal(tools.find(t => t.name === 'custom_dynamic_tool')?.plugin, 'my-agent-tools') + assert.equal(tools.find(t => t.name === 'bash')?.plugin, '@deepseek-ai/dsh-tool-bash', 'static backbone still applies') + }) + test('disposing the plugin fiber removes both keys from later snapshots', async () => { const { ctx, fiber } = await boot() const session = ctx.sessions.create() diff --git a/tests/host/toolSources.spec.ts b/tests/host/toolSources.spec.ts new file mode 100644 index 0000000..02f81fe --- /dev/null +++ b/tests/host/toolSources.spec.ts @@ -0,0 +1,57 @@ +// Unit tests for host/toolSources.ts — best-effort tool-to-plugin attribution: +// MCP server recovery from the `mcp__` name prefix, and the pinned first-party +// package map. (A harness-logged `plugin` field, when one appears, passes +// through headers.ts ahead of everything here.) + +import assert from 'node:assert/strict' +import { describe, test } from 'vitest' +import { + FIRST_PARTY_SOURCES, + MCP_PREFIX, + mcpServerOf, + mcpSourceOf, +} from '../../src/host/toolSources' + +describe('mcpServerOf', () => { + test('recovers the server from a plain mcp__server__tool name', () => { + assert.equal(mcpServerOf('mcp__github__get_issue'), 'github') + assert.equal(mcpServerOf('mcp__my_server__send_message'), 'my_server') + assert.equal(mcpServerOf('mcp__a__b__c'), 'a__b', 'server names may themselves contain __') + }) + + test('non-MCP names and malformed prefixes resolve to undefined', () => { + assert.equal(mcpServerOf('bash'), undefined) + assert.equal(mcpServerOf('mcp_server_tool'), undefined, 'no mcp__ prefix') + assert.equal(mcpServerOf('mcp__alone'), undefined, 'the prefix separator is not a server separator') + assert.equal(mcpServerOf('mcp____'), undefined, 'a second separator right at the prefix edge') + assert.equal(mcpServerOf('mcp____tool'), undefined, 'an empty server between separators') + }) + + test('hash-appended overlong names resolve the server that survived truncation', () => { + const hex64 = '0123456789abcdef'.repeat(4) + // Overlong names get '_<64-hex>' appended; the last __ separator survives + // unless the truncation cut into it. + assert.equal(mcpServerOf(`mcp__github__tool_${hex64}`), 'github') + assert.equal(mcpServerOf(`mcp__github__tool_${'z'.repeat(64)}`), 'github') + }) +}) + +describe('mcpSourceOf', () => { + test('labels proxied tools with their server; other names stay unattributed', () => { + assert.equal(mcpSourceOf('mcp__github__get_issue'), 'mcp:github') + assert.equal(mcpSourceOf('mcp__alone'), undefined) + assert.equal(mcpSourceOf('bash'), undefined) + }) +}) + +describe('FIRST_PARTY_SOURCES', () => { + test('the pinned map is frozen and carries the documented core names', () => { + assert.ok(Object.isFrozen(FIRST_PARTY_SOURCES)) + for (const name of ['read', 'write', 'edit', 'bash', 'pwsh', 'glob', 'grep', + 'web_search', 'web_fetch', 'job_kill', 'ask_user_question', 'plan', 'skill', + 'todo_write', 'subagent', 'send_message', 'ralph', 'workflow', 'run_code']) { + assert.ok(name in FIRST_PARTY_SOURCES, `expected ${name} in the first-party map`) + } + assert.equal(MCP_PREFIX, 'mcp__') + }) +})