mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 17:34:51 +00:00
feat(plugins): add live timeline rows and client slash commands
Plugins can transform live timeline source items at render time, append durable plugin rows from daemon handlers, and register client-only slash commands in the composer. Related to #3940 and #3952.
This commit is contained in:
@@ -3,7 +3,6 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { pluginRegistry } from "./registry";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
export function PluginCatalogSync({
|
||||
serverId,
|
||||
@@ -32,15 +31,10 @@ export function PluginCatalogSync({
|
||||
.getPluginCatalog()
|
||||
.then((catalog) => {
|
||||
if (!cancelled) {
|
||||
const timelineChanged = pluginRegistry.installCatalog(serverId, catalog, {
|
||||
pluginRegistry.installCatalog(serverId, catalog, {
|
||||
replacePluginId,
|
||||
client,
|
||||
});
|
||||
if (timelineChanged) {
|
||||
useSessionStore
|
||||
.getState()
|
||||
.sessions[serverId]?.viewedTimelineSync?.reprojectVisibleTimelines();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useMemo } from "react";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { createPluginAgentActionContext, createPluginWorkspaceActionContext } from "../actions";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { createPluginNavigation } from "../navigation";
|
||||
import { useInstalledPlugins } from "../registry";
|
||||
import { createPluginSurfaceRuntime } from "../surface-runtime";
|
||||
|
||||
export interface PluginClientSlashCommand {
|
||||
pluginId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
argumentHint: string;
|
||||
run(args: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function usePluginClientSlashCommands(input: {
|
||||
serverId: string;
|
||||
workspaceId: string | null | undefined;
|
||||
agentId: string;
|
||||
}): PluginClientSlashCommand[] {
|
||||
const client = useHostRuntimeClient(input.serverId);
|
||||
const installed = useInstalledPlugins();
|
||||
return useMemo(() => {
|
||||
if (!client || !input.workspaceId) return [];
|
||||
const workspaceId = input.workspaceId;
|
||||
const state = createPluginClientStateSource(input.serverId);
|
||||
const navigation = createPluginNavigation({
|
||||
serverId: input.serverId,
|
||||
workspaceId,
|
||||
});
|
||||
const commands = installed
|
||||
.filter((plugin) => plugin.serverId === input.serverId)
|
||||
.flatMap((plugin) => {
|
||||
const runtime = createPluginSurfaceRuntime(client, plugin.id);
|
||||
if (!runtime) return [];
|
||||
return (plugin.clientSlashCommands ?? []).flatMap((contribution) => {
|
||||
const context =
|
||||
contribution.context === "agent"
|
||||
? createPluginAgentActionContext({
|
||||
plugin,
|
||||
runtime,
|
||||
navigation,
|
||||
state,
|
||||
workspaceId,
|
||||
agentId: input.agentId,
|
||||
})
|
||||
: createPluginWorkspaceActionContext({
|
||||
plugin,
|
||||
runtime,
|
||||
navigation,
|
||||
state,
|
||||
workspaceId,
|
||||
});
|
||||
if (!context) return [];
|
||||
return [
|
||||
{
|
||||
pluginId: plugin.id,
|
||||
name: contribution.name,
|
||||
description: contribution.description,
|
||||
argumentHint: contribution.argumentHint,
|
||||
async run(args: string) {
|
||||
if (contribution.context === "agent" && context.context === "agent") {
|
||||
await contribution.onSubmit({ ...context, args });
|
||||
} else if (
|
||||
contribution.context === "workspace" &&
|
||||
context.context === "workspace"
|
||||
) {
|
||||
await contribution.onSubmit({ ...context, args });
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
});
|
||||
return commands;
|
||||
}, [client, input.agentId, input.serverId, input.workspaceId, installed]);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
executePluginClientSlashCommand,
|
||||
mergeSlashCommandSources,
|
||||
resolvePluginClientSlashCommand,
|
||||
} from "./model";
|
||||
|
||||
describe("plugin client slash commands", () => {
|
||||
it("uses built-in, plugin, then provider precedence", () => {
|
||||
const collision = vi.fn();
|
||||
const result = mergeSlashCommandSources({
|
||||
builtIn: [{ name: "clear", aliases: ["new"] }],
|
||||
plugins: [
|
||||
{ name: "clear", pluginId: "review" },
|
||||
{ name: "new", pluginId: "alias-collision" },
|
||||
{ name: "review", pluginId: "alpha" },
|
||||
{ name: "review", pluginId: "beta" },
|
||||
],
|
||||
provider: [{ name: "review" }, { name: "usage" }],
|
||||
onPluginCollision: collision,
|
||||
});
|
||||
|
||||
expect(result.map(({ source, command }) => `${source}:${command.name}`)).toEqual([
|
||||
"built-in:clear",
|
||||
"plugin:review",
|
||||
"provider:usage",
|
||||
]);
|
||||
expect(collision).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("passes the trimmed raw remainder as args", () => {
|
||||
const command = { name: "review" };
|
||||
expect(
|
||||
resolvePluginClientSlashCommand({
|
||||
text: " /review foo bar ",
|
||||
hasAttachments: false,
|
||||
commands: [command],
|
||||
}),
|
||||
).toEqual({ command, args: "foo bar" });
|
||||
expect(
|
||||
resolvePluginClientSlashCommand({
|
||||
text: "/review foo",
|
||||
hasAttachments: true,
|
||||
commands: [command],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("runs the client handler and reports failures", async () => {
|
||||
const run = vi.fn(async (_args: string) => undefined);
|
||||
const onError = vi.fn();
|
||||
await executePluginClientSlashCommand({ command: { run }, args: "foo", onError });
|
||||
expect(run).toHaveBeenCalledWith("foo");
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
|
||||
const failure = new Error("review failed");
|
||||
await executePluginClientSlashCommand({
|
||||
command: { run: vi.fn().mockRejectedValue(failure) },
|
||||
args: "bar",
|
||||
onError,
|
||||
});
|
||||
expect(onError).toHaveBeenCalledWith(failure);
|
||||
});
|
||||
|
||||
it("does not make the composer wait for a never-resolving handler", () => {
|
||||
const run = vi.fn(() => new Promise<void>(() => undefined));
|
||||
|
||||
expect(
|
||||
executePluginClientSlashCommand({ command: { run }, args: "foo", onError: vi.fn() }),
|
||||
).toBeUndefined();
|
||||
expect(run).toHaveBeenCalledWith("foo");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
export interface SlashCommandDescriptor {
|
||||
name: string;
|
||||
aliases?: readonly string[];
|
||||
}
|
||||
|
||||
export type AvailableSlashCommand<
|
||||
BuiltIn extends SlashCommandDescriptor,
|
||||
Plugin extends SlashCommandDescriptor,
|
||||
Provider extends SlashCommandDescriptor,
|
||||
> =
|
||||
| { source: "built-in"; command: BuiltIn }
|
||||
| { source: "plugin"; command: Plugin }
|
||||
| { source: "provider"; command: Provider };
|
||||
|
||||
export function mergeSlashCommandSources<
|
||||
BuiltIn extends SlashCommandDescriptor,
|
||||
Plugin extends SlashCommandDescriptor,
|
||||
Provider extends SlashCommandDescriptor,
|
||||
>(input: {
|
||||
builtIn: readonly BuiltIn[];
|
||||
plugins: readonly Plugin[];
|
||||
provider: readonly Provider[];
|
||||
onPluginCollision(command: Plugin, winner: "built-in" | "plugin"): void;
|
||||
}): Array<AvailableSlashCommand<BuiltIn, Plugin, Provider>> {
|
||||
const commands: Array<AvailableSlashCommand<BuiltIn, Plugin, Provider>> = input.builtIn.map(
|
||||
(command) => ({ source: "built-in", command }),
|
||||
);
|
||||
const claimed = new Map<string, "built-in" | "plugin">();
|
||||
for (const command of input.builtIn) {
|
||||
claimed.set(command.name, "built-in");
|
||||
for (const alias of command.aliases ?? []) claimed.set(alias, "built-in");
|
||||
}
|
||||
for (const command of input.plugins) {
|
||||
const winner = claimed.get(command.name);
|
||||
if (winner) {
|
||||
input.onPluginCollision(command, winner);
|
||||
continue;
|
||||
}
|
||||
claimed.set(command.name, "plugin");
|
||||
commands.push({ source: "plugin", command });
|
||||
}
|
||||
for (const command of input.provider) {
|
||||
if (!claimed.has(command.name)) commands.push({ source: "provider", command });
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
export function resolvePluginClientSlashCommand<Command extends SlashCommandDescriptor>(input: {
|
||||
text: string;
|
||||
hasAttachments: boolean;
|
||||
commands: readonly Command[];
|
||||
}): { command: Command; args: string } | null {
|
||||
if (input.hasAttachments) return null;
|
||||
const match = /^\/([^\s]+)(?:\s+([\s\S]*))?$/.exec(input.text.trim());
|
||||
if (!match) return null;
|
||||
const command = input.commands.find((candidate) => candidate.name === match[1]);
|
||||
return command ? { command, args: (match[2] ?? "").trim() } : null;
|
||||
}
|
||||
|
||||
export function executePluginClientSlashCommand(input: {
|
||||
command: { run(args: string): Promise<void> };
|
||||
args: string;
|
||||
onError(error: unknown): void;
|
||||
}): void {
|
||||
try {
|
||||
void input.command.run(input.args).catch(input.onError);
|
||||
} catch (error) {
|
||||
input.onError(error);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type PluginAttachmentSourceContribution,
|
||||
type PluginCommandCenterItemContribution,
|
||||
type PluginClientContribution,
|
||||
type PluginClientSlashCommandContribution,
|
||||
type PluginSidebarContribution,
|
||||
type PluginSurfaceProps,
|
||||
type PluginThemeContribution,
|
||||
@@ -73,6 +74,7 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
||||
sidebarItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
clientSide: null,
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
@@ -83,6 +85,7 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
||||
const sidebarItemIds = new Set<string>();
|
||||
const workspacePanelIds = new Set<string>();
|
||||
const commandCenterItemIds = new Set<string>();
|
||||
const clientSlashCommandNames = new Set<string>();
|
||||
const attachmentSourceIds = new Set<string>();
|
||||
const themeIds = new Set<string>();
|
||||
const timelineTransformerIds = new Set<string>();
|
||||
@@ -166,6 +169,27 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
||||
keywords: contribution.keywords?.map((keyword) => keyword.trim()).filter(Boolean),
|
||||
});
|
||||
},
|
||||
addClientSlashCommand(contribution: PluginClientSlashCommandContribution) {
|
||||
const name = requireId(contribution.name, "client slash command name");
|
||||
if (clientSlashCommandNames.has(name)) {
|
||||
throw new Error(`Duplicate client slash command: ${name}`);
|
||||
}
|
||||
const description = contribution.description.trim();
|
||||
if (!description) throw new Error(`Client slash command ${name} has no description`);
|
||||
if (contribution.context !== "workspace" && contribution.context !== "agent") {
|
||||
throw new Error(`Client slash command ${name} has invalid context`);
|
||||
}
|
||||
if (typeof contribution.onSubmit !== "function") {
|
||||
throw new Error(`Client slash command ${name} has no callback`);
|
||||
}
|
||||
clientSlashCommandNames.add(name);
|
||||
collector.clientSlashCommands.push({
|
||||
...contribution,
|
||||
name,
|
||||
description,
|
||||
argumentHint: contribution.argumentHint.trim(),
|
||||
});
|
||||
},
|
||||
addClientSide(contribution: PluginClientContribution) {
|
||||
if (collector.clientSide) throw new Error("Plugin has more than one client-side entrypoint");
|
||||
if (typeof contribution !== "function") {
|
||||
@@ -312,6 +336,7 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
||||
sidebarItems: collector.sidebarItems,
|
||||
workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"],
|
||||
commandCenterItems: collector.commandCenterItems,
|
||||
clientSlashCommands: collector.clientSlashCommands,
|
||||
clientSide: collector.clientSide,
|
||||
attachmentSources: collector.attachmentSources,
|
||||
themes: collector.themes,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Icon } from "../icons";
|
||||
import { Modal } from "./modal";
|
||||
import { useToast } from "./toast";
|
||||
import { useRevealedText } from "@/hooks/use-revealed-text";
|
||||
|
||||
export const pluginReactNativeRuntime = { Icon, Modal, useToast };
|
||||
export const pluginReactNativeRuntime = { Icon, Modal, useRevealedText, useToast };
|
||||
|
||||
@@ -7,6 +7,13 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Text } from "react-native";
|
||||
import { SurfaceErrorBoundary } from "./surface-error-boundary";
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: (theme: object) => object) =>
|
||||
factory({ colors: { statusDanger: "red" }, spacing: [0, 4, 8, 12, 16] }),
|
||||
},
|
||||
}));
|
||||
|
||||
let oldSurfaceCleanups = 0;
|
||||
function FailingSurface() {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
interface SurfaceErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
installation: object;
|
||||
resetKey?: unknown;
|
||||
Surface: unknown;
|
||||
}
|
||||
|
||||
@@ -29,7 +30,9 @@ export class SurfaceErrorBoundary extends Component<
|
||||
componentDidUpdate(previous: SurfaceErrorBoundaryProps): void {
|
||||
if (
|
||||
this.state.error &&
|
||||
(previous.installation !== this.props.installation || previous.Surface !== this.props.Surface)
|
||||
(previous.installation !== this.props.installation ||
|
||||
previous.resetKey !== this.props.resetKey ||
|
||||
previous.Surface !== this.props.Surface)
|
||||
) {
|
||||
this.setState({ error: null });
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
import { pluginRegistry } from "../registry";
|
||||
import {
|
||||
transformTimelineItem,
|
||||
type InstalledPluginTimelineItem,
|
||||
type TimelineItemTransform,
|
||||
} from "./model";
|
||||
import { useMemo } from "react";
|
||||
import { useInstalledPlugins } from "../registry";
|
||||
import { transformTimelineItem, type TimelineItemTransform } from "./model";
|
||||
|
||||
export type { InstalledPluginTimelineItem, TimelineItemTransform } from "./model";
|
||||
export { PluginTimelineItemView } from "./view";
|
||||
|
||||
export function createInstalledTimelineTransform(serverId: string): TimelineItemTransform {
|
||||
return (item: AgentTimelineItem): InstalledPluginTimelineItem[] | undefined => {
|
||||
const plugins = pluginRegistry.getSnapshot().filter((plugin) => plugin.serverId === serverId);
|
||||
return transformTimelineItem(item, plugins);
|
||||
};
|
||||
export function useInstalledTimelineTransform(serverId: string): TimelineItemTransform {
|
||||
const installed = useInstalledPlugins();
|
||||
const plugins = useMemo(
|
||||
() => installed.filter((plugin) => plugin.serverId === serverId),
|
||||
[installed, serverId],
|
||||
);
|
||||
return useMemo(() => (input) => transformTimelineItem({ ...input, plugins }), [plugins]);
|
||||
}
|
||||
|
||||
@@ -44,26 +44,32 @@ const toolCall = {
|
||||
};
|
||||
|
||||
describe("plugin timeline transforms", () => {
|
||||
it("adds the installation identity to plain transformed items", () => {
|
||||
const transformed = transformTimelineItem(toolCall, [
|
||||
plugin({
|
||||
id: "reports",
|
||||
transform: () => ({
|
||||
items: [
|
||||
{
|
||||
type: "plugin" as const,
|
||||
kind: "test-report",
|
||||
version: 1,
|
||||
data: { passed: 4 },
|
||||
},
|
||||
],
|
||||
it("adds installation and source identity to plain transformed items", () => {
|
||||
const transformed = transformTimelineItem({
|
||||
item: toolCall,
|
||||
phase: "complete",
|
||||
sourceId: "agent_tool_call-1",
|
||||
plugins: [
|
||||
plugin({
|
||||
id: "reports",
|
||||
transform: () => ({
|
||||
items: [
|
||||
{
|
||||
type: "plugin" as const,
|
||||
kind: "test-report",
|
||||
version: 1,
|
||||
data: { passed: 4 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
],
|
||||
});
|
||||
|
||||
expect(transformed).toEqual([
|
||||
{
|
||||
type: "plugin",
|
||||
id: "agent_tool_call-1/0",
|
||||
pluginId: "reports",
|
||||
kind: "test-report",
|
||||
version: 1,
|
||||
@@ -74,16 +80,26 @@ describe("plugin timeline transforms", () => {
|
||||
|
||||
it("keeps the source item when no transformer returns a result", () => {
|
||||
expect(
|
||||
transformTimelineItem(toolCall, [plugin({ id: "reports", transform: () => undefined })]),
|
||||
transformTimelineItem({
|
||||
item: toolCall,
|
||||
phase: "complete",
|
||||
sourceId: "agent_tool_call-1",
|
||||
plugins: [plugin({ id: "reports", transform: () => undefined })],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts an empty replacement and stops after the first result", () => {
|
||||
const second = vi.fn(() => ({ items: [] }));
|
||||
const transformed = transformTimelineItem(toolCall, [
|
||||
plugin({ id: "first", transform: () => ({ items: [] }) }),
|
||||
plugin({ id: "second", transform: second }),
|
||||
]);
|
||||
const transformed = transformTimelineItem({
|
||||
item: toolCall,
|
||||
phase: "complete",
|
||||
sourceId: "agent_tool_call-1",
|
||||
plugins: [
|
||||
plugin({ id: "first", transform: () => ({ items: [] }) }),
|
||||
plugin({ id: "second", transform: second }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(transformed).toEqual([]);
|
||||
expect(second).not.toHaveBeenCalled();
|
||||
@@ -91,21 +107,26 @@ describe("plugin timeline transforms", () => {
|
||||
|
||||
it("rejects non-JSON data without breaking the timeline", () => {
|
||||
const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
const transformed = transformTimelineItem(toolCall, [
|
||||
plugin({
|
||||
id: "broken",
|
||||
transform: () => ({
|
||||
items: [
|
||||
{
|
||||
type: "plugin" as const,
|
||||
kind: "bad-data",
|
||||
version: 1,
|
||||
data: { value: Number.NaN },
|
||||
},
|
||||
],
|
||||
const transformed = transformTimelineItem({
|
||||
item: toolCall,
|
||||
phase: "complete",
|
||||
sourceId: "agent_tool_call-1",
|
||||
plugins: [
|
||||
plugin({
|
||||
id: "broken",
|
||||
transform: () => ({
|
||||
items: [
|
||||
{
|
||||
type: "plugin" as const,
|
||||
kind: "bad-data",
|
||||
version: 1,
|
||||
data: { value: Number.NaN },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
],
|
||||
});
|
||||
|
||||
expect(transformed).toBeUndefined();
|
||||
expect(warning).toHaveBeenCalledOnce();
|
||||
@@ -114,21 +135,26 @@ describe("plugin timeline transforms", () => {
|
||||
|
||||
it("rejects class instances disguised as JSON data", () => {
|
||||
const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
const transformed = transformTimelineItem(toolCall, [
|
||||
plugin({
|
||||
id: "broken",
|
||||
transform: () => ({
|
||||
items: [
|
||||
{
|
||||
type: "plugin" as const,
|
||||
kind: "bad-data",
|
||||
version: 1,
|
||||
data: { createdAt: new Date() } as never,
|
||||
},
|
||||
],
|
||||
const transformed = transformTimelineItem({
|
||||
item: toolCall,
|
||||
phase: "complete",
|
||||
sourceId: "agent_tool_call-1",
|
||||
plugins: [
|
||||
plugin({
|
||||
id: "broken",
|
||||
transform: () => ({
|
||||
items: [
|
||||
{
|
||||
type: "plugin" as const,
|
||||
kind: "bad-data",
|
||||
version: 1,
|
||||
data: { createdAt: new Date() } as never,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
],
|
||||
});
|
||||
|
||||
expect(transformed).toBeUndefined();
|
||||
expect(warning).toHaveBeenCalledOnce();
|
||||
@@ -137,16 +163,48 @@ describe("plugin timeline transforms", () => {
|
||||
|
||||
it("takes a JSON snapshot of transformed data", () => {
|
||||
const data = { passed: 4 };
|
||||
const transformed = transformTimelineItem(toolCall, [
|
||||
plugin({
|
||||
id: "reports",
|
||||
transform: () => ({
|
||||
items: [{ type: "plugin" as const, kind: "test-report", version: 1, data }],
|
||||
const transformed = transformTimelineItem({
|
||||
item: toolCall,
|
||||
phase: "complete",
|
||||
sourceId: "agent_tool_call-1",
|
||||
plugins: [
|
||||
plugin({
|
||||
id: "reports",
|
||||
transform: () => ({
|
||||
items: [{ type: "plugin" as const, kind: "test-report", version: 1, data }],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
],
|
||||
});
|
||||
|
||||
data.passed = 0;
|
||||
expect(transformed?.[0]?.data).toEqual({ passed: 4 });
|
||||
});
|
||||
|
||||
it("passes phase and preserves explicit output identity", () => {
|
||||
const transform = vi.fn(() => ({
|
||||
items: [
|
||||
{
|
||||
type: "plugin" as const,
|
||||
id: "summary",
|
||||
kind: "test-report",
|
||||
version: 1,
|
||||
data: { passed: 4 },
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const transformed = transformTimelineItem({
|
||||
item: { ...toolCall, status: "running" },
|
||||
phase: "streaming",
|
||||
sourceId: "agent_tool_call-1",
|
||||
plugins: [plugin({ id: "reports", transform })],
|
||||
});
|
||||
|
||||
expect(transform).toHaveBeenCalledWith({
|
||||
item: { ...toolCall, status: "running" },
|
||||
phase: "streaming",
|
||||
});
|
||||
expect(transformed?.[0]?.id).toBe("summary");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,11 +7,18 @@ import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
|
||||
export interface InstalledPluginTimelineItem extends PluginTimelineItem {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
export interface TimelineItemTransformInput {
|
||||
item: AgentTimelineItem;
|
||||
phase: "streaming" | "complete";
|
||||
sourceId: string;
|
||||
}
|
||||
|
||||
export type TimelineItemTransform = (
|
||||
item: AgentTimelineItem,
|
||||
input: TimelineItemTransformInput,
|
||||
) => InstalledPluginTimelineItem[] | undefined;
|
||||
|
||||
function isTimelineData(value: unknown, ancestors: Set<object>): value is PluginTimelineData {
|
||||
@@ -46,10 +53,14 @@ function parseTransformResult(value: unknown): PluginTimelineTransformResult {
|
||||
throw new Error('transformed timeline items must have type "plugin"');
|
||||
}
|
||||
const kind = Reflect.get(item, "kind");
|
||||
const id = Reflect.get(item, "id");
|
||||
const version = Reflect.get(item, "version");
|
||||
if (typeof kind !== "string" || !/^[a-z][a-z0-9-]*$/.test(kind)) {
|
||||
throw new Error(`invalid transformed timeline item kind: ${String(kind)}`);
|
||||
}
|
||||
if (id !== undefined && (typeof id !== "string" || id.length === 0)) {
|
||||
throw new Error(`invalid transformed timeline item id: ${String(id)}`);
|
||||
}
|
||||
if (!Number.isInteger(version) || Number(version) < 1) {
|
||||
throw new Error(`invalid transformed timeline item version: ${String(version)}`);
|
||||
}
|
||||
@@ -61,21 +72,22 @@ function parseTransformResult(value: unknown): PluginTimelineTransformResult {
|
||||
}
|
||||
|
||||
export function transformTimelineItem(
|
||||
item: AgentTimelineItem,
|
||||
plugins: readonly InstalledPlugin[],
|
||||
input: TimelineItemTransformInput & { plugins: readonly InstalledPlugin[] },
|
||||
): InstalledPluginTimelineItem[] | undefined {
|
||||
for (const plugin of plugins) {
|
||||
for (const plugin of input.plugins) {
|
||||
for (const transformer of plugin.timelineTransformers) {
|
||||
if (transformer.query.itemType !== item.type) continue;
|
||||
if (transformer.query.itemType !== input.item.type) continue;
|
||||
try {
|
||||
const transform = transformer.transform as (input: {
|
||||
item: AgentTimelineItem;
|
||||
phase: "streaming" | "complete";
|
||||
}) => PluginTimelineTransformResult | undefined;
|
||||
const output = transform({ item });
|
||||
const output = transform({ item: input.item, phase: input.phase });
|
||||
if (output === undefined) continue;
|
||||
const parsed = parseTransformResult(output);
|
||||
return parsed.items.map((transformedItem) => ({
|
||||
return parsed.items.map((transformedItem, index) => ({
|
||||
type: "plugin",
|
||||
id: transformedItem.id ?? `${input.sourceId}/${index}`,
|
||||
kind: transformedItem.kind,
|
||||
version: transformedItem.version,
|
||||
data: JSON.parse(JSON.stringify(transformedItem.data)) as PluginTimelineData,
|
||||
|
||||
@@ -1,101 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { TimelineItemTransform } from "./model";
|
||||
import {
|
||||
processAgentStreamEvent,
|
||||
processTimelineResponse,
|
||||
} from "@/timeline/session-stream-reducers";
|
||||
import { projectPluginTimelineItems } from "./projection";
|
||||
|
||||
const transform: TimelineItemTransform = (item) => {
|
||||
if (item.type !== "tool_call" || item.status === "running") return;
|
||||
return [
|
||||
{
|
||||
type: "plugin",
|
||||
pluginId: "reports",
|
||||
kind: "test-report",
|
||||
version: 1,
|
||||
data: { name: item.name },
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const event: AgentStreamEventPayload = {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call-1",
|
||||
name: "tests",
|
||||
detail: { type: "unknown", input: null, output: null },
|
||||
status: "completed",
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
function thought(text: string, status: "loading" | "ready" = "loading"): StreamItem {
|
||||
return {
|
||||
kind: "thought",
|
||||
id: "thought-1",
|
||||
text,
|
||||
status,
|
||||
timestamp: new Date("2026-01-01T00:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("plugin timeline projection", () => {
|
||||
it("applies the same transform to fetched projected history", () => {
|
||||
const result = processTimelineResponse({
|
||||
it("does not expose mutable tool detail from stream state", () => {
|
||||
const detail = { type: "read" as const, filePath: "/repo/original.ts" };
|
||||
const source: StreamItem = {
|
||||
kind: "tool_call",
|
||||
id: "tool-1",
|
||||
timestamp: new Date("2026-01-01T00:00:00.000Z"),
|
||||
payload: {
|
||||
agentId: "agent-1",
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
reset: true,
|
||||
epoch: "epoch-1",
|
||||
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
|
||||
startCursor: { seq: 1 },
|
||||
endCursor: { seq: 1 },
|
||||
entries: [
|
||||
{
|
||||
seqStart: 1,
|
||||
seqEnd: 1,
|
||||
provider: "claude",
|
||||
item: event.item,
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
hasNewer: false,
|
||||
hasOlder: false,
|
||||
source: "agent",
|
||||
data: {
|
||||
provider: "claude",
|
||||
callId: "call-1",
|
||||
name: "Read",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail,
|
||||
},
|
||||
},
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
initRequestDirection: "tail",
|
||||
sendingClientMessageIds: [],
|
||||
transformTimelineItem: transform,
|
||||
});
|
||||
};
|
||||
const mutateDetail: TimelineItemTransform = ({ item }) => {
|
||||
if (item.type === "tool_call" && item.detail.type === "read") {
|
||||
item.detail.filePath = "/repo/mutated.ts";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
expect(result.tail).toMatchObject([
|
||||
expect(() => projectPluginTimelineItems([source], mutateDetail)).toThrow(TypeError);
|
||||
expect(detail.filePath).toBe("/repo/original.ts");
|
||||
});
|
||||
|
||||
it("projects a streaming reasoning row from its first delta", () => {
|
||||
const transform: TimelineItemTransform = vi.fn(({ item, phase, sourceId }) => [
|
||||
{
|
||||
type: "plugin" as const,
|
||||
id: `${sourceId}/0`,
|
||||
pluginId: "inline-thinking",
|
||||
kind: "reasoning",
|
||||
version: 1,
|
||||
data: { text: item.type === "reasoning" ? item.text : "", phase },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(projectPluginTimelineItems([thought("First")], transform)).toMatchObject([
|
||||
{
|
||||
kind: "plugin",
|
||||
pluginId: "reports",
|
||||
itemKind: "test-report",
|
||||
data: { name: "tests" },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1 },
|
||||
id: "inline-thinking/thought-1/0",
|
||||
data: { text: "First", phase: "streaming" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("requests authoritative projection when a live delta matches", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
event,
|
||||
seq: 1,
|
||||
epoch: "epoch-1",
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
timestamp: new Date("2026-01-01T00:00:00.000Z"),
|
||||
transformTimelineItem: transform,
|
||||
});
|
||||
it("memoizes projection on the source row reference", () => {
|
||||
const source = thought("Stable");
|
||||
const transform: TimelineItemTransform = vi.fn(() => undefined);
|
||||
projectPluginTimelineItems([source], transform);
|
||||
projectPluginTimelineItems([source], transform);
|
||||
|
||||
expect(result.tail).toMatchObject([
|
||||
expect(transform).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reprojects when streaming produces a new source row", () => {
|
||||
const transform: TimelineItemTransform = vi.fn(() => undefined);
|
||||
projectPluginTimelineItems([thought("First")], transform);
|
||||
projectPluginTimelineItems([thought("First second")], transform);
|
||||
|
||||
expect(transform).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("filters and explodes source rows", () => {
|
||||
const source = thought("Split", "ready");
|
||||
const exploded = projectPluginTimelineItems([source], ({ sourceId }) => [
|
||||
{
|
||||
kind: "tool_call",
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1 },
|
||||
type: "plugin",
|
||||
id: `${sourceId}/left`,
|
||||
pluginId: "split",
|
||||
kind: "part",
|
||||
version: 1,
|
||||
data: { side: "left" },
|
||||
},
|
||||
{
|
||||
type: "plugin",
|
||||
id: `${sourceId}/right`,
|
||||
pluginId: "split",
|
||||
kind: "part",
|
||||
version: 1,
|
||||
data: { side: "right" },
|
||||
},
|
||||
]);
|
||||
expect(result.sideEffects).toContainEqual({ type: "reproject" });
|
||||
const filtered = projectPluginTimelineItems([source], () => []);
|
||||
|
||||
expect(exploded.map((item) => item.id)).toEqual([
|
||||
"split/thought-1/left",
|
||||
"split/thought-1/right",
|
||||
]);
|
||||
expect(filtered).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { AgentTimelineItem, ToolCallTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentToolCallData, PluginTimelineStreamItem, StreamItem } from "@/types/stream";
|
||||
import type { TimelineItemTransform } from "./model";
|
||||
|
||||
const projectionCache = new WeakMap<TimelineItemTransform, WeakMap<StreamItem, StreamItem[]>>();
|
||||
|
||||
function cloneAndFreeze<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return Object.freeze(value.map((entry) => cloneAndFreeze(entry))) as T;
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.freeze(
|
||||
Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneAndFreeze(entry)])),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem {
|
||||
const { callId, name, status, error, detail, metadata } = data;
|
||||
const base = {
|
||||
type: "tool_call" as const,
|
||||
callId,
|
||||
name,
|
||||
detail,
|
||||
...(metadata ? { metadata } : {}),
|
||||
};
|
||||
switch (status) {
|
||||
case "running":
|
||||
case "completed":
|
||||
case "canceled":
|
||||
return { ...base, status, error: null };
|
||||
case "failed":
|
||||
return { ...base, status, error };
|
||||
}
|
||||
}
|
||||
|
||||
function sourceTimelineItem(item: StreamItem): AgentTimelineItem | null {
|
||||
switch (item.kind) {
|
||||
case "user_message":
|
||||
return {
|
||||
type: "user_message",
|
||||
text: item.text,
|
||||
...(item.messageId ? { messageId: item.messageId } : {}),
|
||||
...(item.clientMessageId ? { clientMessageId: item.clientMessageId } : {}),
|
||||
};
|
||||
case "assistant_message":
|
||||
return {
|
||||
type: "assistant_message",
|
||||
text: item.text,
|
||||
...(item.messageId ? { messageId: item.messageId } : {}),
|
||||
};
|
||||
case "thought":
|
||||
return { type: "reasoning", text: item.text };
|
||||
case "tool_call": {
|
||||
if (item.payload.source !== "agent") return null;
|
||||
return sourceToolCallTimelineItem(item.payload.data);
|
||||
}
|
||||
case "todo_list":
|
||||
return { type: "todo", items: item.items };
|
||||
case "activity_log":
|
||||
return item.activityType === "error" ? { type: "error", message: item.message } : null;
|
||||
case "compaction":
|
||||
return {
|
||||
type: "compaction",
|
||||
status: item.status,
|
||||
...(item.trigger ? { trigger: item.trigger } : {}),
|
||||
...(item.preTokens !== undefined ? { preTokens: item.preTokens } : {}),
|
||||
};
|
||||
case "plugin":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function transformSourceItem(
|
||||
item: StreamItem,
|
||||
transformTimelineItem: TimelineItemTransform,
|
||||
): StreamItem[] {
|
||||
const source = sourceTimelineItem(item);
|
||||
if (!source) return [item];
|
||||
const isStreamingThought = item.kind === "thought" && item.status === "loading";
|
||||
const isStreamingToolCall =
|
||||
item.kind === "tool_call" &&
|
||||
item.payload.source === "agent" &&
|
||||
item.payload.data.status === "running";
|
||||
const phase = isStreamingThought || isStreamingToolCall ? "streaming" : "complete";
|
||||
const transformed = transformTimelineItem({
|
||||
item: cloneAndFreeze(source),
|
||||
phase,
|
||||
sourceId: item.id,
|
||||
});
|
||||
if (transformed === undefined) return [item];
|
||||
return transformed.map((pluginItem) => {
|
||||
const projected: PluginTimelineStreamItem = {
|
||||
kind: "plugin",
|
||||
id: `${pluginItem.pluginId}/${pluginItem.id}`,
|
||||
timestamp: item.timestamp,
|
||||
pluginId: pluginItem.pluginId,
|
||||
pluginItemId: pluginItem.id,
|
||||
itemKind: pluginItem.kind,
|
||||
version: pluginItem.version,
|
||||
data: pluginItem.data,
|
||||
};
|
||||
if (item.timelineCursor) projected.timelineCursor = item.timelineCursor;
|
||||
if (item.turnId) projected.turnId = item.turnId;
|
||||
return projected;
|
||||
});
|
||||
}
|
||||
|
||||
export function projectPluginTimelineItems(
|
||||
items: StreamItem[],
|
||||
transformTimelineItem: TimelineItemTransform | undefined,
|
||||
): StreamItem[] {
|
||||
if (!transformTimelineItem) return items;
|
||||
let bySource = projectionCache.get(transformTimelineItem);
|
||||
if (!bySource) {
|
||||
bySource = new WeakMap();
|
||||
projectionCache.set(transformTimelineItem, bySource);
|
||||
}
|
||||
let changed = false;
|
||||
const projected = items.flatMap((item) => {
|
||||
const cached = bySource.get(item);
|
||||
if (cached) {
|
||||
changed = changed || cached.length !== 1 || cached[0] !== item;
|
||||
return cached;
|
||||
}
|
||||
const output = transformSourceItem(item, transformTimelineItem);
|
||||
bySource.set(item, output);
|
||||
changed = changed || output.length !== 1 || output[0] !== item;
|
||||
return output;
|
||||
});
|
||||
return changed ? projected : items;
|
||||
}
|
||||
@@ -11,6 +11,26 @@ vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeClient: () => ({ invokePluginRpc: async () => null }) as unknown as DaemonClient,
|
||||
useHosts: () => [{ serverId: "host-1", label: "Local" }],
|
||||
}));
|
||||
vi.mock("@/constants/layout", () => ({
|
||||
useIsCompactFormFactor: () => false,
|
||||
}));
|
||||
vi.mock("../navigation", () => ({
|
||||
createPluginNavigation: () => ({}),
|
||||
}));
|
||||
vi.mock("../icons", () => ({
|
||||
Icon: () => null,
|
||||
resolvePluginIcon: () => () => null,
|
||||
}));
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: (theme: object) => object) =>
|
||||
factory({
|
||||
colors: { foregroundMuted: "gray", statusDanger: "red" },
|
||||
spacing: [0, 4, 8, 12, 16],
|
||||
}),
|
||||
},
|
||||
withUnistyles: (Component: React.ComponentType) => Component,
|
||||
}));
|
||||
|
||||
import { pluginRegistry } from "../registry";
|
||||
import { PluginTimelineItemView } from "./view";
|
||||
@@ -44,15 +64,35 @@ const failingBundle = `(function() {
|
||||
} };
|
||||
})`;
|
||||
|
||||
const recoveringBundle = `(function(require) {
|
||||
const React = require("react");
|
||||
return { default: function(plugin) {
|
||||
function RecoveringCard(props) {
|
||||
if (props.item.data.label === "explode") throw new Error("transient renderer failure");
|
||||
return React.createElement("span", null, props.item.data.label);
|
||||
}
|
||||
plugin.addTimelineRenderer({
|
||||
kind: "test-report",
|
||||
version: 1,
|
||||
schema: { safeParse(value) { return { success: true, data: value }; } },
|
||||
Component: RecoveringCard,
|
||||
});
|
||||
return function() {};
|
||||
} };
|
||||
})`;
|
||||
|
||||
const timelineItem = {
|
||||
kind: "plugin" as const,
|
||||
id: "item-1",
|
||||
pluginId: "reports",
|
||||
pluginItemId: "item-1",
|
||||
itemKind: "test-report",
|
||||
version: 1,
|
||||
data: { label: "Four tests passed" },
|
||||
timestamp: new Date("2026-01-01T00:00:00.000Z"),
|
||||
};
|
||||
const failingTimelineItem = { ...timelineItem, data: { label: "explode" } };
|
||||
const recoveredTimelineItem = { ...timelineItem, data: { label: "Recovered" } };
|
||||
|
||||
const roots: Array<ReturnType<typeof createRoot>> = [];
|
||||
const containers: HTMLElement[] = [];
|
||||
@@ -104,4 +144,28 @@ describe("PluginTimelineItemView", () => {
|
||||
expect(container.textContent).toContain("Plugin failed: timeline renderer exploded");
|
||||
expect(container.textContent).toContain("Message after");
|
||||
});
|
||||
|
||||
it("recovers when a streaming item's data changes", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: recoveringBundle }]);
|
||||
const container = document.createElement("div");
|
||||
containers.push(container);
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<PluginTimelineItemView serverId="host-1" agentId="agent-1" item={failingTimelineItem} />,
|
||||
);
|
||||
});
|
||||
expect(container.textContent).toBe("Plugin failed: transient renderer failure");
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<PluginTimelineItemView serverId="host-1" agentId="agent-1" item={recoveredTimelineItem} />,
|
||||
);
|
||||
});
|
||||
expect(container.textContent).toBe("Recovered");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,7 +89,7 @@ function PluginTimelineItemBody({
|
||||
},
|
||||
};
|
||||
return (
|
||||
<SurfaceErrorBoundary installation={plugin} Surface={Component}>
|
||||
<SurfaceErrorBoundary installation={plugin} resetKey={item.data} Surface={Component}>
|
||||
<PluginRuntimeBoundary plugin={plugin} runtime={runtime}>
|
||||
<PluginClientStateProvider source={stateSource}>
|
||||
<Component {...props} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginCommandCenterItemContribution,
|
||||
PluginClientContribution,
|
||||
PluginClientSlashCommandContribution,
|
||||
PluginCleanup,
|
||||
PluginComposerPillContribution,
|
||||
PluginSidebarContribution,
|
||||
@@ -25,6 +26,7 @@ export interface EvaluatedPlugin {
|
||||
sidebarItems: PluginSidebarContribution[];
|
||||
workspacePanels: EvaluatedPluginWorkspacePanelContribution[];
|
||||
commandCenterItems: PluginCommandCenterItemContribution[];
|
||||
clientSlashCommands?: PluginClientSlashCommandContribution[];
|
||||
clientSide: PluginClientContribution | null;
|
||||
attachmentSources: PluginAttachmentSourceContribution[];
|
||||
themes: PluginThemeContribution[];
|
||||
@@ -41,6 +43,7 @@ export interface InstalledPlugin extends EvaluatedPlugin {
|
||||
export type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginCommandCenterItemContribution,
|
||||
PluginClientSlashCommandContribution,
|
||||
PluginComposerPillContribution,
|
||||
PluginSidebarContribution,
|
||||
PluginSurfaceContribution,
|
||||
|
||||
Reference in New Issue
Block a user