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:
Mohamed Boudra
2026-09-02 13:12:09 +02:00
committed by GitHub
parent 49784fac15
commit e34aea206e
66 changed files with 2122 additions and 511 deletions
+1 -7
View File
@@ -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);
}
}
+25
View File
@@ -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 });
}
+10 -12
View File
@@ -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]);
}
+113 -55
View File
@@ -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");
});
});
+19 -7
View File
@@ -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");
});
});
+1 -1
View File
@@ -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
View File
@@ -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,