Diffs: route plugin context through artifacts

This commit is contained in:
Gustavo Madeira Santana 2026-03-19 00:22:43 -04:00
parent a98ffa41d0
commit 83d284610c
No known key found for this signature in database
11 changed files with 204 additions and 24 deletions

View File

@ -111,6 +111,7 @@ All fields are optional unless noted:
- `lang` (`string`): language override hint for before and after mode.
- `title` (`string`): viewer title override.
- `mode` (`"view" | "file" | "both"`): output mode. Defaults to plugin default `defaults.mode`.
Deprecated alias: `"image"` behaves like `"file"` and is still accepted for backward compatibility.
- `theme` (`"light" | "dark"`): viewer theme. Defaults to plugin default `defaults.theme`.
- `layout` (`"unified" | "split"`): diff layout. Defaults to plugin default `defaults.layout`.
- `expandUnchanged` (`boolean`): expand unchanged sections when full context is available. Per-call option only (not a plugin default key).
@ -150,9 +151,12 @@ Shared fields for modes that create a viewer:
- `inputKind`
- `fileCount`
- `mode`
- `context` (`agentId`, `sessionId`, `messageChannel`, `agentAccountId` when available)
File fields when PNG or PDF is rendered:
- `artifactId`
- `expiresAt`
- `filePath`
- `path` (same value as `filePath`, for message tool compatibility)
- `fileBytes`

View File

@ -15,6 +15,8 @@ The tool can return:
- `details.viewerUrl`: a gateway URL that can be opened in the canvas
- `details.filePath`: a local rendered artifact path when file rendering is requested
- `details.fileFormat`: the rendered file format (`png` or `pdf`)
- `details.artifactId` and `details.expiresAt`: artifact identity and TTL metadata
- `details.context`: available routing metadata such as `agentId`, `sessionId`, `messageChannel`, and `agentAccountId`
When the plugin is enabled, it also ships a companion skill from `skills/` and prepends stable tool-usage guidance into system-prompt space via `before_prompt_build`. The hook uses `prependSystemContext`, so the guidance stays out of user-prompt space while still being available every turn.
@ -49,6 +51,7 @@ Patch:
Useful options:
- `mode`: `view`, `file`, or `both`
Deprecated alias: `image` behaves like `file` and is still accepted for backward compatibility.
- `layout`: `unified` or `split`
- `theme`: `light` or `dark` (default: `dark`)
- `fileFormat`: `png` or `pdf` (default: `png`)

View File

@ -2,7 +2,7 @@ import type { IncomingMessage } from "node:http";
import { describe, expect, it, vi } from "vitest";
import { createMockServerResponse } from "../../test/helpers/extensions/mock-http-response.js";
import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js";
import type { OpenClawPluginApi } from "./api.js";
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "./api.js";
import plugin from "./index.js";
describe("diffs plugin registration", () => {
@ -48,7 +48,9 @@ describe("diffs plugin registration", () => {
};
type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
let registeredTool: RegisteredTool | undefined;
let registeredToolFactory:
| ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined)
| undefined;
let registeredHttpRouteHandler: RegisteredHttpRouteParams["handler"] | undefined;
const api = createTestPluginApi({
@ -75,7 +77,7 @@ describe("diffs plugin registration", () => {
},
runtime: {} as never,
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
registeredTool = typeof tool === "function" ? undefined : tool;
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
},
registerHttpRoute(params: RegisteredHttpRouteParams) {
registeredHttpRouteHandler = params.handler;
@ -84,6 +86,12 @@ describe("diffs plugin registration", () => {
plugin.register?.(api as unknown as OpenClawPluginApi);
const registeredTool = registeredToolFactory?.({
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
}) as RegisteredTool | undefined;
const result = await registeredTool?.execute?.("tool-1", {
before: "one\n",
after: "two\n",
@ -108,6 +116,14 @@ describe("diffs plugin registration", () => {
expect(String(res.body)).toContain('"disableLineNumbers":true');
expect(String(res.body)).toContain('"diffIndicators":"classic"');
expect(String(res.body)).toContain("--diffs-line-height: 30px;");
expect((result as { details?: Record<string, unknown> } | undefined)?.details?.context).toEqual(
{
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
},
);
});
});

View File

@ -1,6 +1,9 @@
import path from "node:path";
import type { OpenClawPluginApi } from "./api.js";
import { resolvePreferredOpenClawTmpDir } from "./api.js";
import {
definePluginEntry,
resolvePreferredOpenClawTmpDir,
type OpenClawPluginApi,
} from "./api.js";
import {
diffsPluginConfigSchema,
resolveDiffsPluginDefaults,
@ -11,7 +14,7 @@ import { DIFFS_AGENT_GUIDANCE } from "./src/prompt-guidance.js";
import { DiffArtifactStore } from "./src/store.js";
import { createDiffsTool } from "./src/tool.js";
const plugin = {
export default definePluginEntry({
id: "diffs",
name: "Diffs",
description: "Read-only diff viewer and PNG/PDF renderer for agents.",
@ -24,7 +27,9 @@ const plugin = {
logger: api.logger,
});
api.registerTool(createDiffsTool({ api, store, defaults }));
api.registerTool((ctx) => createDiffsTool({ api, store, defaults, context: ctx }), {
name: "diffs",
});
api.registerHttpRoute({
path: "/plugins/diffs",
auth: "plugin",
@ -39,6 +44,4 @@ const plugin = {
prependSystemContext: DIFFS_AGENT_GUIDANCE,
}));
},
};
export default plugin;
});

View File

@ -1,7 +1,9 @@
import fs from "node:fs";
import { describe, expect, it } from "vitest";
import {
DEFAULT_DIFFS_PLUGIN_SECURITY,
DEFAULT_DIFFS_TOOL_DEFAULTS,
diffsPluginConfigSchema,
resolveDiffImageRenderOptions,
resolveDiffsPluginDefaults,
resolveDiffsPluginSecurity,
@ -165,3 +167,13 @@ describe("resolveDiffsPluginSecurity", () => {
});
});
});
describe("diffs plugin schema surfaces", () => {
it("keeps the runtime json schema in sync with the manifest config schema", () => {
const manifest = JSON.parse(
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
) as { configSchema?: unknown };
expect(diffsPluginConfigSchema.jsonSchema).toEqual(manifest.configSchema);
});
});

View File

@ -28,10 +28,22 @@ describe("DiffArtifactStore", () => {
title: "Demo",
inputKind: "before_after",
fileCount: 1,
context: {
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
},
});
const loaded = await store.getArtifact(artifact.id, artifact.token);
expect(loaded?.id).toBe(artifact.id);
expect(loaded?.context).toEqual({
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
});
expect(await store.readHtml(artifact.id)).toBe("<html>demo</html>");
});
@ -97,10 +109,19 @@ describe("DiffArtifactStore", () => {
});
it("creates standalone file artifacts with managed metadata", async () => {
const standalone = await store.createStandaloneFileArtifact();
const standalone = await store.createStandaloneFileArtifact({
context: {
agentId: "main",
sessionId: "session-123",
},
});
expect(standalone.filePath).toMatch(/preview\.png$/);
expect(standalone.filePath).toContain(rootDir);
expect(Date.parse(standalone.expiresAt)).toBeGreaterThan(Date.now());
expect(standalone.context).toEqual({
agentId: "main",
sessionId: "session-123",
});
});
it("expires standalone file artifacts using ttl metadata", async () => {

View File

@ -2,7 +2,7 @@ import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { PluginLogger } from "../api.js";
import type { DiffArtifactMeta, DiffOutputFormat } from "./types.js";
import type { DiffArtifactContext, DiffArtifactMeta, DiffOutputFormat } from "./types.js";
const DEFAULT_TTL_MS = 30 * 60 * 1000;
const MAX_TTL_MS = 6 * 60 * 60 * 1000;
@ -16,11 +16,13 @@ type CreateArtifactParams = {
inputKind: DiffArtifactMeta["inputKind"];
fileCount: number;
ttlMs?: number;
context?: DiffArtifactContext;
};
type CreateStandaloneFileArtifactParams = {
format?: DiffOutputFormat;
ttlMs?: number;
context?: DiffArtifactContext;
};
type StandaloneFileMeta = {
@ -29,6 +31,7 @@ type StandaloneFileMeta = {
createdAt: string;
expiresAt: string;
filePath: string;
context?: DiffArtifactContext;
};
type ArtifactMetaFileName = "meta.json" | "file-meta.json";
@ -69,6 +72,7 @@ export class DiffArtifactStore {
expiresAt: expiresAt.toISOString(),
viewerPath: `${VIEWER_PREFIX}/${id}/${token}`,
htmlPath,
...(params.context ? { context: params.context } : {}),
};
await fs.mkdir(artifactDir, { recursive: true });
@ -127,7 +131,7 @@ export class DiffArtifactStore {
async createStandaloneFileArtifact(
params: CreateStandaloneFileArtifactParams = {},
): Promise<{ id: string; filePath: string; expiresAt: string }> {
): Promise<{ id: string; filePath: string; expiresAt: string; context?: DiffArtifactContext }> {
await this.ensureRoot();
const id = crypto.randomBytes(10).toString("hex");
@ -143,6 +147,7 @@ export class DiffArtifactStore {
createdAt: createdAt.toISOString(),
expiresAt,
filePath: this.normalizeStoredPath(filePath, "filePath"),
...(params.context ? { context: params.context } : {}),
};
await fs.mkdir(artifactDir, { recursive: true });
@ -152,6 +157,7 @@ export class DiffArtifactStore {
id,
filePath: meta.filePath,
expiresAt: meta.expiresAt,
...(meta.context ? { context: meta.context } : {}),
};
}
@ -268,6 +274,7 @@ export class DiffArtifactStore {
createdAt: value.createdAt,
expiresAt: value.expiresAt,
filePath: this.normalizeStoredPath(value.filePath, "filePath"),
...(value.context ? { context: normalizeArtifactContext(value.context) } : {}),
};
} catch (error) {
this.logger?.warn(`Failed to normalize standalone diff metadata for ${id}: ${String(error)}`);
@ -356,3 +363,23 @@ function isExpired(meta: { expiresAt: string }): boolean {
function isFileNotFound(error: unknown): boolean {
return error instanceof Error && "code" in error && error.code === "ENOENT";
}
function normalizeArtifactContext(value: unknown): DiffArtifactContext | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const raw = value as Record<string, unknown>;
const context = {
agentId: normalizeOptionalString(raw.agentId),
sessionId: normalizeOptionalString(raw.sessionId),
messageChannel: normalizeOptionalString(raw.messageChannel),
agentAccountId: normalizeOptionalString(raw.agentAccountId),
};
return Object.values(context).some((entry) => entry !== undefined) ? context : undefined;
}
function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}

View File

@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../../test/helpers/extensions/plugin-api.js";
import type { OpenClawPluginApi } from "../api.js";
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../api.js";
import type { DiffScreenshotter } from "./browser.js";
import { DEFAULT_DIFFS_TOOL_DEFAULTS } from "./config.js";
import { DiffArtifactStore } from "./store.js";
@ -137,6 +137,8 @@ describe("diffs tool", () => {
});
expectArtifactOnlyFileResult(screenshotter, result);
expect((result?.details as Record<string, unknown>).artifactId).toEqual(expect.any(String));
expect((result?.details as Record<string, unknown>).expiresAt).toEqual(expect.any(String));
});
it("honors ttlSeconds for artifact-only file output", async () => {
@ -316,6 +318,12 @@ describe("diffs tool", () => {
fontFamily: "JetBrains Mono",
fontSize: 17,
},
context: {
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
},
});
const result = await tool.execute?.("tool-5", {
@ -326,6 +334,12 @@ describe("diffs tool", () => {
expect(readTextContent(result, 0)).toContain("Diff viewer ready.");
expect((result?.details as Record<string, unknown>).mode).toBe("view");
expect((result?.details as Record<string, unknown>).context).toEqual({
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
});
const viewerPath = String((result?.details as Record<string, unknown>).viewerPath);
const [id] = viewerPath.split("/").filter(Boolean).slice(-2);
@ -381,6 +395,29 @@ describe("diffs tool", () => {
const html = await store.readHtml(id);
expect(html).toContain('body data-theme="dark"');
});
it("routes tool context into artifact details for file mode", async () => {
const screenshotter = createPngScreenshotter();
const tool = createToolWithScreenshotter(store, screenshotter, DEFAULT_DIFFS_TOOL_DEFAULTS, {
agentId: "reviewer",
sessionId: "session-456",
messageChannel: "telegram",
agentAccountId: "work",
});
const result = await tool.execute?.("tool-context-file", {
before: "one\n",
after: "two\n",
mode: "file",
});
expect((result?.details as Record<string, unknown>).context).toEqual({
agentId: "reviewer",
sessionId: "session-456",
messageChannel: "telegram",
agentAccountId: "work",
});
});
});
function createApi(): OpenClawPluginApi {
@ -403,12 +440,19 @@ function createToolWithScreenshotter(
store: DiffArtifactStore,
screenshotter: DiffScreenshotter,
defaults = DEFAULT_DIFFS_TOOL_DEFAULTS,
context: OpenClawPluginToolContext | undefined = {
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
},
) {
return createDiffsTool({
api: createApi(),
store,
defaults,
screenshotter,
context,
});
}

View File

@ -1,11 +1,11 @@
import fs from "node:fs/promises";
import { Static, Type } from "@sinclair/typebox";
import type { AnyAgentTool, OpenClawPluginApi } from "../api.js";
import type { AnyAgentTool, OpenClawPluginApi, OpenClawPluginToolContext } from "../api.js";
import { PlaywrightDiffScreenshotter, type DiffScreenshotter } from "./browser.js";
import { resolveDiffImageRenderOptions } from "./config.js";
import { renderDiffDocument } from "./render.js";
import type { DiffArtifactStore } from "./store.js";
import type { DiffRenderOptions, DiffToolDefaults } from "./types.js";
import type { DiffArtifactContext, DiffRenderOptions, DiffToolDefaults } from "./types.js";
import {
DIFF_IMAGE_QUALITY_PRESETS,
DIFF_LAYOUTS,
@ -64,7 +64,10 @@ const DiffsToolSchema = Type.Object(
}),
),
mode: Type.Optional(
stringEnum(DIFF_MODES, "Output mode: view, file, image, or both. Default: both."),
stringEnum(
DIFF_MODES,
"Output mode: view, file, image (deprecated alias for file), or both. Default: both.",
),
),
theme: Type.Optional(stringEnum(DIFF_THEMES, "Viewer theme. Default: dark.")),
layout: Type.Optional(stringEnum(DIFF_LAYOUTS, "Diff layout. Default: unified.")),
@ -135,6 +138,7 @@ export function createDiffsTool(params: {
store: DiffArtifactStore;
defaults: DiffToolDefaults;
screenshotter?: DiffScreenshotter;
context?: OpenClawPluginToolContext;
}): AnyAgentTool {
return {
name: "diffs",
@ -144,6 +148,7 @@ export function createDiffsTool(params: {
parameters: DiffsToolSchema,
execute: async (_toolCallId, rawParams) => {
const toolParams = rawParams as DiffsToolRawParams;
const artifactContext = buildArtifactContext(params.context);
const input = normalizeDiffInput(toolParams);
const mode = normalizeMode(toolParams.mode, params.defaults.mode);
const theme = normalizeTheme(toolParams.theme, params.defaults.theme);
@ -181,6 +186,7 @@ export function createDiffsTool(params: {
theme,
image,
ttlMs,
context: artifactContext,
});
return {
@ -195,10 +201,13 @@ export function createDiffsTool(params: {
],
details: buildArtifactDetails({
baseDetails: {
...(artifactFile.artifactId ? { artifactId: artifactFile.artifactId } : {}),
...(artifactFile.expiresAt ? { expiresAt: artifactFile.expiresAt } : {}),
title: rendered.title,
inputKind: rendered.inputKind,
fileCount: rendered.fileCount,
mode,
...(artifactContext ? { context: artifactContext } : {}),
},
artifactFile,
image,
@ -212,6 +221,7 @@ export function createDiffsTool(params: {
inputKind: rendered.inputKind,
fileCount: rendered.fileCount,
ttlMs,
context: artifactContext,
});
const viewerUrl = buildViewerUrl({
@ -229,6 +239,7 @@ export function createDiffsTool(params: {
inputKind: artifact.inputKind,
fileCount: artifact.fileCount,
mode,
...(artifactContext ? { context: artifactContext } : {}),
};
if (mode === "view") {
@ -351,15 +362,18 @@ async function renderDiffArtifactFile(params: {
theme: DiffTheme;
image: DiffRenderOptions["image"];
ttlMs?: number;
}): Promise<{ path: string; bytes: number }> {
context?: DiffArtifactContext;
}): Promise<{ path: string; bytes: number; artifactId?: string; expiresAt?: string }> {
const standaloneArtifact = params.artifactId
? undefined
: await params.store.createStandaloneFileArtifact({
format: params.image.format,
ttlMs: params.ttlMs,
context: params.context,
});
const outputPath = params.artifactId
? params.store.allocateFilePath(params.artifactId, params.image.format)
: (
await params.store.createStandaloneFileArtifact({
format: params.image.format,
ttlMs: params.ttlMs,
})
).filePath;
: standaloneArtifact!.filePath;
await params.screenshotter.screenshotHtml({
html: params.html,
@ -372,9 +386,35 @@ async function renderDiffArtifactFile(params: {
return {
path: outputPath,
bytes: stats.size,
...(standaloneArtifact?.id ? { artifactId: standaloneArtifact.id } : {}),
...(standaloneArtifact?.expiresAt ? { expiresAt: standaloneArtifact.expiresAt } : {}),
};
}
function buildArtifactContext(
context: OpenClawPluginToolContext | undefined,
): DiffArtifactContext | undefined {
if (!context) {
return undefined;
}
const artifactContext = {
agentId: normalizeContextString(context.agentId),
sessionId: normalizeContextString(context.sessionId),
messageChannel: normalizeContextString(context.messageChannel),
agentAccountId: normalizeContextString(context.agentAccountId),
};
return Object.values(artifactContext).some((value) => value !== undefined)
? artifactContext
: undefined;
}
function normalizeContextString(value: string | undefined): string | undefined {
const normalized = value?.trim();
return normalized ? normalized : undefined;
}
function normalizeDiffInput(params: DiffsToolParams): DiffInput {
const patch = params.patch?.trim();
const before = params.before;

View File

@ -99,6 +99,13 @@ export type RenderedDiffDocument = {
inputKind: DiffInput["kind"];
};
export type DiffArtifactContext = {
agentId?: string;
sessionId?: string;
messageChannel?: string;
agentAccountId?: string;
};
export type DiffArtifactMeta = {
id: string;
token: string;
@ -109,6 +116,7 @@ export type DiffArtifactMeta = {
fileCount: number;
viewerPath: string;
htmlPath: string;
context?: DiffArtifactContext;
filePath?: string;
imagePath?: string;
};

View File

@ -1,11 +1,13 @@
// Narrow plugin-sdk surface for the bundled diffs plugin.
// Keep this list additive and scoped to symbols used under extensions/diffs.
export { definePluginEntry } from "./core.js";
export type { OpenClawConfig } from "../config/config.js";
export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
export type {
AnyAgentTool,
OpenClawPluginApi,
OpenClawPluginConfigSchema,
OpenClawPluginToolContext,
PluginLogger,
} from "../plugins/types.js";