fix(dsh-plugin): support DSH 0.2 hosts

This commit is contained in:
drakezhang
2026-09-29 17:49:44 +08:00
parent 8b3cfffe38
commit bf5a8d9685
11 changed files with 147 additions and 24 deletions
@@ -37,6 +37,11 @@ In a conversation, try:
By default, the browser tools become available when the skill is invoked.
The plugin declares support for DSH `^0.1.5-rc.3 || ^0.2.0-rc.1`.
DSH 0.2 checks these peer requirements before loading the plugin; versions outside
this range are not declared compatible. The range preserves the existing 0.1.x
baseline and admits 0.2.x without opting into future 0.3.x hosts.
## Updating
Installed plugins do not update automatically. To upgrade this plugin to npm's
@@ -125,8 +125,12 @@ events continue to fold without repeated registration attempts or warnings.
explicitly shared platform modules (React and `dsh-client-ui-primitives`) external,
everything else inlined, CSS Modules compiled by lightningcss.
- **Host compatibility**: development dependencies are pinned to the DSH `0.1.5-rc.3`
SDK shipped with DSH `0.1.5-rc.2`, including the current renderer, Session Controller,
and chat type contracts. Previously, tests used `0.1.0-rc.6`, which exported `MessageImage`
SDK as the minimum build baseline; peer requirements also admit `0.2.0-rc.1`
and the 0.2.x line. Keep the 0.3.x boundary closed until that host is verified.
Agent-scoped skills use the shared `agent/created` lifecycle event. History
recovery accepts both 0.1 tool-result blocks and 0.2 tool-result envelopes,
preserving call identity and success checks. Previously, tests used `0.1.0-rc.6`,
which exported `MessageImage`
from the attachment client; the current attachment client exposes only plugin hooks.
Importing the old component caused the screenshot-card expansion crash. Keep service
contracts type-only and restrict runtime imports to the host's shared module table.
@@ -67,11 +67,11 @@
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.2",
"@deepseek-ai/dsh-attachment": "^0.1.5-rc.3",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.3",
"@deepseek-ai/dsh-client-ui-tool": "^0.1.5-rc.3",
"@deepseek-ai/dsh-llm": "^0.1.5-rc.3",
"@deepseek-ai/dsh-tools": "^0.1.5-rc.3",
"@deepseek-ai/dsh-attachment": "^0.1.5-rc.3 || ^0.2.0-rc.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.3 || ^0.2.0-rc.1",
"@deepseek-ai/dsh-client-ui-tool": "^0.1.5-rc.3 || ^0.2.0-rc.1",
"@deepseek-ai/dsh-llm": "^0.1.5-rc.3 || ^0.2.0-rc.1",
"@deepseek-ai/dsh-tools": "^0.1.5-rc.3 || ^0.2.0-rc.1",
"@deepseek-ai/schemastery": "^3.18.2",
"react": "^18.2.0"
},
@@ -119,12 +119,14 @@
"@types/node": "^22.10.0",
"@types/react": "~18.3.1",
"@types/react-dom": "^18.3.0",
"@types/semver": "7.7.1",
"happy-dom": "^15.11.7",
"lightningcss": "^1.30.0",
"postcss": "^8.5.0",
"postcss-prefix-selector": "^2.1.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"semver": "7.8.5",
"tailwindcss": "^4.3.3",
"tsdown": "^0.22.14",
"typescript": "^5.8.0",
@@ -33,7 +33,7 @@ interface ViewModel {
readonly title: string;
}
const TERMINAL_LABELS: TerminalBlockLabels = {
const TERMINAL_LABELS: TerminalBlockLabels & { noExitCode: string } = {
signal: (signal) => `Signal ${signal}`,
exitCode: (exitCode) => `Exit code ${exitCode}`,
running: "Running",
@@ -42,6 +42,7 @@ const TERMINAL_LABELS: TerminalBlockLabels = {
copy: "Copy",
copied: "Copied",
noOutput: "No output",
noExitCode: "No exit code",
collapseAria: "Collapse output",
collapse: "Collapse",
expandAria: (hidden) => `Expand the remaining ${hidden} output lines`,
@@ -128,7 +129,8 @@ function commandOf(argsRaw: string, callId: string): { command: string; title: s
/** Derive the display model from the frozen block only. */
export function viewModelOf(block: ToolCallViewProps["block"]): ViewModel {
const settled = "kind" in block;
const argsRaw = (settled ? block.call?.argsRaw : block.argsRaw) ?? "";
// DSH 0.2 can render a preparing call before arguments have been dispatched.
const argsRaw = (settled ? block.call?.argsRaw : "argsRaw" in block ? block.argsRaw : "") ?? "";
const { command, title } = commandOf(argsRaw, block.callId);
if (!settled) {
return { state: "running", command, output: null, image: null, summary: command, title };
@@ -20,8 +20,9 @@
* tool-skill's per-step catalog digest treats visibility changes as a
* first-class cache-invalidation input — the suite simply appears in the
* NEXT step's assembly.
* - Durable results use message.source.callId and a tool-result content
* block. Older hosts stored callId/isError directly on the message.
* - DSH 0.2 stores toolCallId/isError on the message; DSH 0.1 uses a
* tool-result content block. Both carry message.source.callId. Older hosts
* stored callId/isError directly on the message.
*/
import type { Context } from "@deepseek-ai/cordis";
@@ -61,6 +62,16 @@ function toolResultOf(data: unknown): { callId: string; isError: boolean } | und
if (message === undefined) return;
const source = record(message.source);
if (source?.kind === "tool") {
if (message.role === "tool" && "toolCallId" in message) {
if (
isCallId(source.callId) &&
message.toolCallId === source.callId &&
typeof message.isError === "boolean"
) {
return { callId: source.callId, isError: message.isError };
}
return;
}
const blocks = message.content;
const block = Array.isArray(blocks) && blocks.length === 1 ? record(blocks[0]) : undefined;
if (
@@ -67,8 +67,8 @@ export function registerBskSkill(ctx: Context): () => void {
* Registering the embedded skill through `agent.ctx` makes the DSH protocol
* contract authoritative for that agent without touching the shared CLI skill.
*
* New agents are handled at `agent/session-start`, the first supported startup
* injection point and still before the first prompt assembly. Existing agents
* New agents are handled at `agent/created`, supported by both DSH 0.1 and 0.2
* after agent setup and before the first prompt assembly. Existing agents
* are registered immediately so plugin reloads take effect without recreating
* the conversation. Returns a disposer for all plugin-owned registrations.
*/
@@ -81,11 +81,12 @@ export function armAgentScopedBskSkill(ctx: Context): () => void {
registrations.set(agent, registerBskSkill(agent.ctx));
};
let stopSessionStart = () => {};
let stopCreated = () => {};
let stopDisposed = () => {};
if (typeof ctx.on === "function") {
stopSessionStart = ctx.on("agent/session-start", ({ agent }) => {
stopCreated = ctx.on("agent/created", ({ agent }) => {
registerForAgent(agent);
return undefined;
});
stopDisposed = ctx.on("agent/disposed", ({ agent }) => {
// Agent-scoped effects have already unwound at this lifecycle edge. Drop
@@ -103,7 +104,7 @@ export function armAgentScopedBskSkill(ctx: Context): () => void {
if (!active) return;
active = false;
stopDisposed();
stopSessionStart();
stopCreated();
for (const unregister of [...registrations.values()].reverse()) unregister();
registrations.clear();
};
@@ -57,6 +57,14 @@ const PATH_TEXT = "[session s1] screenshot saved to /tmp/shot.png (800x457px, 8
afterEach(cleanup);
describe("viewModelOf", () => {
it("keeps a DSH 0.2 preparing call renderable before arguments exist", () => {
const model = viewModelOf({ callId: "c1", name: "browser_inspect" } as never);
expect(model.state).toBe("running");
expect(model.command).toBe("browser_inspect (c1)");
expect(model.image).toBeNull();
expect(model.output).toBeNull();
});
it("derives the running model from the call frame", () => {
const model = viewModelOf(runningBlock('{"action":"screenshot","session":"s1"}'));
expect(model.state).toBe("running");
@@ -0,0 +1,42 @@
import { readFileSync } from "node:fs";
import { satisfies } from "semver";
import { describe, expect, it } from "vitest";
const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
peerDependencies: Record<string, string>;
};
const dshPeers = Object.entries(manifest.peerDependencies).filter(
([name]) => name === "@deepseek-ai/dsh" || name.startsWith("@deepseek-ai/dsh-"),
);
// DSH checks every DSH peer against its runtime version before loading the
// bundle, including optional peers. Its preflight includes prereleases.
describe("DSH host admission", () => {
it("declares host peer requirements", () => {
expect(dshPeers.length).toBeGreaterThan(0);
});
describe.each(dshPeers)("%s (%s)", (_name, range) => {
it.each([
"0.1.5-rc.3",
"0.1.5",
"0.1.7-rc.2",
"0.2.0-rc.1",
"0.2.0",
"0.2.1",
])("admits supported host %s", (version) => {
expect(satisfies(version, range, { includePrerelease: true })).toBe(true);
});
it.each([
"0.1.0",
"0.1.5-rc.2",
"0.2.0-rc.0",
"0.3.0-rc.1",
"0.3.0",
"1.0.0",
])("keeps unsupported host %s outside the declared range", (version) => {
expect(satisfies(version, range, { includePrerelease: true })).toBe(false);
});
});
});
@@ -528,13 +528,45 @@ describe("hasSuccessfulSkillInvocation", () => {
{ type: "tool/result", data: { message: { isError: false } } },
]),
).toBe(false);
const message = structuredClone(skillResult().data.message);
message.content[0].toolCallId = "different" as never;
const message = {
source: { kind: "tool", callId: "skill-1" },
content: [{ type: "tool-result", toolCallId: "different", isError: false }],
};
expect(
hasSuccessfulSkillInvocation([skillCall(), { type: "tool/result", data: { message } }]),
).toBe(false);
});
it("restores DSH 0.2 results with matching envelope identities and a successful outcome", () => {
const message = {
role: "tool",
source: { kind: "tool", callId: "skill-1" },
toolCallId: "skill-1",
content: [{ type: "text", text: "skill instructions" }],
isError: false,
};
const result = { type: "tool/result", data: { message } };
expect(hasSuccessfulSkillInvocation([skillCall(), result])).toBe(true);
expect(hasSuccessfulSkillInvocation([result])).toBe(false);
expect(hasSuccessfulSkillInvocation([result, skillCall()])).toBe(false);
for (const invalid of [
{ ...message, isError: true },
{ ...message, isError: undefined },
{ ...message, role: "assistant" },
{ ...message, toolCallId: "different" },
{ ...message, source: { kind: "tool", callId: "different" } },
{ ...message, source: { kind: "tool" } },
]) {
expect(
hasSuccessfulSkillInvocation([
skillCall(),
{ type: "tool/result", data: { message: invalid } },
]),
).toBe(false);
}
});
it("pairs call and result by callId and honors gestures", () => {
expect(hasSuccessfulSkillInvocation([])).toBe(false);
expect(
@@ -110,7 +110,7 @@ describe("registerBskSkill", () => {
});
describe("armAgentScopedBskSkill", () => {
it("overrides a nearer legacy CLI skill when the DSH agent starts", async () => {
it("overrides a nearer legacy CLI skill when the DSH agent is created", async () => {
const root = new Context();
const skillFiber = root.plugin(SkillRegistry);
await skillFiber;
@@ -143,10 +143,8 @@ describe("armAgentScopedBskSkill", () => {
expect(before?.source).toBe("user-agents");
const disarm = armAgentScopedBskSkill(pluginCtx);
pluginCtx.emit(scopeTarget(agent, agentKey), "agent/session-start", {
agent,
source: "startup",
});
const payload = { agent, source: "startup" as const };
await pluginCtx.serial(scopeTarget(agent, agentKey), "agent/created", payload);
const after = await skillFiber.ctx.skills.get("browser-skill", { scope: agentKey });
expect(after?.content).toMatch(/All browser work\s+must use the injected tools directly/);
@@ -188,7 +186,7 @@ describe("armAgentScopedBskSkill", () => {
expect(registered).toHaveLength(1);
// A later lifecycle notification for the same agent must not duplicate it.
listeners.get("agent/session-start")?.({ agent });
listeners.get("agent/created")?.({ agent });
expect(registered).toHaveLength(1);
disarm();
+18
View File
@@ -167,6 +167,9 @@ importers:
'@types/react-dom':
specifier: ^18.3.0
version: 18.3.7(@types/react@18.3.31)
'@types/semver':
specifier: 7.7.1
version: 7.7.1
happy-dom:
specifier: ^15.11.7
version: 15.11.7
@@ -185,6 +188,9 @@ importers:
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
semver:
specifier: 7.8.5
version: 7.8.5
tailwindcss:
specifier: ^4.3.3
version: 4.3.3
@@ -1812,6 +1818,9 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/semver@7.7.1':
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@@ -3617,6 +3626,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
set-value@4.1.0:
resolution: {integrity: sha512-zTEg4HL0RwVrqcWs3ztF+x1vkxfm0lP+MQQFPiMJTKVceBwEV0A569Ou8l9IYQG8jOZdMVI1hGsc0tmeD2o/Lw==}
engines: {node: '>=11.0'}
@@ -5482,6 +5496,8 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/semver@7.7.1': {}
'@types/unist@3.0.3': {}
'@ungap/structured-clone@1.4.0': {}
@@ -7377,6 +7393,8 @@ snapshots:
semver@7.8.0: {}
semver@7.8.5: {}
set-value@4.1.0:
dependencies:
is-plain-object: 2.0.4