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
+11 -1
View File
@@ -339,6 +339,15 @@ export interface CompactionTimelineItem {
preTokens?: number;
}
export interface PluginTimelineItem {
type: "plugin";
id: string;
pluginId: string;
kind: string;
version: number;
data: JsonValue;
}
export interface AgentTaskItem {
text: string;
completed: boolean;
@@ -354,7 +363,8 @@ export type AgentTimelineItem =
| ToolCallTimelineItem
| { type: "todo"; items: AgentTaskItem[] }
| { type: "error"; message: string }
| CompactionTimelineItem;
| CompactionTimelineItem
| PluginTimelineItem;
export type AgentStreamEvent =
| { type: "thread_started"; sessionId: string; provider: AgentProvider }
@@ -7,6 +7,50 @@ import {
} from "./messages.js";
describe("plugin protocol compatibility", () => {
it("parses plugin timeline append messages and advertises the capability", () => {
expect(
SessionInboundMessageSchema.parse({
type: "agent.timeline.append.request",
requestId: "request-append",
agentId: "agent-1",
item: {
type: "plugin",
id: "review-1",
kind: "review",
version: 1,
data: { status: "running" },
},
}),
).toMatchObject({ type: "agent.timeline.append.request" });
expect(
SessionOutboundMessageSchema.parse({
type: "agent.timeline.append.response",
payload: { requestId: "request-append", seq: 7, epoch: "epoch-1" },
}),
).toMatchObject({ type: "agent.timeline.append.response" });
expect(
StatusMessageSchema.parse({
type: "status",
payload: {
status: "server_info",
serverId: "server-1",
features: { pluginTimelineItems: true },
},
}).payload,
).toMatchObject({ features: { pluginTimelineItems: true } });
});
it.each([0, -1, 1.5])("rejects plugin timeline version %s", (version) => {
expect(() =>
SessionInboundMessageSchema.parse({
type: "agent.timeline.append.request",
requestId: "append-1",
agentId: "agent-1",
item: { type: "plugin", id: "row-1", kind: "review", version, data: {} },
}),
).toThrow();
});
it("keeps old directory plugin config valid when enabled is absent", () => {
const config = MutableDaemonConfigSchema.parse({
mcp: { injectIntoAgents: true },
+39 -1
View File
@@ -286,8 +286,13 @@ import type {
ToolCallDetail,
ToolCallTimelineItem,
AgentUsage,
JsonValue,
} from "./agent-types.js";
// WebSocket payloads have already crossed JSON serialization. Keeping this as
// unknown avoids zod-aot's recursive z.json() object-codegen regression.
const JsonWireValueSchema = z.unknown() as z.ZodType<JsonValue>;
export const AgentStatusSchema = z.enum(AGENT_LIFECYCLE_STATUSES);
const AgentModeSchema: z.ZodType<AgentMode> = z.object({
@@ -733,6 +738,14 @@ export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem, unknow
trigger: z.enum(["auto", "manual"]).optional(),
preTokens: z.number().optional(),
}),
z.object({
type: z.literal("plugin"),
id: z.string(),
pluginId: PluginIdSchema,
kind: z.string(),
version: z.number(),
data: JsonWireValueSchema,
}),
]);
export const AgentStreamEventPayloadSchema = z.discriminatedUnion("type", [
@@ -1498,6 +1511,19 @@ export const PluginRpcInvokeRequestSchema = z.object({
input: z.unknown(),
});
export const AgentTimelineAppendRequestSchema = z.object({
type: z.literal("agent.timeline.append.request"),
requestId: z.string(),
agentId: z.string(),
item: z.object({
type: z.literal("plugin"),
id: z.string(),
kind: z.string(),
version: z.number().int().positive(),
data: JsonWireValueSchema,
}),
});
export const AgentSkillOperationSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("add"), name: z.string() }).strict(),
z.object({ kind: z.literal("update"), name: z.string() }).strict(),
@@ -3048,6 +3074,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
PluginDisableRequestSchema,
PluginRemoveRequestSchema,
PluginRpcInvokeRequestSchema,
AgentTimelineAppendRequestSchema,
AgentSkillsGetStatusRequestSchema,
AgentSkillsReconcileRequestSchema,
AgentSkillsUninstallRequestSchema,
@@ -3403,6 +3430,7 @@ export const ServerInfoStatusPayloadSchema = z
// A daemon that predates this flag keeps `addTheme` in the server bundle it compiles,
// so a theme plugin cannot start there at all.
pluginThemes: z.boolean().optional(),
pluginTimelineItems: z.boolean().optional(),
// COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16.
skillManagement: z.boolean().optional(),
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
@@ -4318,7 +4346,7 @@ export const AgentTimelineEntryPayloadSchema = z.object({
seqStart: z.number().int().nonnegative(),
seqEnd: z.number().int().nonnegative(),
sourceSeqRanges: z.array(AgentTimelineSeqRangeSchema),
collapsed: z.array(z.enum(["assistant_merge", "reasoning_merge", "tool_lifecycle"])),
collapsed: z.array(z.enum(["assistant_merge", "reasoning_merge", "tool_lifecycle", "identity"])),
});
export const FetchAgentTimelineResponseMessageSchema = z.object({
@@ -6269,6 +6297,15 @@ export const PluginRpcInvokeResponseSchema = z.object({
}),
});
export const AgentTimelineAppendResponseSchema = z.object({
type: z.literal("agent.timeline.append.response"),
payload: z.object({
requestId: z.string(),
seq: z.number().int().nonnegative(),
epoch: z.string(),
}),
});
function agentSkillsStatusResponse<const Type extends string>(type: Type) {
return z.object({
type: z.literal(type),
@@ -6318,6 +6355,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
PluginDisableResponseSchema,
PluginRemoveResponseSchema,
PluginRpcInvokeResponseSchema,
AgentTimelineAppendResponseSchema,
AgentSkillsGetStatusResponseSchema,
AgentSkillsReconcileResponseSchema,
AgentSkillsUninstallResponseSchema,
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { timelineItemIdentity } from "./timeline-identity.js";
describe("timelineItemIdentity", () => {
it("uses tool call ids and plugin-scoped ids", () => {
expect(
timelineItemIdentity({
type: "tool_call",
callId: "call-1",
name: "read",
detail: { type: "unknown", input: null, output: null },
status: "running",
error: null,
}),
).toBe("call-1");
expect(
timelineItemIdentity({
type: "plugin",
id: "row-1",
pluginId: "review",
kind: "review",
version: 1,
data: {},
}),
).toBe("review/row-1");
expect(timelineItemIdentity({ type: "reasoning", text: "hi" })).toBeNull();
});
});
@@ -0,0 +1,7 @@
import type { AgentTimelineItem } from "./agent-types.js";
export function timelineItemIdentity(item: AgentTimelineItem): string | null {
if (item.type === "tool_call") return item.callId;
if (item.type === "plugin") return `${item.pluginId}/${item.id}`;
return null;
}