chore(providers): integrate current main with provider artwork

This commit is contained in:
paseo-bot[bot]
2026-10-18 16:03:38 +02:00
1052 changed files with 61212 additions and 17946 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
# Protocol Validator Codegen
This directory is build-time only. `ws-outbound.compile.ts` is the zod-aot discovery entry for the inbound WebSocket validator.
This directory is build-time only. The generator writes `generated/ws-outbound.compile.ts`, the zod-aot discovery entry for the inbound WebSocket validators: one for the envelope and one per session message type.
The generated runtime file is written to `../src/generated/validation/ws-outbound.aot.ts` and is not committed. The protocol package owns every generation trigger through its npm lifecycle scripts.
The discovery entry and the generated runtime files under `../src/generated/validation/` are not committed. The protocol package owns every generation trigger through its npm lifecycle scripts.
`zod-aot` is exact-pinned, and the protocol generator applies the small compiler patches it requires before generation. Treat changes to those patches like compiler changes: regenerate, inspect the output, and run the protocol validation regression tests before shipping.
@@ -1,4 +0,0 @@
import { compile } from "zod-aot";
import { WSOutboundMessageSchema as SourceWSOutboundMessageSchema } from "../src/messages.js";
export const WSOutboundMessageSchema = compile(SourceWSOutboundMessageSchema);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.11.0-beta.5",
"version": "0.12.0-beta.3",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
@@ -4,8 +4,12 @@ import { dirname, relative, resolve, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const source = resolve(packageRoot, "codegen/ws-outbound.compile.ts");
const runtimeSchemaMetadata = resolve(packageRoot, "src/validation/ws-outbound-schema-metadata.ts");
const envelopeSource = resolve(packageRoot, "src/validation/ws-outbound-envelope.ts");
const source = resolve(packageRoot, "codegen/generated/ws-outbound.compile.ts");
const runtimeSchemaMetadata = resolve(
packageRoot,
"src/generated/validation/ws-outbound-schema-metadata.ts",
);
const output = resolve(packageRoot, "src/generated/validation/ws-outbound.aot.ts");
const require = createRequire(import.meta.url);
@@ -72,16 +76,69 @@ await Promise.all([
ensureZodAotDiscriminatedUnionOutputPatch(),
]);
const [{ discoverSchemas }, { compileSchemas }, { generateCompiledFileContent }] =
await Promise.all([
import(pathToFileURL(resolve(zodAotRoot, "dist/discovery.js")).href),
import(pathToFileURL(resolve(zodAotRoot, "dist/core/pipeline.js")).href),
import(pathToFileURL(resolve(zodAotRoot, "dist/cli/emitter.js")).href),
]);
const [
{ loadSourceFile },
{ discoverSchemas },
{ compileSchemas },
{ generateCompiledFileContent },
] = await Promise.all([
import(pathToFileURL(resolve(zodAotRoot, "dist/loader.js")).href),
import(pathToFileURL(resolve(zodAotRoot, "dist/discovery.js")).href),
import(pathToFileURL(resolve(zodAotRoot, "dist/core/pipeline.js")).href),
import(pathToFileURL(resolve(zodAotRoot, "dist/cli/emitter.js")).href),
]);
// One compiled validator per session message plus a tag-only envelope. zod-aot emits one function
// per compiled schema, so this keeps every generated function small enough for V8 to optimize cheaply.
const { sessionOutboundMessageOptions } = await loadSourceFile(envelopeSource, { cacheBust: true });
const sessionTags = sessionOutboundMessageOptions.map((option) => option.shape.type.value);
function sessionExport(index) {
return `SessionOutboundMessage${index}`;
}
const envelopeImport = relative(dirname(source), envelopeSource)
.replace(/\.ts$/, ".js")
.split(sep)
.join("/");
const metadataImport = relative(dirname(runtimeSchemaMetadata), envelopeSource)
.replace(/\.ts$/, ".js")
.split(sep)
.join("/");
await mkdir(dirname(source), { recursive: true });
await writeFile(
source,
[
"// AUTO-GENERATED by generate-validation-aot.mjs — DO NOT EDIT",
'import { compile } from "zod-aot";',
`import { WSOutboundEnvelopeSchema, sessionOutboundMessageOptions } from "${envelopeImport}";`,
"export const WSOutboundEnvelope = compile(WSOutboundEnvelopeSchema);",
...sessionTags.map(
(_, index) =>
`export const ${sessionExport(index)} = compile(sessionOutboundMessageOptions[${index}]);`,
),
"",
].join("\n"),
);
await mkdir(dirname(runtimeSchemaMetadata), { recursive: true });
await writeFile(
runtimeSchemaMetadata,
[
"// AUTO-GENERATED by generate-validation-aot.mjs — DO NOT EDIT",
`import { WSOutboundEnvelopeSchema, sessionOutboundMessageOptions } from "${metadataImport}";`,
"export const WSOutboundEnvelope = { schema: WSOutboundEnvelopeSchema };",
...sessionTags.map(
(_, index) =>
`export const ${sessionExport(index)} = { schema: sessionOutboundMessageOptions[${index}] };`,
),
"",
].join("\n"),
);
const schemas = await discoverSchemas(source, { cacheBust: true });
if (schemas.length === 0) {
throw new Error(`No zod-aot compile() exports found in ${relative(packageRoot, source)}`);
if (schemas.length !== sessionTags.length + 1) {
throw new Error(
`Expected ${sessionTags.length + 1} zod-aot compile() exports in ${relative(packageRoot, source)}, found ${schemas.length}`,
);
}
const compiled = compileSchemas(schemas, { mode: "inline" });
@@ -95,12 +152,16 @@ const content = generateCompiledFileContent(compiled, runtimeImportPath, {
"// AUTO-GENERATED by zod-aot — DO NOT EDIT",
"// @ts-nocheck\n// AUTO-GENERATED by zod-aot — DO NOT EDIT",
);
const sessionMessageValidators = [
"export const SessionOutboundMessageValidators = {",
...sessionTags.map((tag, index) => ` ${JSON.stringify(tag)}: ${sessionExport(index)},`),
"};",
"",
].join("\n");
await mkdir(dirname(output), { recursive: true });
await writeFile(output, content);
await writeFile(output, `${content}\n${sessionMessageValidators}`);
console.info(
`generated ${relative(packageRoot, output)} from ${relative(packageRoot, source)} (${schemas
.map((schema) => schema.exportName)
.join(", ")})`,
`generated ${relative(packageRoot, output)}: envelope + ${sessionTags.length} session message validators`,
);
+13
View File
@@ -0,0 +1,13 @@
import { z } from "zod";
/** Semantic provenance, independent of provider envelopes and timeline presentation. */
export const AgentMessageSchema = z.object({
event: z.enum(["message", "finished", "errored", "permission-required", "closed"]),
sender: z.object({
id: z.string().min(1),
title: z.string().min(1).optional(),
}),
text: z.string(),
});
export type AgentMessage = z.infer<typeof AgentMessageSchema>;
+2
View File
@@ -1,3 +1,4 @@
import type { AgentMessage } from "./agent-message.js";
import type { AgentAttachment } from "./messages.js";
export type AgentProvider = string;
@@ -311,6 +312,7 @@ export type ToolCallDetail =
};
interface ToolCallBase {
agentMessage?: AgentMessage;
[key: string]: unknown;
type: "tool_call";
callId: string;
@@ -1,6 +1,6 @@
# Generated protocol validators
`ws-outbound.aot.ts` is generated by zod-aot from `packages/protocol/codegen/ws-outbound.compile.ts`.
`ws-outbound.aot.ts` is generated by zod-aot from `packages/protocol/codegen/generated/ws-outbound.compile.ts`, which the generator writes along with `ws-outbound-schema-metadata.ts`.
Run:
@@ -812,3 +812,14 @@ describe("checkout PR schemas", () => {
});
});
});
test.each([undefined, false, true])(
"accepts optional Hoplite capability (%s)",
(hopliteProvider) => {
const features = hopliteProvider === undefined ? {} : { hopliteProvider };
expect(
ServerInfoStatusPayloadSchema.parse({ status: "server_info", serverId: "test", features })
.features,
).toEqual(features);
},
);
@@ -0,0 +1,51 @@
import { describe, expect, test } from "vitest";
import { SessionInboundMessageSchema } from "./messages.js";
describe("create request internal field", () => {
test("accepts internal on create_agent_request", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "create_agent_request",
requestId: "create-agent-internal",
config: {
provider: "codex",
cwd: "/repo/app",
},
internal: true,
});
expect(parsed).toEqual({
type: "create_agent_request",
requestId: "create-agent-internal",
config: {
provider: "codex",
cwd: "/repo/app",
},
internal: true,
labels: {},
});
});
test("accepts internal on agent.create.request", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "agent.create.request",
requestId: "agent-create-internal",
config: {
provider: "codex",
cwd: "/repo/app",
},
internal: true,
});
expect(parsed).toEqual({
type: "agent.create.request",
requestId: "agent-create-internal",
config: {
provider: "codex",
cwd: "/repo/app",
},
internal: true,
labels: {},
});
});
});
@@ -23,6 +23,27 @@ describe("plugin protocol compatibility", () => {
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
},
);
it("preserves manifest metadata in plugin list responses", () => {
const message = {
type: "plugin.list.response",
payload: {
requestId: "metadata",
plugins: [
{
id: "review",
name: "Review tools",
icon: "assets/icon.png",
media: ["screenshot.png", "https://example.com/demo.mp4"],
path: "/plugins/review",
enabled: false,
status: "disabled",
},
],
},
};
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
});
it("parses plugin timeline append messages and advertises the capability", () => {
expect(
SessionInboundMessageSchema.parse({
+28
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vitest";
import {
AgentTimelineItemPayloadSchema,
FileExplorerRequestSchema,
PaseoWorktreeArchiveRequestSchema,
parseServerInfoStatusPayload,
@@ -520,3 +521,30 @@ describe("viewed timeline subscription messages", () => {
});
});
});
test("agent messages are an optional semantic payload on existing tool rows", () => {
const legacy = {
type: "tool_call",
callId: "delivery",
name: "agent_message",
status: "completed",
error: null,
detail: { type: "plain_text", text: "Done" },
};
const agentMessage = {
event: "finished",
sender: { id: "server::worker", title: "Reviewer" },
text: "Done",
};
expect(AgentTimelineItemPayloadSchema.parse(legacy)).toEqual(legacy);
expect(AgentTimelineItemPayloadSchema.parse({ ...legacy, agentMessage })).toEqual({
...legacy,
agentMessage,
});
expect(
AgentTimelineItemPayloadSchema.safeParse({
...legacy,
agentMessage: { ...agentMessage, sender: { id: 42 } },
}).success,
).toBe(false);
});
+52 -7
View File
@@ -1,3 +1,4 @@
import { AgentMessageSchema } from "./agent-message.js";
import { PluginRegistryIdentitySchema } from "./plugin-registry.js";
import { AgentProfileSchema, AgentSkillSelectionSchema } from "./agent-profile.js";
export {
@@ -625,6 +626,7 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail, unknown> = z.discri
);
const ToolCallBasePayloadSchema = z.object({
agentMessage: AgentMessageSchema.optional(),
type: z.literal("tool_call"),
callId: z.string(),
name: z.string(),
@@ -837,6 +839,8 @@ export const AgentSnapshotPayloadSchema = z.object({
attentionTimestamp: z.string().nullable().optional(),
archivedAt: z.string().nullable().optional(),
providerUnavailable: z.boolean().optional(),
// Private helper marker, retained for exact-ID snapshots and older wire messages.
internal: z.boolean().optional(),
});
export type AgentSnapshotPayload = z.infer<typeof AgentSnapshotPayloadSchema>;
@@ -908,6 +912,9 @@ const AgentDirectoryFilterSchema = z.object({
includeArchived: z.boolean().optional(),
requiresAttention: z.boolean().optional(),
thinkingOptionId: z.string().nullable().optional(),
// Retained for wire parsing; private helpers are excluded from public discovery.
includeInternal: z.boolean().optional(),
includeBackground: z.boolean().optional(),
});
export const DeleteAgentRequestMessageSchema = z.object({
@@ -1235,6 +1242,7 @@ export const FetchAgentsRequestMessageSchema = z.object({
type: z.literal("fetch_agents_request"),
requestId: z.string(),
scope: z.enum(["active"]).optional(),
includeBackground: z.boolean().optional(),
filter: AgentDirectoryFilterSchema.optional(),
sort: z
.array(
@@ -1268,6 +1276,7 @@ const WorkspaceStateBucketSchema = z.enum([
]);
export const FetchWorkspacesRequestMessageSchema = z.object({
includeBackground: z.boolean().optional(),
type: z.literal("fetch_workspaces_request"),
requestId: z.string(),
filter: z
@@ -1276,6 +1285,9 @@ export const FetchWorkspacesRequestMessageSchema = z.object({
projectId: z.string().optional(),
// Unused: accepted so older clients still parse, but the server does not filter on it.
idPrefix: z.string().optional(),
// Retained for wire parsing of older requests.
includeInternal: z.boolean().optional(),
includeBackground: z.boolean().optional(),
})
.optional(),
sort: z
@@ -1354,6 +1366,8 @@ export const SendAgentMessageRequestSchema = z.object({
/** Accepts full ID, unique prefix, or exact full title (server resolves). */
agentId: z.string(),
text: z.string(),
/** Opaque sender identity for agent-originated prompts. */
sourceAgentId: z.string().min(1).optional(),
messageId: z.string().optional(), // Client-provided ID for deduplication
activeTurnBehavior: ActiveTurnBehaviorSchema.optional(),
images: z.array(ImageAttachmentSchema).optional(),
@@ -1710,6 +1724,13 @@ export const CreateAgentRequestMessageSchema = z.object({
git: GitSetupOptionsSchema.optional(),
worktree: CreateAgentWorktreeTargetSchema.optional(),
autoArchive: z.boolean().optional(),
// An ephemeral helper: the daemon never persists, lists, flags or announces
// it, and plugin lifecycle hooks skip it. Lives on the request rather than
// in the config schema, which is reused for updates. Retained for wire parsing;
// public internal creation is no longer supported.
internal: z.boolean().optional(),
// Workspace creation intent, rejected when selecting an existing workspace.
background: z.boolean().optional(),
labels: z.record(z.string(), z.string()).default({}),
requestId: z.string(),
});
@@ -2629,6 +2650,10 @@ export const WorkspaceCreateRequestSchema = z.object({
title: z.string().optional(),
// Optional prompt context for workspace-level name/branch generation.
firstAgentContext: FirstAgentContextSchema.optional(),
// Retained for wire parsing; public internal creation is no longer supported.
internal: z.boolean().optional(),
background: z.boolean().optional(),
callerAgentId: z.string().optional(),
source: z.discriminatedUnion("kind", [
z.object({
kind: z.literal("directory"),
@@ -3554,6 +3579,11 @@ export const ServerInfoStatusPayloadSchema = z
creationLifecycle: z.boolean().optional(),
// COMPAT(hubAgentRpc): added in v0.8.0; remove gate after 2027-03-05.
hubAgentRpc: z.boolean().optional(),
// COMPAT(internalAgents): added in v0.9.0; remove gate after 2027-03-17.
internalAgents: z.boolean().optional(),
// COMPAT(internalWorkspaces): added in v0.9.0; remove gate after 2027-03-17.
internalWorkspaces: z.boolean().optional(),
backgroundWorkspaces: z.boolean().optional(),
providersSnapshot: z.boolean().optional(),
usageSources: z.boolean().optional(),
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
@@ -3680,6 +3710,8 @@ export const ServerInfoStatusPayloadSchema = z
commitBaseClassification: z.boolean().optional(),
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
providerRemoval: z.boolean().optional(),
// COMPAT(hopliteProvider): added after v0.11.2, remove after 2027-04-15 once host floor includes the bundled adapter.
hopliteProvider: z.boolean().optional(),
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
importSessionWorkspaceTarget: z.boolean().optional(),
// COMPAT(importSessionSearch): added in v0.8.0, remove gate after 2027-03-02.
@@ -3695,6 +3727,7 @@ export const ServerInfoStatusPayloadSchema = z
ownedSubscriptions: z.boolean().optional(),
// COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30.
canonicalSubmittedPrompts: z.boolean().optional(),
agentMessageProvenance: z.boolean().optional(),
// COMPAT(agentTurnIdentity): accept peers that observed pre-release v0.2.6 through 2027-01-31.
agentTurnIdentity: z.boolean().optional(),
// COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15.
@@ -4019,6 +4052,9 @@ export const WorkspaceDescriptorPayloadSchema = z
pinnedAt: z.string().nullable().optional(),
// COMPAT(workspaceLabels): added in v0.5.0, remove optional after 2027-08-14.
labels: z.array(z.string()).optional(),
// Retained for wire parsing of older daemon descriptors.
internal: z.boolean().optional(),
background: z.boolean().optional(),
archivingAt: z.string().nullable().optional().default(null),
status: WorkspaceStateBucketSchema,
// Best-effort workspace status entry timestamp. Old daemons omit the
@@ -6270,14 +6306,15 @@ export const UsageProblemSchema = z.discriminatedUnion("kind", [
}),
z.object({ kind: z.literal("no_quota"), detail: z.string() }),
]);
const AvailableUsageReportSchema = z.object({
status: z.literal("available"),
planLabel: z.string().optional(),
windows: z.array(ProviderUsageWindowSchema),
balances: z.array(ProviderUsageBalanceSchema).optional(),
details: z.array(ProviderUsageDetailSchema).optional(),
});
export const UsageReportSchema = z.discriminatedUnion("status", [
z.object({
status: z.literal("available"),
planLabel: z.string().optional(),
windows: z.array(ProviderUsageWindowSchema),
balances: z.array(ProviderUsageBalanceSchema).optional(),
details: z.array(ProviderUsageDetailSchema).optional(),
}),
AvailableUsageReportSchema,
z.object({ status: z.literal("unavailable"), problem: UsageProblemSchema }),
z.object({ status: z.literal("error"), error: z.string() }),
]);
@@ -6289,6 +6326,11 @@ export const UsageReportEntrySchema = z.object({
sourceLabel: z.string(),
icon: z.string().optional(),
report: UsageReportSchema,
// Successful completion time; fetchedAt remains the attempt timestamp for ordering.
successfulAt: z.string().optional(),
lastSuccessful: z
.object({ fetchedAt: z.string(), report: AvailableUsageReportSchema })
.optional(),
loginErrors: z
.array(
z.object({
@@ -6632,6 +6674,9 @@ export const PluginNpmInstallationSchema = z.object({
export const PluginListItemSchema = z.object({
id: PluginIdSchema,
name: z.string().optional(),
icon: z.string().optional(),
media: z.array(z.string()).optional(),
description: z.string().optional(),
path: z.string(),
enabled: z.boolean(),
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import { z } from "zod";
import {
UsageReportEntrySchema,
AgentSnapshotPayloadSchema,
AgentTimelineItemPayloadSchema,
ServerInfoStatusPayloadSchema,
@@ -436,3 +437,40 @@ test("usage login errors are additive and older reports still parse", () => {
expect(SessionOutboundMessageSchema.parse(newMessage)).toEqual(newMessage);
expect(legacy.parse(newMessage)).toEqual(oldMessage);
});
test("usage entries accept older failures and additive successful snapshots", () => {
const oldEntry = {
id: "source:account",
sourceId: "source",
sourceLabel: "Source",
account: {},
fetchedAt: "2026-10-17T10:06:00Z",
report: { status: "error", error: "Rate limited" },
};
expect(UsageReportEntrySchema.parse(oldEntry)).toEqual(oldEntry);
const retained = {
...oldEntry,
lastSuccessful: {
fetchedAt: "2026-10-17T10:00:00Z",
report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] },
},
};
expect(UsageReportEntrySchema.parse(retained)).toEqual(retained);
const available = {
...oldEntry,
report: retained.lastSuccessful.report,
successfulAt: "2026-10-17T10:00:15Z",
};
expect(UsageReportEntrySchema.parse(available)).toEqual(available);
expect(
UsageReportEntrySchema.omit({ lastSuccessful: true, successfulAt: true }).parse(available),
).toEqual({ ...oldEntry, report: available.report });
// An older client's entry shape strips the additive snapshot and still sees the failure.
expect(UsageReportEntrySchema.omit({ lastSuccessful: true }).parse(retained)).toEqual(oldEntry);
expect(
UsageReportEntrySchema.safeParse({
...oldEntry,
lastSuccessful: { fetchedAt: oldEntry.fetchedAt, report: oldEntry.report },
}).success,
).toBe(false);
});
@@ -0,0 +1,31 @@
import type { AgentMessage } from "../agent-message.js";
import type { ToolCallDetail } from "../agent-types.js";
import { toSummaryText } from "./summary-text.js";
export interface AgentMessageDisplay {
displayName: string;
summary?: string;
detail: ToolCallDetail;
}
/** Events read noun-first ("Agent finished"); the sender goes in the summary. */
const EVENT_LABELS: Record<AgentMessage["event"], string> = {
message: "Message received",
finished: "Agent finished",
errored: "Agent errored",
"permission-required": "Agent needs permission",
closed: "Agent closed",
};
/** Presentation shared by clients; never used to encode provider prompts. */
export function buildAgentMessageDisplay(message: AgentMessage): AgentMessageDisplay {
const summary = toSummaryText(message.sender.title ?? message.sender.id);
return {
displayName: EVENT_LABELS[message.event],
...(summary ? { summary } : {}),
detail: {
type: "plain_text",
text: `From agent: ${message.sender.id}\n\n${message.text}`,
},
};
}
@@ -0,0 +1,349 @@
/**
* One entry per Paseo MCP tool: the timeline label, the context shown after it,
* and how the expanded details lay out the call.
*
* Labels are fixed text so truncation never hides them. Actions read verb-first
* ("Send message"); the context goes in the summary.
*/
type ToolInput = Readonly<Record<string, unknown>>;
/** `targetAgent` is the target agent's title when known, else its ID. */
export type PaseoToolContext = (input: ToolInput, targetAgent: string | undefined) => unknown;
export interface PaseoToolDetailSpec {
promptField?: string;
inputOrder?: readonly string[];
outputFields?: readonly string[];
}
export interface PaseoToolEntry {
label: string;
context?: PaseoToolContext;
detail?: PaseoToolDetailSpec;
}
const WORKSPACE_FIELDS = [
"title",
"workspaceId",
"projectId",
"isolation",
"path",
"mode",
"worktreeSlug",
"branchName",
"baseBranch",
"branch",
"prNumber",
"forge",
] as const;
const AGENT_FIELDS = [
"title",
"agentId",
"provider",
"workspaceId",
"cwd",
"sessionMode",
"modeId",
"background",
"notifyOnFinish",
"internal",
"settings",
"labels",
] as const;
const AUTOMATION_FIELDS = [
"name",
"id",
"cron",
"timezone",
"provider",
"cwd",
"isolation",
"maxRuns",
"expiresIn",
"clearExpires",
] as const;
const BROWSER_FIELDS = [
"browserId",
"url",
"ref",
"sourceRef",
"targetRef",
"value",
"text",
"key",
"button",
"doubleClick",
"modifiers",
"filePaths",
"fullPage",
"maxEntries",
"timeoutMs",
"deltaX",
"deltaY",
"width",
"height",
"function",
] as const;
const AUTOMATION_OUTPUT = ["id", "status", "nextRunAt", "expiresAt"] as const;
function field(...keys: string[]): PaseoToolContext {
return (input) => keys.map((key) => input[key]).find(isPresent);
}
function targetAgent(): PaseoToolContext {
return (_input, agent) => agent;
}
function fieldOrTargetAgent(key: string): PaseoToolContext {
return (input, agent) => (isPresent(input[key]) ? input[key] : agent);
}
function workspaceContext(): PaseoToolContext {
return (input) =>
[input.title, input.branchName, input.branch, baseName(input.path)].find(isPresent);
}
function viewportContext(): PaseoToolContext {
return (input) =>
typeof input.width === "number" && typeof input.height === "number"
? `${input.width}×${input.height}`
: undefined;
}
function isPresent(value: unknown): boolean {
if (typeof value === "string") return value.trim().length > 0;
if (Array.isArray(value)) return value.length > 0;
return typeof value === "number";
}
function baseName(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
return value.split(/[\\/]/).findLast((segment) => segment.length > 0);
}
function browser(label: string, context?: PaseoToolContext): PaseoToolEntry {
return { label, ...(context ? { context } : {}), detail: { inputOrder: BROWSER_FIELDS } };
}
export const PASEO_TOOL_CATALOG = {
// Agents
create_agent: {
label: "Create agent",
context: field("title", "initialPrompt"),
detail: {
promptField: "initialPrompt",
inputOrder: AGENT_FIELDS,
outputFields: ["agentId", "status", "currentModeId", "cwd"],
},
},
send_agent_prompt: {
label: "Send message",
context: field("prompt"),
detail: {
promptField: "prompt",
inputOrder: AGENT_FIELDS,
outputFields: ["status", "lastMessage", "permission"],
},
},
get_agent_status: {
label: "Check agent",
context: targetAgent(),
detail: { inputOrder: ["agentId"] },
},
get_agent_activity: {
label: "Read agent activity",
context: targetAgent(),
detail: { inputOrder: ["agentId", "limit"] },
},
list_agents: {
label: "List agents",
detail: { inputOrder: ["cwd", "statuses", "sinceHours", "limit", "includeArchived"] },
},
update_agent: {
label: "Update agent",
context: fieldOrTargetAgent("name"),
detail: { inputOrder: AGENT_FIELDS },
},
set_agent_mode: {
label: "Set agent mode",
context: field("modeId"),
detail: { inputOrder: ["agentId", "modeId"] },
},
cancel_agent: {
label: "Cancel agent",
context: targetAgent(),
detail: { inputOrder: ["agentId"] },
},
archive_agent: {
label: "Archive agent",
context: targetAgent(),
detail: { inputOrder: ["agentId"] },
},
kill_agent: { label: "Kill agent", context: targetAgent(), detail: { inputOrder: ["agentId"] } },
list_pending_permissions: { label: "List permissions" },
respond_to_permission: {
label: "Respond to permission",
context: targetAgent(),
detail: { inputOrder: ["agentId", "requestId", "response"] },
},
// Workspaces and scripts
create_workspace: {
label: "Create workspace",
context: workspaceContext(),
detail: { inputOrder: WORKSPACE_FIELDS, outputFields: ["workspaceId", "projectId"] },
},
list_workspaces: { label: "List workspaces" },
rename_workspace: {
label: "Rename workspace",
context: field("title"),
detail: { inputOrder: ["title", "workspaceId"] },
},
archive_workspace: {
label: "Archive workspace",
context: field("workspaceId"),
detail: {
inputOrder: ["workspaceId"],
outputFields: ["workspaceId", "archivedAgentIds", "removedDirectory"],
},
},
list_workspace_scripts: { label: "List scripts", detail: { inputOrder: ["workspaceId"] } },
start_workspace_script: {
label: "Start script",
context: field("scriptName"),
detail: { inputOrder: ["workspaceId", "scriptName"] },
},
stop_workspace_script: {
label: "Stop script",
context: field("scriptName"),
detail: { inputOrder: ["workspaceId", "scriptName"] },
},
// Terminals
create_terminal: {
label: "Create terminal",
context: field("name", "command"),
detail: { inputOrder: ["cwd", "workspaceId", "name", "command"] },
},
list_terminals: { label: "List terminals", detail: { inputOrder: ["cwd", "all"] } },
capture_terminal: {
label: "Read terminal",
context: field("terminalId"),
detail: { inputOrder: ["terminalId", "lines"] },
},
send_terminal_keys: {
label: "Send keys",
context: field("keys"),
detail: { inputOrder: ["terminalId", "keys", "literal"] },
},
kill_terminal: {
label: "Kill terminal",
context: field("terminalId"),
detail: { inputOrder: ["terminalId"] },
},
// Schedules and heartbeats
create_schedule: {
label: "Create schedule",
context: field("name", "cron"),
detail: {
promptField: "prompt",
inputOrder: AUTOMATION_FIELDS,
outputFields: AUTOMATION_OUTPUT,
},
},
create_heartbeat: {
label: "Create heartbeat",
context: field("name", "cron"),
detail: {
promptField: "prompt",
inputOrder: AUTOMATION_FIELDS,
outputFields: AUTOMATION_OUTPUT,
},
},
update_schedule: {
label: "Update schedule",
context: field("name", "id"),
detail: { promptField: "prompt", inputOrder: AUTOMATION_FIELDS },
},
list_schedules: { label: "List schedules" },
inspect_schedule: {
label: "Inspect schedule",
context: field("id"),
detail: { inputOrder: ["id"] },
},
schedule_logs: {
label: "Read schedule logs",
context: field("id"),
detail: { inputOrder: ["id"] },
},
run_schedule_once: {
label: "Run schedule",
context: field("id"),
detail: { inputOrder: ["id"] },
},
pause_schedule: { label: "Pause schedule", context: field("id"), detail: { inputOrder: ["id"] } },
resume_schedule: {
label: "Resume schedule",
context: field("id"),
detail: { inputOrder: ["id"] },
},
delete_schedule: {
label: "Delete schedule",
context: field("id"),
detail: { inputOrder: ["id"] },
},
delete_heartbeat: {
label: "Delete heartbeat",
context: field("id"),
detail: { inputOrder: ["id"] },
},
// Providers
list_providers: { label: "List providers" },
list_models: {
label: "List models",
context: field("provider"),
detail: { inputOrder: ["provider"] },
},
list_profiles: { label: "List profiles" },
inspect_provider: {
label: "Inspect provider",
context: field("provider"),
detail: { inputOrder: ["provider", "cwd", "settings"] },
},
// Browser
browser_list_tabs: browser("List browser tabs"),
browser_new_tab: browser("Open browser tab", field("url")),
browser_close_tab: browser("Close browser tab", field("browserId")),
browser_navigate: browser("Navigate", field("url")),
browser_back: browser("Go back"),
browser_forward: browser("Go forward"),
browser_reload: browser("Reload page"),
browser_snapshot: browser("Snapshot page"),
browser_screenshot: browser("Take screenshot"),
browser_click: browser("Click element", field("ref")),
browser_hover: browser("Hover element", field("ref")),
browser_drag: browser("Drag element", field("sourceRef")),
browser_fill: browser("Fill field", field("ref")),
browser_select: browser("Select option", field("value", "ref")),
browser_type: browser("Type text", field("text")),
browser_keypress: browser("Press key", field("key")),
browser_upload: browser("Upload files", field("filePaths")),
browser_wait: browser("Wait for page", field("text", "url")),
browser_scroll: browser("Scroll page"),
browser_resize: browser("Resize browser", viewportContext()),
browser_logs: browser("Read browser logs"),
browser_evaluate: browser("Run JavaScript", field("function")),
} satisfies Record<string, PaseoToolEntry>;
export type PaseoToolName = keyof typeof PASEO_TOOL_CATALOG;
export function getPaseoToolEntry(leafName: string): PaseoToolEntry | undefined {
return Object.prototype.hasOwnProperty.call(PASEO_TOOL_CATALOG, leafName)
? PASEO_TOOL_CATALOG[leafName as PaseoToolName]
: undefined;
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildPaseoToolDetailSections } from "./paseo-tool-call-detail.js";
import { buildPaseoToolDetailSections } from "./index.js";
describe("Paseo tool-call detail presentation", () => {
it.each(["mcp__paseo__create_agent", "paseo.create_agent", "paseo_remote.create_agent"])(
@@ -1,4 +1,5 @@
import { getPaseoToolLeafName } from "./tool-name-normalization.js";
import { getPaseoToolLeafName } from "../tool-name-normalization.js";
import { getPaseoToolEntry, type PaseoToolDetailSpec } from "./catalog.js";
export interface PaseoToolDetailField {
label: string;
@@ -17,160 +18,6 @@ export type PaseoToolDetailSection =
fields: PaseoToolDetailField[];
};
interface ToolDetailSpec {
promptField?: string;
inputOrder?: readonly string[];
outputFields?: readonly string[];
}
const WORKSPACE_FIELDS = [
"title",
"workspaceId",
"projectId",
"isolation",
"path",
"mode",
"worktreeSlug",
"branchName",
"baseBranch",
"branch",
"prNumber",
"forge",
] as const;
const AGENT_FIELDS = [
"title",
"agentId",
"provider",
"workspaceId",
"cwd",
"sessionMode",
"modeId",
"background",
"notifyOnFinish",
"settings",
"labels",
] as const;
const AUTOMATION_FIELDS = [
"name",
"id",
"cron",
"timezone",
"provider",
"cwd",
"isolation",
"maxRuns",
"expiresIn",
"clearExpires",
] as const;
const BROWSER_FIELDS = [
"browserId",
"url",
"ref",
"sourceRef",
"targetRef",
"value",
"text",
"key",
"button",
"doubleClick",
"modifiers",
"filePaths",
"fullPage",
"maxEntries",
"timeoutMs",
"deltaX",
"deltaY",
"width",
"height",
"function",
] as const;
const TOOL_SPECS: Readonly<Record<string, ToolDetailSpec>> = {
create_workspace: {
inputOrder: WORKSPACE_FIELDS,
outputFields: ["workspaceId", "projectId"],
},
list_workspaces: {},
archive_workspace: {
inputOrder: ["workspaceId"],
outputFields: ["workspaceId", "archivedAgentIds", "removedDirectory"],
},
rename_workspace: { inputOrder: ["title", "workspaceId"] },
create_agent: {
promptField: "initialPrompt",
inputOrder: AGENT_FIELDS,
outputFields: ["agentId", "status", "currentModeId", "cwd"],
},
send_agent_prompt: {
promptField: "prompt",
inputOrder: AGENT_FIELDS,
outputFields: ["status", "lastMessage", "permission"],
},
get_agent_status: { inputOrder: ["agentId"] },
list_agents: { inputOrder: ["cwd", "statuses", "sinceHours", "limit", "includeArchived"] },
cancel_agent: { inputOrder: ["agentId"] },
archive_agent: { inputOrder: ["agentId"] },
kill_agent: { inputOrder: ["agentId"] },
update_agent: { inputOrder: AGENT_FIELDS },
get_agent_activity: { inputOrder: ["agentId", "limit"] },
set_agent_mode: { inputOrder: ["agentId", "modeId"] },
list_workspace_scripts: { inputOrder: ["workspaceId"] },
start_workspace_script: { inputOrder: ["workspaceId", "scriptName"] },
stop_workspace_script: { inputOrder: ["workspaceId", "scriptName"] },
list_terminals: { inputOrder: ["cwd", "all"] },
create_terminal: { inputOrder: ["cwd", "workspaceId", "name", "command"] },
kill_terminal: { inputOrder: ["terminalId"] },
capture_terminal: { inputOrder: ["terminalId", "lines"] },
send_terminal_keys: { inputOrder: ["terminalId", "keys", "literal"] },
create_schedule: {
promptField: "prompt",
inputOrder: AUTOMATION_FIELDS,
outputFields: ["id", "status", "nextRunAt", "expiresAt"],
},
create_heartbeat: {
promptField: "prompt",
inputOrder: AUTOMATION_FIELDS,
outputFields: ["id", "status", "nextRunAt", "expiresAt"],
},
delete_heartbeat: { inputOrder: ["id"] },
list_schedules: {},
inspect_schedule: { inputOrder: ["id"] },
pause_schedule: { inputOrder: ["id"] },
resume_schedule: { inputOrder: ["id"] },
delete_schedule: { inputOrder: ["id"] },
update_schedule: { promptField: "prompt", inputOrder: AUTOMATION_FIELDS },
schedule_logs: { inputOrder: ["id"] },
run_schedule_once: { inputOrder: ["id"] },
list_providers: {},
list_models: { inputOrder: ["provider"] },
list_profiles: {},
inspect_provider: { inputOrder: ["provider", "cwd", "settings"] },
list_pending_permissions: {},
respond_to_permission: { inputOrder: ["agentId", "requestId", "response"] },
browser_list_tabs: {},
browser_new_tab: { inputOrder: BROWSER_FIELDS },
browser_snapshot: { inputOrder: BROWSER_FIELDS },
browser_click: { inputOrder: BROWSER_FIELDS },
browser_fill: { inputOrder: BROWSER_FIELDS },
browser_wait: { inputOrder: BROWSER_FIELDS },
browser_type: { inputOrder: BROWSER_FIELDS },
browser_keypress: { inputOrder: BROWSER_FIELDS },
browser_navigate: { inputOrder: BROWSER_FIELDS },
browser_back: { inputOrder: BROWSER_FIELDS },
browser_forward: { inputOrder: BROWSER_FIELDS },
browser_reload: { inputOrder: BROWSER_FIELDS },
browser_screenshot: { inputOrder: BROWSER_FIELDS },
browser_upload: { inputOrder: BROWSER_FIELDS },
browser_hover: { inputOrder: BROWSER_FIELDS },
browser_select: { inputOrder: BROWSER_FIELDS },
browser_drag: { inputOrder: BROWSER_FIELDS },
browser_logs: { inputOrder: BROWSER_FIELDS },
browser_evaluate: { inputOrder: BROWSER_FIELDS },
browser_scroll: { inputOrder: BROWSER_FIELDS },
browser_resize: { inputOrder: BROWSER_FIELDS },
browser_close_tab: { inputOrder: BROWSER_FIELDS },
};
const FIELD_LABELS: Readonly<Record<string, string>> = {
agentId: "Agent",
archivedAgentIds: "Archived agents",
@@ -196,6 +43,7 @@ const FIELD_LABELS: Readonly<Record<string, string>> = {
newMode: "New mode",
nextRunAt: "Next run",
notifyOnFinish: "Notify on finish",
internal: "Internal",
prNumber: "Change request",
projectId: "Project",
removedDirectory: "Removed directory",
@@ -328,7 +176,7 @@ export function buildPaseoToolDetailSections(
const leafName = getPaseoToolLeafName(toolName);
if (!leafName) return null;
const spec = TOOL_SPECS[leafName] ?? {};
const spec: PaseoToolDetailSpec = getPaseoToolEntry(leafName)?.detail ?? {};
const sections: PaseoToolDetailSection[] = [];
if (spec.promptField && isRecord(input)) {
const prompt = input[spec.promptField];
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import {
buildAgentMessageDisplay,
buildPaseoToolDisplay,
getPaseoToolTargetAgentId,
MAX_SUMMARY_CHARS,
PASEO_TOOL_NAMES,
} from "./index.js";
function display(toolName: string, input: unknown, targetAgentTitle?: string) {
return buildPaseoToolDisplay({
toolName,
detail: { type: "unknown", input, output: null },
...(targetAgentTitle ? { targetAgentTitle } : {}),
});
}
describe("Paseo tool display", () => {
it.each([
[
"send_agent_prompt",
{ agentId: "agt_1", prompt: "Fix the failing test" },
"Send message",
"Fix the failing test",
],
["create_agent", { title: "Reviewer", initialPrompt: "Review it" }, "Create agent", "Reviewer"],
["create_agent", { initialPrompt: "Review it" }, "Create agent", "Review it"],
["get_agent_status", { agentId: "agt_1" }, "Check agent", "agt_1"],
["update_agent", { agentId: "agt_1", name: "Renamed" }, "Update agent", "Renamed"],
["create_workspace", { path: "/tmp/repo/feature-x" }, "Create workspace", "feature-x"],
["create_workspace", { title: "Docs", branchName: "docs" }, "Create workspace", "Docs"],
["start_workspace_script", { scriptName: "web" }, "Start script", "web"],
["send_terminal_keys", { terminalId: "t1", keys: "npm test" }, "Send keys", "npm test"],
["create_schedule", { prompt: "Check CI", cron: "0 9 * * 1" }, "Create schedule", "0 9 * * 1"],
["browser_navigate", { url: "https://paseo.sh" }, "Navigate", "https://paseo.sh"],
[
"browser_upload",
{ ref: "@e1", filePaths: ["/a.png", "/b.png"] },
"Upload files",
"/a.png +1",
],
["browser_resize", { width: 800, height: 600 }, "Resize browser", "800×600"],
])("presents %s with a fixed label and its context", (tool, input, label, summary) => {
expect(display(`mcp__paseo__${tool}`, input)).toEqual({ displayName: label, summary });
});
it("omits the summary when a call has no context", () => {
expect(display("mcp__paseo__list_agents", {})).toEqual({ displayName: "List agents" });
});
it("recognizes every Paseo namespace format", () => {
for (const name of ["mcp__paseo__kill_agent", "paseo.kill_agent", "paseo_remote.kill_agent"]) {
expect(display(name, { agentId: "agt_1" })?.displayName).toBe("Kill agent");
}
});
it("returns null outside the Paseo namespace, including speak", () => {
expect(display("Bash", {})).toBeNull();
expect(display("mcp__paseo__speak", { text: "hi" })).toBeNull();
});
it("humanizes a Paseo tool that has no catalog entry", () => {
expect(display("mcp__paseo__brand_new_tool", {})).toEqual({ displayName: "Brand new tool" });
});
it("prefers the known agent title over the agent ID", () => {
expect(display("mcp__paseo__cancel_agent", { agentId: "agt_1" }, "Reviewer")).toEqual({
displayName: "Cancel agent",
summary: "Reviewer",
});
expect(
getPaseoToolTargetAgentId("mcp__paseo__cancel_agent", {
type: "unknown",
input: { agentId: "agt_1" },
output: null,
}),
).toBe("agt_1");
expect(
getPaseoToolTargetAgentId("Bash", { type: "unknown", input: { agentId: "x" }, output: null }),
).toBeNull();
});
it("truncates a massive single-line prompt and collapses whitespace", () => {
const prompt = `Line one\n\n line two ${"x".repeat(1_000_000)}`;
const summary = display("mcp__paseo__send_agent_prompt", { agentId: "a", prompt })?.summary;
expect(summary).toHaveLength(MAX_SUMMARY_CHARS);
expect(summary?.startsWith("Line one line two xxx")).toBe(true);
expect(summary?.endsWith("…")).toBe(true);
});
it("bounds the scan even when the prompt opens with a wall of whitespace", () => {
const prompt = `${" ".repeat(10_000)}hidden`;
const summary = display("mcp__paseo__send_agent_prompt", { agentId: "a", prompt })?.summary;
expect(summary).toBeUndefined();
});
it("keeps a short prompt intact", () => {
expect(
display("mcp__paseo__send_agent_prompt", { agentId: "a", prompt: " hi\nthere " })?.summary,
).toBe("hi there");
});
it("catalogs tool names as lowercase leaf names", () => {
for (const name of PASEO_TOOL_NAMES) {
expect(name).toMatch(/^[a-z_]+$/);
}
});
});
describe("agent message display", () => {
it.each([
["message", "Message received"],
["finished", "Agent finished"],
["errored", "Agent errored"],
["permission-required", "Agent needs permission"],
["closed", "Agent closed"],
] as const)("labels %s noun-first with the sender as context", (event, label) => {
const result = buildAgentMessageDisplay({
event,
sender: { id: "srv::agt_1", title: "QA messenger" },
text: "Done.",
});
expect(result.displayName).toBe(label);
expect(result.summary).toBe("QA messenger");
expect(result.detail).toEqual({ type: "plain_text", text: "From agent: srv::agt_1\n\nDone." });
});
it("falls back to the sender ID and truncates a long title", () => {
expect(
buildAgentMessageDisplay({ event: "finished", sender: { id: "srv::agt_1" }, text: "" })
.summary,
).toBe("srv::agt_1");
const summary = buildAgentMessageDisplay({
event: "finished",
sender: { id: "a", title: "t".repeat(500) },
text: "",
}).summary;
expect(summary).toHaveLength(MAX_SUMMARY_CHARS);
});
});
@@ -0,0 +1,85 @@
import type { ToolCallDetail } from "../agent-types.js";
import { getPaseoToolLeafName, isPaseoToolName } from "../tool-name-normalization.js";
import { getPaseoToolEntry, PASEO_TOOL_CATALOG, type PaseoToolName } from "./catalog.js";
import { toSummaryText } from "./summary-text.js";
export { buildAgentMessageDisplay, type AgentMessageDisplay } from "./agent-message.js";
export {
buildPaseoToolDetailSections,
type PaseoToolDetailField,
type PaseoToolDetailSection,
} from "./detail-sections.js";
export { MAX_SUMMARY_CHARS } from "./summary-text.js";
export type { PaseoToolName };
export interface PaseoToolDisplayInput {
toolName: string;
detail: ToolCallDetail;
/** Title of the agent named by the call's `agentId`, when the caller knows it. */
targetAgentTitle?: string;
}
export interface PaseoToolDisplay {
displayName: string;
summary?: string;
}
export const PASEO_TOOL_NAMES = Object.keys(PASEO_TOOL_CATALOG) as readonly PaseoToolName[];
/** Returns null for tools outside the Paseo namespace. */
export function buildPaseoToolDisplay(input: PaseoToolDisplayInput): PaseoToolDisplay | null {
const leafName = paseoLeafName(input.toolName);
if (!leafName) return null;
const entry = getPaseoToolEntry(leafName);
if (!entry) return { displayName: humanizeLeafName(leafName) };
const toolInput = readToolInput(input.detail);
const targetAgent = input.targetAgentTitle ?? readString(toolInput.agentId);
const summary = formatContext(entry.context?.(toolInput, targetAgent));
return { displayName: entry.label, ...(summary ? { summary } : {}) };
}
/** The agent a Paseo call acts on, so callers can resolve its title. */
export function getPaseoToolTargetAgentId(
toolName: string,
detail: ToolCallDetail | undefined,
): string | null {
if (!detail || !paseoLeafName(toolName)) return null;
return readString(readToolInput(detail).agentId) ?? null;
}
function paseoLeafName(toolName: string): string | null {
return isPaseoToolName(toolName) ? getPaseoToolLeafName(toolName) : null;
}
function readToolInput(detail: ToolCallDetail): Readonly<Record<string, unknown>> {
if (detail.type !== "unknown") return {};
const value = detail.input;
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function formatContext(value: unknown): string | undefined {
if (typeof value === "string") return toSummaryText(value);
if (typeof value === "number") return String(value);
if (Array.isArray(value)) {
const [first, ...rest] = value;
const head = typeof first === "string" ? toSummaryText(first) : undefined;
if (!head) return undefined;
return rest.length > 0 ? `${head} +${rest.length}` : head;
}
return undefined;
}
function humanizeLeafName(leafName: string): string {
const sentence = leafName
.replace(/[._-]+/g, " ")
.trim()
.toLowerCase();
return `${sentence[0]?.toUpperCase() ?? ""}${sentence.slice(1)}`;
}
@@ -0,0 +1,21 @@
/**
* Prompts and results can be one enormous line. Summaries are capped here so no
* consumer relies on its renderer to clip them.
*/
export const MAX_SUMMARY_CHARS = 120;
// Bounds the whitespace scan: inputs can be megabytes.
const SCAN_WINDOW_CHARS = MAX_SUMMARY_CHARS * 4;
export function toSummaryText(value: string): string | undefined {
const isPartial = value.length > SCAN_WINDOW_CHARS;
const collapsed = value.slice(0, SCAN_WINDOW_CHARS).replace(/\s+/g, " ").trim();
if (!collapsed) return undefined;
if (collapsed.length <= MAX_SUMMARY_CHARS && !isPartial) return collapsed;
const kept = dropTrailingHighSurrogate(collapsed.slice(0, MAX_SUMMARY_CHARS - 1)).trimEnd();
return `${kept}…`;
}
function dropTrailingHighSurrogate(text: string): string {
const last = text.charCodeAt(text.length - 1);
return last >= 0xd800 && last <= 0xdbff ? text.slice(0, -1) : text;
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { pluginOverviewUrl } from "./plugin-overview.js";
describe("plugin overview destinations", () => {
it.each([
undefined,
"",
"./docs",
"../docs",
"/login",
"//example.com",
"#section",
"?login",
"http://example.com",
"mailto:hello@example.com",
"ftp://example.com",
"javascript:alert(1)",
"data:image/png;base64,AAAA",
"vbscript:msgbox(1)",
"javascript&#58;alert(1)",
"javascript%3Aalert(1)",
"javascript%253Aalert(1)",
"java\tscript:alert(1)",
" https://example.com",
"https://example.com\n",
"https:example.com",
"https:///example.com",
"https://paseo.sh@evil.example",
"https://example.com\\@evil.example",
"https://",
])("leaves %s as text", (url) => {
expect(pluginOverviewUrl(url)).toBeUndefined();
});
it.each([
"https://example.com/path?q=a&b=c#section",
"HTTPS://example.com/path",
"https://example.com/%3Cscript%3E",
])("allows %s without decoding it again", (url) => {
expect(pluginOverviewUrl(url)).toBe(url);
});
});
+49
View File
@@ -0,0 +1,49 @@
/** Presentation contract for author-controlled registry content, independent of the UI runtime. */
export const pluginOverviewPolicy = {
rawHtml: false,
elements: [
"p",
"br",
"hr",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"blockquote",
"ul",
"ol",
"li",
"strong",
"em",
"del",
"pre",
"code",
"a",
"img",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
],
link: { rel: "noopener noreferrer nofollow", target: "_blank" },
} as const;
/** Validate decoded Markdown destinations; never resolve against the reader's page or repository. */
export function pluginOverviewUrl(value: string | undefined): string | undefined {
// URL parsers discard controls and normalize backslashes; reject those ambiguous spellings.
// eslint-disable-next-line no-control-regex
if (!value || !/^https:\/\/[^/?#]/i.test(value) || /[\u0000-\u0020\u007f\\]/.test(value))
return undefined;
try {
const url = new URL(value);
if (url.protocol !== "https:" || !url.hostname || url.username || url.password)
return undefined;
return value;
} catch {
return undefined;
}
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { pluginMediaKind } from "./plugin-registry.js";
describe("plugin media kind", () => {
it.each([
["https://cdn.example.com/a/preview.png", "image"],
["https://cdn.example.com/a/preview.JPG", "image"],
["https://cdn.example.com/a/preview.jpeg", "image"],
["https://cdn.example.com/a/preview.webp", "image"],
["https://cdn.example.com/a/preview.gif", "image"],
["https://cdn.example.com/a/demo.mp4", "video"],
["https://cdn.example.com/a/demo.WebM", "video"],
["https://cdn.example.com/a/demo.mp4?raw=true#t=2", "video"],
["https://cdn.example.com/mp4/preview.png?format=mp4", "image"],
] as const)("reads %s as %s from its path extension", (url, kind) => {
expect(pluginMediaKind(url)).toBe(kind);
});
});
+11 -1
View File
@@ -39,13 +39,21 @@ export const PublishedPluginSchema = z.object({
repository: z.object({ url: z.string().url(), commit: z.string().optional() }),
artifact: PluginRegistryArtifactSchema,
icon: z.string().url().optional(),
screenshots: z.array(z.string().url()),
/** Images and videos in display order; see `pluginMediaKind`. */
media: z.array(z.string().url()),
submittedAt: z.string(),
reviewedAt: z.string(),
updatedAt: z.string(),
publishedAt: z.string(),
installs: z.number().int().nonnegative().optional(),
});
const VIDEO_EXTENSIONS = new Set(["mp4", "webm"]);
/** Whether a registry media entry is a video or an image, from its path extension. */
export function pluginMediaKind(url: string): "image" | "video" {
const extension = new URL(url).pathname.split(".").pop()?.toLowerCase() ?? "";
return VIDEO_EXTENSIONS.has(extension) ? "video" : "image";
}
export const PublishedPluginDetailSchema = PublishedPluginSchema.extend({ readme: z.string() });
export const PluginRegistryIndexSchema = z.object({
schemaVersion: z.literal(1),
@@ -54,6 +62,8 @@ export const PluginRegistryIndexSchema = z.object({
z.object({ slug: z.string(), label: z.string(), description: z.string().optional() }),
),
plugins: z.array(PublishedPluginSchema),
/** Plugin IDs the maintainer features, in display order. An ID may name a plugin that is not listed. */
featured: z.array(z.string()),
generatedAt: z.string(),
});
export type PluginRegistryIdentity = z.infer<typeof PluginRegistryIdentitySchema>;
@@ -3,6 +3,35 @@ import { describe, expect, it } from "vitest";
import { parsePluginSourceReference } from "./plugin-source-reference.js";
describe("plugin source references", () => {
it.each([
".",
"..",
"./owner/slug",
"../owner/slug",
"/owner/slug",
"~/owner/slug",
"~",
"C:\\",
"C:/",
"D:\\plugins\\repository",
"D:/plugins/repository",
"\\\\server\\share",
"\\\\server\\share\\plugins\\review",
".\\plugins\\review",
"..\\plugins\\review",
])("recognizes the explicit directory %s by shape", (source) => {
expect(parsePluginSourceReference(source)).toEqual({
kind: "directory",
source,
pluginPath: undefined,
});
expect(parsePluginSourceReference(`${source}:plugins/review`)).toEqual({
kind: "directory",
source,
pluginPath: "plugins/review",
});
});
it.each([
["npm:review@1.2.0:nested", "npm:review@1.2.0", "nested"],
["npm:review@^1.2.0", "npm:review@^1.2.0", undefined],
@@ -16,6 +45,7 @@ describe("plugin source references", () => {
["git:file:///repo:plugins/main", "git:file:///repo", "plugins/main"],
["plugins.example.com:8443/owner/slug", "plugins.example.com:8443/owner/slug", undefined],
["owner/repository", "owner/repository", undefined],
["plugins/foo", "plugins/foo", undefined],
["owner/repository:plugins/review", "owner/repository", "plugins/review"],
[
"https://example.test:8443/owner/repository.git",
@@ -39,10 +69,8 @@ describe("plugin source references", () => {
"file:///D:/plugins/repository",
"plugins/review",
],
["D:\\plugins\\repository", "D:\\plugins\\repository", undefined],
["D:\\plugins\\repository:plugins/review", "D:\\plugins\\repository", "plugins/review"],
])("parses %s", (reference, source, pluginPath) => {
expect(parsePluginSourceReference(reference)).toEqual({ source, pluginPath });
expect(parsePluginSourceReference(reference)).toEqual({ kind: "managed", source, pluginPath });
});
});
@@ -1,19 +1,27 @@
import { parsePluginRegistryReference } from "./plugin-registry.js";
import type { PluginInstallation } from "./messages.js";
export interface PluginSourceReference {
kind: "directory" | "managed";
source: string;
pluginPath: string | undefined;
}
export function parsePluginSourceReference(reference: string): PluginSourceReference {
if (parsePluginRegistryReference(reference)) return { source: reference, pluginPath: undefined };
const prefix = /^(npm:|github:|git:(?!\/\/))/.exec(reference)?.[0];
const source = prefix ? reference.slice(prefix.length) : reference;
const parsed = splitPluginPath(source, { scp: prefix !== "npm:" });
return { ...parsed, source: `${prefix ?? ""}${parsed.source}` };
if (!prefix && /^(\.\.?(?:[/\\]|$)|\/|~|[A-Za-z]:[/\\]|\\\\)/.test(parsed.source)) {
return { kind: "directory", ...parsed };
}
if (parsePluginRegistryReference(reference))
return { kind: "managed", source: reference, pluginPath: undefined };
return { kind: "managed", ...parsed, source: `${prefix ?? ""}${parsed.source}` };
}
function splitPluginPath(reference: string, options: { scp: boolean }): PluginSourceReference {
function splitPluginPath(
reference: string,
options: { scp: boolean },
): Pick<PluginSourceReference, "source" | "pluginPath"> {
const separator = reference.lastIndexOf(":");
if (separator === -1) return { source: reference, pluginPath: undefined };
@@ -34,6 +34,7 @@ export const ACP_PROVIDER_ICON_NAMES = [
"goose",
"grok",
"hermes",
"hoplite",
"junie",
"kilo",
"kimi",
@@ -47,3 +47,33 @@ describe("renderTerminalSnapshotToAnsi", () => {
expect(ansi).toContain("ABCDEFGHIJ\r\nKLMNOP");
});
});
describe("renderTerminalSnapshotToAnsi wide chars", () => {
it("skips wide-char continuation cells so the glyph keeps its two columns", () => {
// The daemon captures "통" as the glyph followed by an empty continuation
// cell. Emitting anything for that cell pushes the rest of the row right.
const state: TerminalState = {
rows: 1,
cols: 10,
scrollback: [],
grid: [[{ char: "통" }, { char: "" }, { char: "합" }, { char: "" }, { char: "!" }]],
cursor: { row: 0, col: 5 },
};
expect(renderTerminalSnapshotToAnsi(state)).toContain("통합!");
});
it("replays snapshots from an old daemon verbatim, including its continuation spaces", () => {
// Old daemons already collapsed the continuation to " ". The serializer
// cannot tell that apart from a real space, so it must not guess.
const state: TerminalState = {
rows: 1,
cols: 10,
scrollback: [],
grid: [cells("통 합 !")],
cursor: { row: 0, col: 6 },
};
expect(renderTerminalSnapshotToAnsi(state)).toContain("통 합 !");
});
});
@@ -87,6 +87,12 @@ function renderTerminalRow(row: TerminalCell[], padToCols?: number): string {
for (let index = 0; index < length; index += 1) {
const cell = row[index] ?? { char: " " };
// An empty char marks a wide-char continuation cell. The glyph in the
// previous cell already advances the cursor two columns, so emitting
// anything here would shift the rest of the row one column right.
if (cell.char === "") {
continue;
}
const nextStyle = getTerminalStyle(cell);
if (!terminalStylesEqual(previousStyle, nextStyle)) {
output.push(styleToAnsi(nextStyle));
+22 -9
View File
@@ -1,12 +1,17 @@
import type { ToolCallTimelineItem } from "./agent-types.js";
import { getPaseoToolLeafName, isPaseoToolName } from "./tool-name-normalization.js";
import {
buildAgentMessageDisplay,
buildPaseoToolDisplay,
} from "./paseo-tool-presentation/index.js";
import { stripCwdPrefix } from "./path-utils.js";
export type ToolCallDisplayInput = Pick<
ToolCallTimelineItem,
"name" | "status" | "error" | "metadata" | "detail"
"name" | "status" | "error" | "metadata" | "detail" | "agentMessage"
> & {
cwd?: string;
/** Title of the agent a Paseo call targets, when the caller knows it. */
targetAgentTitle?: string;
};
export interface ToolCallDisplayModel {
@@ -33,12 +38,6 @@ function humanizeToolName(name: string): string {
if (!trimmed) {
return name;
}
if (isPaseoToolName(trimmed)) {
const leaf = getPaseoToolLeafName(trimmed);
if (leaf) {
return humanizeToolName(leaf);
}
}
if (/[:./]/.test(trimmed) || trimmed.includes("__")) {
return trimmed;
}
@@ -151,6 +150,21 @@ function buildUnknownDetailOverride(input: ToolCallDisplayInput): DetailDisplay
}
export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCallDisplayModel {
const errorText = input.status === "failed" ? formatErrorText(input.error) : undefined;
const paseoDisplay = input.agentMessage
? buildAgentMessageDisplay(input.agentMessage)
: buildPaseoToolDisplay({
toolName: input.name,
detail: input.detail,
targetAgentTitle: input.targetAgentTitle,
});
if (paseoDisplay) {
return {
displayName: paseoDisplay.displayName,
...(paseoDisplay.summary ? { summary: paseoDisplay.summary } : {}),
...(errorText ? { errorText } : {}),
};
}
const canonicalDisplay = buildCanonicalDetailDisplay(input);
const unknownDetailOverride = buildUnknownDetailOverride(input);
const displayName =
@@ -158,7 +172,6 @@ export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCall
canonicalDisplay.displayName ??
humanizeToolName(input.name);
const summary = unknownDetailOverride.summary ?? canonicalDisplay.summary;
const errorText = input.status === "failed" ? formatErrorText(input.error) : undefined;
return {
displayName,
@@ -0,0 +1,25 @@
import { z } from "zod";
import {
SessionOutboundMessageSchema,
WSHelloRejectedMessageSchema,
WSPongMessageSchema,
} from "../messages.js";
export const sessionOutboundMessageOptions = SessionOutboundMessageSchema.options;
const [firstSessionTag, ...otherSessionTags] = sessionOutboundMessageOptions.map((option) =>
z.object({ type: option.shape.type }),
);
// WSOutboundMessageSchema with every session message reduced to its tag. It reports the same
// envelope and discriminator issues, while each session message gets its own generated validator:
// one function for the whole protocol is large enough that V8's Maglev tier spends hundreds of MB
// and seconds compiling it.
export const WSOutboundEnvelopeSchema = z.discriminatedUnion("type", [
WSPongMessageSchema,
z.object({
type: z.literal("session"),
message: z.discriminatedUnion("type", [firstSessionTag, ...otherSessionTags]),
}),
WSHelloRejectedMessageSchema,
]);
@@ -1,3 +0,0 @@
import { WSOutboundMessageSchema as SourceWSOutboundMessageSchema } from "../messages.js";
export const WSOutboundMessageSchema = { schema: SourceWSOutboundMessageSchema };
+46 -11
View File
@@ -1,19 +1,54 @@
import { ZodRealError } from "zod";
import type { z } from "zod";
import { WSOutboundMessageSchema } from "../generated/validation/ws-outbound.aot.js";
import type { WSOutboundMessage } from "../messages.js";
import {
SessionOutboundMessageValidators,
WSOutboundEnvelope,
} from "../generated/validation/ws-outbound.aot.js";
import type { SessionOutboundMessage, WSOutboundMessage } from "../messages.js";
type WSOutboundValidationResult =
| { success: true; data: WSOutboundMessage }
| { success: false; error: z.ZodError };
type ValidationResult<T> = { success: true; data: T } | { success: false; error: z.ZodError };
interface WSOutboundGeneratedValidator {
safeParse(input: unknown): WSOutboundValidationResult;
interface GeneratedValidator<T> {
safeParse(input: unknown): ValidationResult<T>;
}
// zod-aot emits runtime JavaScript from WSOutboundMessageSchema but not its TypeScript surface.
// zod-aot emits runtime JavaScript from the envelope and message schemas but not its TypeScript surface.
// Protocol regression tests cover the patched compiler cases behind this boundary.
const wsOutboundValidator = WSOutboundMessageSchema as WSOutboundGeneratedValidator;
const envelopeValidator = WSOutboundEnvelope as GeneratedValidator<WSOutboundMessage>;
const sessionMessageValidators = SessionOutboundMessageValidators as Record<
SessionOutboundMessage["type"],
GeneratedValidator<SessionOutboundMessage>
>;
export function validateWSOutboundMessage(input: unknown): WSOutboundValidationResult {
return wsOutboundValidator.safeParse(input);
// The message validator reports paths relative to the message, nested issues included.
function underMessage(issue: z.core.$ZodIssue): z.core.$ZodIssue {
const path = ["message", ...issue.path];
switch (issue.code) {
case "invalid_union":
return issue.inclusive === false
? { ...issue, path }
: { ...issue, path, errors: issue.errors.map((branch) => branch.map(underMessage)) };
case "invalid_key":
case "invalid_element":
return { ...issue, path, issues: issue.issues.map(underMessage) };
default:
return { ...issue, path };
}
}
export function validateWSOutboundMessage(input: unknown): ValidationResult<WSOutboundMessage> {
const envelope = envelopeValidator.safeParse(input);
if (!envelope.success || envelope.data.type !== "session") {
return envelope;
}
const message = envelope.data.message;
const result = sessionMessageValidators[message.type].safeParse(message);
if (!result.success) {
return { success: false, error: new ZodRealError(result.error.issues.map(underMessage)) };
}
// Message validators return a new object when they apply defaults.
if (result.data === message) {
return envelope;
}
return { success: true, data: { ...envelope.data, message: result.data } };
}
@@ -0,0 +1,5 @@
/** Largest WebSocket message the daemon accepts. A bigger one closes the connection with 1009. */
export const MAX_WEBSOCKET_MESSAGE_BYTES = 100 * 1024 * 1024;
/** Largest encrypted frame the relay accepts. */
export const MAX_RELAY_WEBSOCKET_MESSAGE_BYTES = 32 * 1024 * 1024;
File diff suppressed because it is too large Load Diff
@@ -3,8 +3,10 @@ import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createJiti } from "jiti";
import type { z } from "zod";
import { describe, expect, it } from "vitest";
import { WSOutboundMessageSchema as GeneratedWSOutboundMessageSchema } from "../../src/generated/validation/ws-outbound.aot.js";
import { WSOutboundMessageSchema } from "../../src/messages.js";
import { validateWSOutboundMessage } from "../../src/validation/ws-outbound.js";
interface GeneratedSchema {
safeParse(input: unknown): { success: boolean; data?: unknown };
@@ -132,10 +134,8 @@ const SourceSchema = z.object({
});
it("accepts a minimal valid envelope and rejects a corrupted envelope", () => {
expect(GeneratedWSOutboundMessageSchema.safeParse({ type: "pong" }).success).toBe(true);
expect(GeneratedWSOutboundMessageSchema.safeParse({ type: "not_a_message" }).success).toBe(
false,
);
expect(validateWSOutboundMessage({ type: "pong" }).success).toBe(true);
expect(validateWSOutboundMessage({ type: "not_a_message" }).success).toBe(false);
});
it("accepts project config responses with and without setup commit status", () => {
@@ -158,11 +158,10 @@ const SourceSchema = z.object({
},
});
expect(GeneratedWSOutboundMessageSchema.safeParse(envelope(payload)).success).toBe(true);
expect(validateWSOutboundMessage(envelope(payload)).success).toBe(true);
expect(
GeneratedWSOutboundMessageSchema.safeParse(
envelope({ ...payload, hasUncommittedWorktreeSetupChanges: true }),
).success,
validateWSOutboundMessage(envelope({ ...payload, hasUncommittedWorktreeSetupChanges: true }))
.success,
).toBe(true);
});
@@ -196,7 +195,7 @@ const SourceSchema = z.object({
},
};
expect(GeneratedWSOutboundMessageSchema.safeParse(envelope)).toEqual({
expect(validateWSOutboundMessage(envelope)).toEqual({
success: true,
data: envelope,
});
@@ -255,7 +254,7 @@ const SourceSchema = z.object({
])("preserves workspaceId in a $name", ({ message }) => {
const envelope = { type: "session", message };
expect(GeneratedWSOutboundMessageSchema.safeParse(envelope)).toEqual({
expect(validateWSOutboundMessage(envelope)).toEqual({
success: true,
data: envelope,
});
@@ -263,11 +262,11 @@ const SourceSchema = z.object({
it("emits runtime imports with .js extensions", async () => {
const generated = await readFile(generatedWSOutboundPath, "utf8");
expect(generated).toContain('from "../../validation/ws-outbound-schema-metadata.js"');
expect(generated).toContain('from "./ws-outbound-schema-metadata.js"');
});
it("accepts a forge.search.response envelope", () => {
const result = GeneratedWSOutboundMessageSchema.safeParse({
const result = validateWSOutboundMessage({
type: "session",
message: {
type: "forge.search.response",
@@ -293,7 +292,7 @@ const SourceSchema = z.object({
});
it("accepts a legacy github_search_response envelope", () => {
const result = GeneratedWSOutboundMessageSchema.safeParse({
const result = validateWSOutboundMessage({
type: "session",
message: {
type: "github_search_response",
@@ -319,4 +318,86 @@ const SourceSchema = z.object({
});
expect(result.success).toBe(true);
});
it("keeps each generated validator function small", async () => {
// A single function for the whole protocol made V8's Maglev tier spend ~800 MB and seconds compiling it.
const generated = await readFile(generatedWSOutboundPath, "utf8");
const starts = [...generated.matchAll(/function safeParse_\w+\(/g)].map((match) => match.index);
const sizes = starts.map((start, index) => (starts[index + 1] ?? generated.length) - start);
expect(Math.max(...sizes)).toBeLessThan(1_000_000);
expect(sizes.length).toBeGreaterThan(1);
});
it("keeps nested union issue paths rooted at the envelope", () => {
// Generated validators root branch issues at the input; runtime Zod reports them relative to the union.
const envelope = {
type: "session",
message: {
type: "read_project_config_response",
payload: { repoRoot: "/repo", ok: true, config: {}, revision: { mtimeMs: 1, size: 1 } },
},
};
const paths = (issues: z.core.$ZodIssue[]): PropertyKey[][] =>
issues.flatMap((issue) => [
issue.path,
...(issue.code === "invalid_union" ? paths(issue.errors.flat()) : []),
]);
const result = validateWSOutboundMessage(envelope);
expect(result.success).toBe(false);
if (result.success) return;
expect(paths(result.error.issues)).toEqual([
["message", "payload"],
["message", "payload", "requestId"],
["message", "payload", "requestId"],
["message", "payload", "ok"],
["message", "payload", "error"],
]);
});
it("applies defaults from the matching session message schema", () => {
const envelope = {
type: "session",
message: {
type: "agent_update",
payload: {
kind: "upsert",
agent: {
id: "agent-1",
provider: "claude",
cwd: "/repo",
model: "model",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
lastUserMessageAt: "2026-01-01T00:00:00.000Z",
status: "initializing",
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
},
currentModeId: "default",
availableModes: [],
pendingPermissions: [],
persistence: { provider: "claude", sessionId: "session-1" },
title: "Agent",
},
},
},
};
const result = validateWSOutboundMessage(envelope);
const expected = WSOutboundMessageSchema.safeParse(envelope);
expect(expected.success).toBe(true);
expect(result).toEqual(expected);
expect(result.success && result.data).toMatchObject({
message: { payload: { agent: { capabilities: { supportsRewindConversation: false } } } },
});
});
});